"""Reusable Python client for the hosted QUBE Survey API.

This is the single API implementation for repository Python tooling. The VS
Code extension has a TypeScript peer because it runs in the editor process;
both clients are contract-tested against the same hosted routes.
"""

from __future__ import annotations

import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
from typing import Any, Callable

DEFAULT_SERVICE = "https://app.qubesurvey.com"
CLIENT_ID = "qube-survey-vscode"
USER_AGENT = "qube-python-client/1.0 (+https://qubesurvey.com)"


class ApiError(RuntimeError):
    def __init__(self, message: str, status: int, code: str | None = None):
        super().__init__(message)
        self.status = status
        self.code = code


def request(
    url: str,
    *,
    method: str = "GET",
    token: str | None = None,
    body: dict[str, Any] | None = None,
    data: bytes | None = None,
    content_type: str | None = None,
) -> Any:
    """Make one JSON API request.

    The full-URL form intentionally remains public: migration tools sometimes
    work across a caller-selected service URL before constructing a client.
    """
    if body is not None and data is not None:
        raise ValueError("Pass body or data, not both.")
    payload = json.dumps(body).encode() if body is not None else data
    headers = {"user-agent": USER_AGENT}
    if body is not None:
        headers["content-type"] = "application/json"
    elif content_type:
        headers["content-type"] = content_type
    if token:
        headers["authorization"] = f"Bearer {token}"
    req = urllib.request.Request(url, data=payload, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=60) as response:
            raw = response.read()
            return json.loads(raw) if raw.strip() else None
    except urllib.error.HTTPError as error:
        raw = error.read().decode(errors="replace")
        try:
            detail = json.loads(raw)
        except json.JSONDecodeError:
            detail = {}
        code = detail.get("error") if isinstance(detail.get("error"), str) else None
        message = (
            detail.get("error_description")
            or detail.get("message")
            or code
            or raw[:400]
            or f"HTTP {error.code}"
        )
        raise ApiError(str(message), error.code, code) from error
    except urllib.error.URLError as error:
        raise ApiError(f"Could not reach QUBE Survey: {error.reason}", 0, "network_error") from error


def authorize(
    service: str = DEFAULT_SERVICE,
    *,
    announce: Callable[[dict[str, Any]], None] | None = None,
) -> str:
    """Complete RFC 8628 device authorization and return an access token."""
    service = service.rstrip("/")
    device = request(
        f"{service}/api/auth/device/code",
        method="POST",
        body={"client_id": CLIENT_ID},
    )
    if announce:
        announce(device)
    else:
        print("Authorize this QUBE Survey client:", flush=True)
        print(f"  {device['verification_uri_complete']}", flush=True)
        print(f"  code: {device['user_code']}", flush=True)
        print("Waiting for authorization...", flush=True)

    deadline = time.time() + int(device["expires_in"])
    interval = max(int(device.get("interval", 5)), 1)
    while time.time() < deadline:
        time.sleep(interval)
        try:
            token = request(
                f"{service}/api/auth/device/token",
                method="POST",
                body={
                    "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                    "device_code": device["device_code"],
                    "client_id": CLIENT_ID,
                },
            )
            return str(token["access_token"])
        except ApiError as error:
            if error.code == "authorization_pending":
                continue
            if error.code == "slow_down":
                interval += 5
                continue
            raise
    raise ApiError("Authorization expired. Run the command again.", 408, "expired_token")


class QubeSurveyClient:
    def __init__(self, token: str, service: str = DEFAULT_SERVICE):
        self.token = token
        self.service = service.rstrip("/")

    def _request(
        self,
        path: str,
        *,
        method: str = "GET",
        body: dict[str, Any] | None = None,
        data: bytes | None = None,
        content_type: str | None = None,
    ) -> Any:
        return request(
            f"{self.service}{path}",
            method=method,
            token=self.token,
            body=body,
            data=data,
            content_type=content_type,
        )

    def list_projects(self) -> list[dict[str, Any]]:
        return self._request("/api/projects")["projects"]

    def create_project(self, name: str) -> dict[str, Any]:
        """Create a hosted project through the same route as the VS Code client."""
        return self._request(
            "/api/projects",
            method="POST",
            body={"name": name},
        )["project"]

    def list_versions(self, project_id: str) -> list[dict[str, Any]]:
        return self._request(f"/api/projects/{_quote(project_id)}/versions")["versions"]

    def get_version(self, project_id: str, version_id: str) -> dict[str, Any]:
        return self._request(
            f"/api/projects/{_quote(project_id)}/versions/{_quote(version_id)}"
        )["version"]

    def list_question_reviews(
        self,
        project_id: str,
        version_id: str,
        *,
        question: str | None = None,
        source_line: int | None = None,
    ) -> list[dict[str, Any]]:
        query: dict[str, str | int] = {}
        if question:
            query["question"] = question
        if source_line is not None:
            query["line"] = source_line
        suffix = f"?{urllib.parse.urlencode(query)}" if query else ""
        return self._request(
            f"/api/projects/{_quote(project_id)}/versions/{_quote(version_id)}/reviews{suffix}"
        )["reviews"]

    def create_version(
        self,
        *,
        project_id: str,
        instrument_id: str,
        source_name: str,
        source: str,
        language: str,
    ) -> dict[str, Any]:
        boundary = f"----qube-{uuid.uuid4().hex}"
        fields = {
            "instrumentId": instrument_id,
            "sourceName": source_name,
            "source": source,
            "language": language,
        }
        parts: list[bytes] = []
        for name, value in fields.items():
            parts.extend(
                [
                    f"--{boundary}\r\n".encode(),
                    f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode(),
                    value.encode(),
                    b"\r\n",
                ]
            )
        parts.append(f"--{boundary}--\r\n".encode())
        return self._request(
            f"/api/projects/{_quote(project_id)}/versions",
            method="POST",
            data=b"".join(parts),
            content_type=f"multipart/form-data; boundary={boundary}",
        )["version"]

    def list_tickets(
        self,
        project_id: str,
        *,
        status: str | None = None,
        version_id: str | None = None,
    ) -> list[dict[str, Any]]:
        query = {}
        if status:
            query["status"] = status
        if version_id:
            query["version"] = version_id
        suffix = f"?{urllib.parse.urlencode(query)}" if query else ""
        return self._request(
            f"/api/projects/{_quote(project_id)}/tickets{suffix}"
        )["tickets"]

    def list_system_tickets(self, *, status: str | None = None) -> list[dict[str, Any]]:
        suffix = f"?{urllib.parse.urlencode({'status': status})}" if status else ""
        return self._request(f"/api/tickets{suffix}")["tickets"]

    def create_project_ticket(
        self, project_id: str, *, title: str, description: str
    ) -> dict[str, Any]:
        return self._request(
            f"/api/projects/{_quote(project_id)}/tickets",
            method="POST",
            body={"scope": "project", "title": title, "description": description},
        )["ticket"]

    def create_system_ticket(self, *, title: str, description: str) -> dict[str, Any]:
        return self._request(
            "/api/tickets",
            method="POST",
            body={"scope": "system", "title": title, "description": description},
        )["ticket"]

    def get_ticket(self, project_id: str, ticket_id: str) -> dict[str, Any]:
        return self._request(
            f"/api/projects/{_quote(project_id)}/tickets/{_quote(ticket_id)}"
        )["ticket"]

    def update_ticket(
        self,
        project_id: str,
        ticket_id: str,
        **changes: Any,
    ) -> dict[str, Any]:
        return self._request(
            f"/api/projects/{_quote(project_id)}/tickets/{_quote(ticket_id)}",
            method="PATCH",
            body=changes,
        )["ticket"]

    def add_ticket_comment(
        self,
        project_id: str,
        ticket_id: str,
        body: str,
    ) -> dict[str, Any]:
        return self._request(
            f"/api/projects/{_quote(project_id)}/tickets/{_quote(ticket_id)}/comments",
            method="POST",
            body={"body": body},
        )["comment"]

    def get_system_ticket(self, ticket_id: str) -> dict[str, Any]:
        return self._request(f"/api/tickets/{_quote(ticket_id)}")["ticket"]

    def update_system_ticket(self, ticket_id: str, *, status: str) -> dict[str, Any]:
        return self._request(
            f"/api/tickets/{_quote(ticket_id)}",
            method="PATCH",
            body={"status": status},
        )["ticket"]

    def add_system_ticket_comment(self, ticket_id: str, body: str) -> dict[str, Any]:
        return self._request(
            f"/api/tickets/{_quote(ticket_id)}/comments",
            method="POST",
            body={"body": body},
        )["comment"]


def load_client(
    token_path: Path,
    service: str = DEFAULT_SERVICE,
) -> QubeSurveyClient:
    """Load a cached token, refreshing through device auth when necessary."""
    token = None
    try:
        token = json.loads(token_path.read_text(encoding="utf-8")).get("accessToken")
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        pass
    if isinstance(token, str):
        client = QubeSurveyClient(token, service)
        try:
            client.list_projects()
            return client
        except ApiError as error:
            if error.status != 401:
                raise
            token_path.unlink(missing_ok=True)

    token = authorize(service)
    token_path.parent.mkdir(parents=True, exist_ok=True)
    token_path.write_text(json.dumps({"accessToken": token}) + "\n", encoding="utf-8")
    os.chmod(token_path, 0o600)
    return QubeSurveyClient(token, service)


def _quote(value: str) -> str:
    return urllib.parse.quote(value, safe="")
