#!/usr/bin/env python3
"""
md2gost API — simple command-line demo client.

Uploads a Markdown (GOST-style, *.g.md) project to the md2gost REST API as a
ZIP, polls for completion, and downloads the rendered PDF/DOCX result.

Only the Python standard library is used, so there are no external dependencies.

Usage:
    python examples/demo_client.py
    python examples/demo_client.py --base-url http://localhost:3000 \
        --api-key sk_xxx --format pdf --project ./docs/project-a \
        --output ./result.pdf

Features:
  * Prompts for API key (hidden input) if not provided via CLI/env.
  * Lets the user pick a project root folder.
  * If the folder contains several *.g.md files, shows an interactive menu to
    select which one becomes the root document. The chosen file is renamed to
    "main.g.md" inside the ZIP sent to the API (required by the worker).
  * Displays live progress while the job is processed.
  * Auto-generates the result filename from the selected main document
    (override with --output).
  * Handles Ctrl+C gracefully: offers to cancel the in-flight job before exit.
"""

from __future__ import annotations

import argparse
import getpass
import io
import json
import os
import re
import sys
import time
import uuid
import zipfile
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

# Ensure Unicode glyphs (tree characters, symbols) render on consoles that use
# legacy codepages (e.g. cp1251/cp437). Safe for piping; undecodable bytes are
# replaced rather than raising.
for _stream in (sys.stdout, sys.stderr):
    reconfigure = getattr(_stream, "reconfigure", None)
    if reconfigure is not None:
        reconfigure(encoding="utf-8", errors="replace")

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

DEFAULT_BASE_URL = "https://md2gost.duckdns.org"
POLL_INTERVAL_SECONDS = 2.0
API_TIMEOUT_SECONDS = 60

TERMINAL_STATUSES = {"completed", "failed", "cancelled"}

# Friendly messages for known API error codes.
KNOWN_ERRORS = {
    "invalid_api_key": "The API key is invalid or revoked.",
    "missing_api_key": "No API key was provided.",
    "invalid_idempotency_key": "The Idempotency-Key header is malformed.",
    "idempotency_key_reused": "The Idempotency-Key was already used with different data.",
    "job_quota_exceeded": "Too many jobs are already queued or running for this API key.",
    "global_job_limit_exceeded": "The service queue is currently full; try again later.",
    "invalid_format": "Unsupported output format. Use 'pdf' or 'docx'.",
    "invalid_content_type": "The request was not sent as multipart/form-data.",
    "invalid_file_type": "The uploaded file must be a ZIP archive.",
    "missing_file": "No 'file' field was found in the upload.",
    "file_too_large": "The uploaded ZIP exceeds the maximum allowed size.",
    "timeout": "The upload timed out; please try again.",
    "archive": "Failed to extract the archive. It may be corrupted, password-protected, or unsupported.",
    "archive:no_main": "No root document main.g.md was found in the archive.",
    "render": "An unexpected error occurred during rendering.",
    "render:bad_input": "The document contains syntax errors, invalid references, or unsupported features.",
    "render:timeout": "Rendering timed out; the document is too complex.",
    "output_too_large": "The rendered document exceeds the maximum allowed output size.",
}


class ApiError(Exception):
    """Raised when the API returns a non-success status."""

    def __init__(self, status: int, error: str, message: str | None):
        super().__init__(message or error)
        self.status = status
        self.error = error
        self.message = message

    def friendly_message(self) -> str:
        return KNOWN_ERRORS.get(self.error, self.message or f"Unknown error '{self.error}'")


class ClientError(Exception):
    """Raised for local problems that are not API responses (network, config)."""


def _human_error(error: str | None) -> str:
    return KNOWN_ERRORS.get(error or "", error or "Unknown processing error occurred.")


# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------


def _print_status(text: str) -> None:
    print(f"  \u2022 {text}")


def _run_request(request: Request, *, binary: bool = False) -> tuple[int, bytes]:
    """
    Perform an HTTP request and return (status, body).

    Raises ApiError for HTTP error statuses and ClientError for network errors.
    """
    try:
        with urlopen(request, timeout=API_TIMEOUT_SECONDS) as resp:
            return resp.status, resp.read()
    except HTTPError as exc:
        body = exc.read() if exc.fp else b""
        try:
            payload = json.loads(body) if body else {}
        except json.JSONDecodeError:
            payload = {}
        raise ApiError(
            exc.code,
            payload.get("error", "http_error"),
            payload.get("message"),
        ) from exc
    except URLError as exc:
        raise ClientError(f"Could not reach the API: {exc.reason}") from exc
    except TimeoutError as exc:
        raise ClientError("The request timed out while talking to the API.") from exc


# ---------------------------------------------------------------------------
# Config / interaction
# ---------------------------------------------------------------------------


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description="md2gost API demo client.")
    parser.add_argument("--base-url", default=None, help=f"API base URL (default env MD2GOST_BASE_URL or {DEFAULT_BASE_URL}).")
    parser.add_argument("--api-key", default=None, help="API key (default env MD2GOST_API_KEY; prompted if missing).")
    parser.add_argument("--format", choices=["pdf", "docx"], default=None, help="Output format (default pdf).")
    parser.add_argument("--project", default=None, help="Path to the project root folder.")
    parser.add_argument("--output", default=None, help="Where to save the result. Default: auto-named in CWD.")
    parser.add_argument(
        "--poll-interval", type=float, default=POLL_INTERVAL_SECONDS, help=f"Seconds between status polls (default {POLL_INTERVAL_SECONDS})."
    )
    return parser


def prompt_config(args: argparse.Namespace) -> dict:
    base_url = args.base_url or os.environ.get("MD2GOST_BASE_URL") or DEFAULT_BASE_URL
    base_url = base_url.rstrip("/")

    api_key = args.api_key or os.environ.get("MD2GOST_API_KEY")
    if not api_key:
        api_key = getpass.getpass("Enter API key: ").strip()
    if not api_key:
        raise ClientError("An API key is required.")

    output_format = args.format or "pdf"

    return {"base_url": base_url, "api_key": api_key, "format": output_format}


def pick_project_root(args: argparse.Namespace) -> Path:
    candidate = args.project
    if not candidate:
        print("Available subfolders under the current directory:")
        entries = sorted(p for p in Path(".").iterdir() if p.is_dir() and not p.name.startswith("."))
        if entries:
            for i, entry in enumerate(entries, start=1):
                print(f"  [{i}] {entry}")
            print("  [0] Enter a custom path")
            choice = input("\nSelect project root [0]: ").strip()
            if choice.isdigit():
                idx = int(choice)
                if 1 <= idx <= len(entries):
                    candidate = str(entries[idx - 1])

        if not candidate:
            candidate = input("Enter project root path: ").strip()

    root = Path(candidate).expanduser()
    if not root.exists() or not root.is_dir():
        raise ClientError(f"Project root is not a valid directory: {root}")
    return root.resolve()


def find_gmd_files(root: Path) -> list[Path]:
    files = sorted(p for p in root.rglob("*.g.md") if p.is_file())
    if not files:
        raise ClientError(f"No *.g.md files found under {root}. Add at least one GOST-style markdown document.")
    return files


def pick_main_file(files: list[Path]) -> Path:
    if len(files) == 1:
        return files[0]

    print(f"\nFound {len(files)} *.g.md files. Select the root document:")
    for i, f in enumerate(files, start=1):
        print(f"  [{i}] {f}")
    while True:
        raw = input(f"\nChoose 1-{len(files)}: ").strip()
        if raw.isdigit() and 1 <= int(raw) <= len(files):
            return files[int(raw) - 1]
        print(f"Invalid choice. Enter a number between 1 and {len(files)}.")


# ---------------------------------------------------------------------------
# Referenced-file detection + ZIP building
# ---------------------------------------------------------------------------


# Reference patterns found in the main document:
#   * Image embeds:  ![alt](path)
#       alt may contain nested brackets/labels (e.g. ![[#] caption](img.png))
#       and path may be angle-bracketed: ![](<file with spaces.png>)
#   * Include/embed: !!(filename.ext){json}  -> captures filename.ext
_REFERENCE_PATTERNS = [
    re.compile(r"!\[.*?\]\(([^)]*)\)", re.DOTALL),
    re.compile(r"!!\(([^)]+)\)"),
]


def extract_referenced_paths(content: str) -> list[str]:
    """Collect candidate local file paths mentioned in the document text."""
    # Drop HTML comments so commented-out embeds are not treated as assets.
    content = re.sub(r"<!--.*?-->", "", content, flags=re.DOTALL)

    candidates: list[str] = []
    for pattern in _REFERENCE_PATTERNS:
        for match in pattern.findall(content):
            candidates.append(match)

    paths: list[str] = []
    for raw in candidates:
        cleaned = raw.strip()
        # Angle-bracketed paths may contain spaces: <file with spaces.png>.
        if cleaned.startswith("<") and cleaned.endswith(">"):
            cleaned = cleaned[1:-1]
        # Strip anchors/fragments, query strings and surrounding quotes.
        cleaned = cleaned.split("#", 1)[0].split("?", 1)[0].strip().strip("\"'")
        if not cleaned:
            continue
        # Skip external URLs.
        if re.match(r"^(?:https?|ftp|mailto):", cleaned, re.IGNORECASE):
            continue
        # Normalize: forward slashes, no leading "./".
        cleaned = cleaned.replace("\\", "/")
        if cleaned.startswith("./"):
            cleaned = cleaned[2:]
        if not cleaned:
            continue
        paths.append(cleaned)
    return paths


def should_include_file(rel_path: str, referenced_names: set[str]) -> bool:
    """
    Decide whether a project file belongs in the ZIP archive.

    Simple heuristic: include a file if either its normalized relative path or
    its bare filename is mentioned in the main document. This is intentionally
    kept as an isolated function so a more intelligent asset-resolution
    strategy can be plugged in later without changing the call sites.
    """
    rel_norm = rel_path.replace("\\", "/")
    if rel_norm.startswith("./"):
        rel_norm = rel_norm[2:]
    name = Path(rel_norm).name
    return rel_norm in referenced_names or name in referenced_names


def build_zip_bytes(root: Path, main_file: Path, referenced_names: set[str]) -> bytes:
    """
    Stream only the required files into a ZIP in memory.

    Included files: the selected main document and any project file whose path
    or filename is referenced inside the main document (via
    should_include_file). The main file is stored as "main.g.md" at the archive
    root (the worker requires a root document named main.g.md).
    """
    buffer = io.BytesIO()
    main_rel = main_file.relative_to(root).as_posix()

    with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
        for file_path in sorted(p for p in root.rglob("*") if p.is_file()):
            rel = file_path.relative_to(root).as_posix()
            if rel == main_rel:
                zf.write(file_path, arcname="main.g.md")
            elif should_include_file(rel, referenced_names):
                zf.write(file_path, arcname=rel)

    return buffer.getvalue()


def print_zip_tree(zip_bytes: bytes) -> None:
    """Print a human-readable tree of the ZIP archive contents."""
    entries = zipfile.ZipFile(io.BytesIO(zip_bytes)).namelist()

    # Build a nested trie of directories and files.
    root_node: dict = {}
    for entry in entries:
        node = root_node
        for part in entry.split("/"):
            if part == "":
                continue
            if part not in node:
                node[part] = {}
            node = node[part]
    # Mark actual files (nodes with no children that were leaves).
    # Leaves in the trie are files; internal nodes are directories.

    def render(node: dict, prefix: str) -> None:
        keys = sorted(node.keys())
        for i, key in enumerate(keys):
            last = i == len(keys) - 1
            branch = "└── " if last else "├── "
            child = node[key]
            is_dir = bool(child)
            marker = "/" if is_dir else ""
            print(f"{prefix}{branch}{key}{marker}")
            if is_dir:
                extension = "    " if last else "│   "
                render(child, prefix + extension)

    print("ZIP contents:")
    render(root_node, "  ")


# ---------------------------------------------------------------------------
# API calls
# ---------------------------------------------------------------------------


def build_multipart(zip_bytes: bytes) -> tuple[bytes, str]:
    """Build a multipart/form-data body containing the ZIP as field 'file'."""
    boundary = "----md2gostBoundary" + uuid.uuid4().hex
    disposition = f'form-data; name="file"; filename="project.zip"'

    body_parts = [
        f"--{boundary}\r\n".encode(),
        f"Content-Disposition: {disposition}\r\n".encode(),
        b"Content-Type: application/zip\r\n",
        b"\r\n",
        zip_bytes,
        b"\r\n",
        f"--{boundary}--\r\n".encode(),
    ]
    return b"".join(body_parts), boundary


def create_job(config: dict, zip_bytes: bytes) -> str:
    url = f"{config['base_url']}/v1/jobs?{urlencode({'format': config['format']})}"
    body, boundary = build_multipart(zip_bytes)
    idempotency_key = uuid.uuid4().hex[:32]

    request = Request(
        url,
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {config['api_key']}",
            "Idempotency-Key": idempotency_key,
            "Content-Type": f"multipart/form-data; boundary={boundary}",
            "Content-Length": str(len(body)),
        },
    )

    status, raw = _run_request(request)
    if status == 409:
        # Idempotency key collision should not happen with a fresh UUID.
        raise ClientError("Idempotency-Key collision detected; please retry.")

    payload = json.loads(raw) if raw else {}
    if status not in (200, 202):
        raise ApiError(status, payload.get("error", "job_creation_failed"), payload.get("message"))

    job_id = payload.get("id")
    if not job_id:
        raise ClientError(f"Job creation response did not include an id: {payload}")
    return job_id


def get_status(config: dict, job_id: str) -> dict:
    url = f"{config['base_url']}/v1/jobs/{job_id}"
    request = Request(
        url,
        headers={"Authorization": f"Bearer {config['api_key']}"},
    )
    status, raw = _run_request(request)
    payload = json.loads(raw) if raw else {}
    if status != 200:
        raise ApiError(status, payload.get("error", "status_fetch_failed"), payload.get("message"))
    return payload


def cancel_job(config: dict, job_id: str) -> None:
    url = f"{config['base_url']}/v1/jobs/{job_id}"
    request = Request(
        url,
        method="DELETE",
        headers={"Authorization": f"Bearer {config['api_key']}"},
    )
    status, raw = _run_request(request)
    payload = json.loads(raw) if raw else {}
    if status not in (200, 409):
        raise ApiError(status, payload.get("error", "cancel_failed"), payload.get("message"))
    if status == 409:
        print("  Note: the job is already processing and could not be cancelled.")
    else:
        _print_status(f"Job cancelled: {payload.get('status', 'cancelled')}")


def download_result(config: dict, job_id: str, output_path: Path) -> None:
    url = f"{config['base_url']}/v1/jobs/{job_id}/result"
    request = Request(
        url,
        headers={"Authorization": f"Bearer {config['api_key']}"},
    )
    status, raw = _run_request(request)
    if status != 200:
        raise ApiError(status, "result_download_failed", "Failed to download the result.")
    output_path.write_bytes(raw)
    _print_status(f"Saved result to {output_path}")


# ---------------------------------------------------------------------------
# Polling / flow
# ---------------------------------------------------------------------------


def poll_until_terminal(config: dict, job_id: str, poll_interval: float) -> dict:
    """Poll job status until a terminal state. Returns the final status payload."""
    seen_progress = None
    while True:
        payload = get_status(config, job_id)
        status = payload.get("status", "unknown")

        progress = payload.get("progress")
        if status == "processing" and progress is not None and progress != seen_progress:
            seen_progress = progress
            if isinstance(progress, dict):
                # The worker reports structured progress, e.g.
                # {percent: 44, stage: "rendering", message: "..."}.
                percent = progress.get("percent")
                message = progress.get("message") or progress.get("stage") or ""
                if percent is not None:
                    print(f"  \u2699 rendering... {percent}% {message}".rstrip())
                elif message:
                    print(f"  \u2699 rendering... {message}")
                else:
                    print(f"  \u2699 rendering... {progress}")
            else:
                print(f"  \u2699 rendering... {progress}%")

        if status == "queued":
            _print_status("waiting in queue...")
        elif status == "pending":
            _print_status("job pending...")

        if status in TERMINAL_STATUSES:
            return payload

        time.sleep(poll_interval)


def run(config: dict, args: argparse.Namespace) -> int:
    # 1. Choose the project root.
    root = pick_project_root(args)
    _print_status(f"Project root: {root}")

    # 2. Find and select the root *.g.md file.
    gmd_files = find_gmd_files(root)
    main_file = pick_main_file(gmd_files)
    _print_status(f"Root document: {main_file} (will be sent as main.g.md)")

    # 3. Detect files referenced by the main document and build a ZIP with
    #    only the required files (main document + referenced assets).
    main_text = main_file.read_text(encoding="utf-8", errors="replace")
    referenced_names = set(extract_referenced_paths(main_text))
    _print_status(f"Referenced {len(referenced_names)} asset(s); building ZIP with required files only...")
    zip_bytes = build_zip_bytes(root, main_file, referenced_names)
    _print_status(f"ZIP ready ({len(zip_bytes) / 1024:.1f} KiB)")
    print()
    print_zip_tree(zip_bytes)
    print()

    # 4. Create the job.
    _print_status(f"Submitting job (format: {config['format']})...")
    job_id = create_job(config, zip_bytes)
    _print_status(f"Job submitted: {config['base_url']}/v1/jobs/{job_id}")
    print()

    # 5. Poll until terminal, with Ctrl+C -> offer cancel.
    try:
        final = poll_until_terminal(config, job_id, args.poll_interval)
    except KeyboardInterrupt:
        print("\n\nInterrupted by user.")
        try:
            answer = input("Cancel the job on the server? [y/N]: ").strip().lower()
        except EOFError:
            answer = "n"
        if answer in ("y", "yes"):
            try:
                cancel_job(config, job_id)
            except ApiError as exc:
                print(f"  ! Failed to cancel: {exc.friendly_message()}")
            except ClientError as exc:
                print(f"  ! Could not reach API to cancel: {exc}")
        else:
            print("  Job left running on the server.")
        print("Exiting.")
        return 1

    status = final.get("status")

    if status == "completed":
        # Auto result naming based on the selected main document.
        out = args.output
        if not out:
            # main_file.stem of "report.g.md" is "report.g" — drop the trailing
            # ".g" GOST marker so the result is named "report.pdf", not "report.g.pdf".
            stem = main_file.stem  # strips '.md'
            if stem.endswith(".g"):
                stem = stem[:-2]
            out = f"{stem}.{config['format']}"
        output_path = Path(out).expanduser()
        _print_status("Render complete. Downloading result...")
        download_result(config, job_id, output_path)
        return 0

    if status == "failed":
        code = final.get("errorCode") or final.get("error")
        print(f"\n! Job failed: {_human_error(code)}")
        if final.get("error"):
            print(f"  ({final['error']})")
        return 2

    if status == "cancelled":
        print("\nJob was cancelled.")
        return 1

    print(f"\nUnexpected terminal status: {status}")
    return 1


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    try:
        config = prompt_config(args)
    except ClientError as exc:
        print(f"Configuration error: {exc}")
        return 2

    try:
        return run(config, args)
    except ApiError as exc:
        print(f"\nAPI error ({exc.status}): {exc.friendly_message()}")
        return 2
    except ClientError as exc:
        print(f"\nError: {exc}")
        return 2
    except KeyboardInterrupt:
        print("\nInterrupted. Exiting.")
        return 130
    except Exception as exc:  # noqa: BLE001 - last-resort guard for a demo tool
        print(f"\nUnexpected error: {exc}")
        return 3


if __name__ == "__main__":
    sys.exit(main())
