#!/usr/bin/env python3
"""
TickTick OAuth 2.0 Authentication Script

Performs the one-time OAuth flow to obtain access and refresh tokens from TickTick.
Reads client credentials from the SOPS vault, opens a browser for user authorization,
captures the callback on a local HTTP server, exchanges the code for tokens, and
saves them to an encrypted local file.

Usage:
    python ticktick_auth.py
"""

import json
import os
import secrets
import stat
import subprocess
import sys
import threading
import time
import webbrowser
from datetime import datetime, timezone
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlencode, urlparse, parse_qs
from urllib.request import Request, urlopen
from html import escape as html_escape
from urllib.error import URLError, HTTPError

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

VAULT_SCRIPT = "D:/vault/scripts/vault.sh"
VAULT_ENTRY = "services/ticktick.sops.yaml"

AUTH_URL = "https://ticktick.com/oauth/authorize"
TOKEN_URL = "https://ticktick.com/oauth/token"
REDIRECT_URI = "http://localhost:9876/callback"
SCOPES = "tasks:read tasks:write"
CALLBACK_PORT = 9876
CALLBACK_TIMEOUT_SECONDS = 60

TOKEN_FILE = Path(__file__).resolve().parent / ".tokens.json"


# ---------------------------------------------------------------------------
# Vault credential retrieval
# ---------------------------------------------------------------------------

def vault_get_field(field: str) -> str:
    """Retrieve a single field from the SOPS vault entry."""
    try:
        result = subprocess.run(
            ["bash", VAULT_SCRIPT, "get-field", VAULT_ENTRY, field],
            capture_output=True,
            text=True,
            timeout=15,
        )
    except FileNotFoundError:
        print(f"[ERROR] Could not find bash or vault script at {VAULT_SCRIPT}")
        sys.exit(1)
    except subprocess.TimeoutExpired:
        print(f"[ERROR] Vault command timed out while retrieving {field}")
        sys.exit(1)

    if result.returncode != 0:
        stderr = result.stderr.strip()
        print(f"[ERROR] Vault returned non-zero exit code for field '{field}'")
        if stderr:
            print(f"        {stderr}")
        sys.exit(1)

    value = result.stdout.strip()
    if not value:
        print(f"[ERROR] Vault returned empty value for field '{field}'")
        sys.exit(1)

    return value


# ---------------------------------------------------------------------------
# Callback HTTP server
# ---------------------------------------------------------------------------

class _CallbackState:
    """Shared mutable state between the HTTP handler and the main thread."""

    def __init__(self) -> None:
        self.authorization_code: str | None = None
        self.error: str | None = None
        self.received = threading.Event()


class _CallbackHandler(BaseHTTPRequestHandler):
    """Handles the OAuth redirect callback from TickTick."""

    state: _CallbackState  # set on the class before the server starts
    expected_csrf: str      # set on the class before the server starts

    def do_GET(self) -> None:  # noqa: N802 – required method name
        parsed = urlparse(self.path)
        if parsed.path != "/callback":
            self._respond(404, "Not found")
            return

        params = parse_qs(parsed.query)

        # Check for error response from provider
        if "error" in params:
            error_msg = params["error"][0]
            description = params.get("error_description", [""])[0]
            self.state.error = f"{error_msg}: {description}" if description else error_msg
            self._respond(400, f"Authorization failed: {self.state.error}")
            self.state.received.set()
            return

        # Validate CSRF state parameter
        returned_state = params.get("state", [None])[0]
        if returned_state != self.expected_csrf:
            self.state.error = "CSRF state mismatch -- possible request forgery"
            self._respond(400, self.state.error)
            self.state.received.set()
            return

        # Extract authorization code
        code = params.get("code", [None])[0]
        if not code:
            self.state.error = "No authorization code in callback"
            self._respond(400, self.state.error)
            self.state.received.set()
            return

        self.state.authorization_code = code
        self._respond(
            200,
            "Authorization successful! You can close this tab and return to the terminal.",
        )
        self.state.received.set()

    def _respond(self, status: int, body: str) -> None:
        self.send_response(status)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        html = (
            "<!DOCTYPE html><html><head><title>TickTick Auth</title></head>"
            f"<body><h2>{html_escape(body)}</h2></body></html>"
        )
        self.wfile.write(html.encode("utf-8"))

    # Silence default request logging
    def log_message(self, format: str, *args: object) -> None:  # noqa: A002
        pass


# ---------------------------------------------------------------------------
# Token exchange
# ---------------------------------------------------------------------------

def exchange_code_for_tokens(
    code: str,
    client_id: str,
    client_secret: str,
) -> dict:
    """Exchange an authorization code for access and refresh tokens."""
    body = urlencode({
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "client_id": client_id,
        "client_secret": client_secret,
        "scope": SCOPES,
    }).encode("utf-8")

    request = Request(
        TOKEN_URL,
        data=body,
        headers={
            "Content-Type": "application/x-www-form-urlencoded",
            "Accept": "application/json",
        },
        method="POST",
    )

    try:
        with urlopen(request, timeout=15) as response:
            data = json.loads(response.read().decode("utf-8"))
    except HTTPError as exc:
        error_body = exc.read().decode("utf-8", errors="replace")
        print(f"[ERROR] Token exchange failed (HTTP {exc.code})")
        print(f"        Response: {error_body}")
        sys.exit(1)
    except URLError as exc:
        print(f"[ERROR] Could not reach token endpoint: {exc.reason}")
        sys.exit(1)
    except json.JSONDecodeError:
        print("[ERROR] Token endpoint returned invalid JSON")
        sys.exit(1)

    if "access_token" not in data:
        print("[ERROR] Token response missing 'access_token'")
        print(f"        Full response: {json.dumps(data, indent=2)}")
        sys.exit(1)

    return data


# ---------------------------------------------------------------------------
# Token persistence
# ---------------------------------------------------------------------------

def save_tokens(token_data: dict) -> None:
    """Persist tokens to a local JSON file."""
    payload = {
        "access_token": token_data["access_token"],
        "refresh_token": token_data.get("refresh_token", ""),
        "token_type": token_data.get("token_type", "bearer"),
        "obtained_at": datetime.now(timezone.utc).isoformat(),
    }

    TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
    TOKEN_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    # Restrict file permissions (owner read/write only)
    try:
        TOKEN_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
    except OSError:
        pass  # Windows may not support POSIX permissions
    print(f"[OK] Tokens saved to {TOKEN_FILE}")


# ---------------------------------------------------------------------------
# Main flow
# ---------------------------------------------------------------------------

def main() -> None:
    print("[INFO] TickTick OAuth 2.0 Authentication")
    print("=" * 50)

    # -- 1. Read credentials from SOPS vault ----------------------------------
    print("[INFO] Reading credentials from SOPS vault ...")
    client_id = vault_get_field("credentials.client_id")
    client_secret = vault_get_field("credentials.client_secret")
    print(f"[OK] Client ID retrieved (ends ...{client_id[-4:]})")

    # -- 2. Prepare CSRF state and authorization URL --------------------------
    csrf_state = secrets.token_urlsafe(32)

    auth_params = urlencode({
        "client_id": client_id,
        "redirect_uri": REDIRECT_URI,
        "response_type": "code",
        "scope": SCOPES,
        "state": csrf_state,
    })
    full_auth_url = f"{AUTH_URL}?{auth_params}"

    # -- 3. Start local callback server ---------------------------------------
    callback_state = _CallbackState()
    _CallbackHandler.state = callback_state
    _CallbackHandler.expected_csrf = csrf_state

    try:
        server = HTTPServer(("127.0.0.1", CALLBACK_PORT), _CallbackHandler)
    except OSError as exc:
        print(f"[ERROR] Could not start callback server on port {CALLBACK_PORT}: {exc}")
        sys.exit(1)

    server_thread = threading.Thread(target=server.serve_forever, daemon=True)
    server_thread.start()
    print(f"[OK] Callback server listening on http://127.0.0.1:{CALLBACK_PORT}/callback")

    # -- 4. Open browser for authorization ------------------------------------
    print("[INFO] Opening browser for TickTick authorization ...")
    print(f"[INFO] If the browser does not open, visit this URL manually:")
    print(f"       {full_auth_url}")
    webbrowser.open(full_auth_url)

    # -- 5. Wait for callback -------------------------------------------------
    print(f"[INFO] Waiting up to {CALLBACK_TIMEOUT_SECONDS}s for authorization callback ...")
    received = callback_state.received.wait(timeout=CALLBACK_TIMEOUT_SECONDS)
    server.shutdown()

    if not received:
        print(f"[ERROR] Timed out after {CALLBACK_TIMEOUT_SECONDS}s waiting for callback")
        print("        Make sure you completed the authorization in your browser.")
        sys.exit(1)

    if callback_state.error:
        print(f"[ERROR] Authorization failed: {callback_state.error}")
        sys.exit(1)

    code = callback_state.authorization_code
    if not code:
        print("[ERROR] No authorization code received (unknown error)")
        sys.exit(1)

    print("[OK] Authorization code received")

    # -- 6. Exchange code for tokens ------------------------------------------
    print("[INFO] Exchanging authorization code for tokens ...")
    token_data = exchange_code_for_tokens(code, client_id, client_secret)
    print("[OK] Token exchange successful")

    # -- 7. Save tokens -------------------------------------------------------
    save_tokens(token_data)

    print("=" * 50)
    print("[OK] TickTick authentication complete")


if __name__ == "__main__":
    main()
