#!/usr/bin/env python3
"""Cloud version API for editable detail pages.

Storage layout:
  <root>/projects/<project_id>/manifest.json
  <root>/projects/<project_id>/versions/<version_id>.html
  <root>/projects/<project_id>/versions/<version_id>.json

The service intentionally has no credentials. It is designed to be bound to
127.0.0.1 and exposed by nginx under /detail-page-api/ on the same host that
serves /detail-pages/<project_id>/.
"""
from __future__ import annotations

import argparse
import json
import re
import time
import uuid
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlparse

PROJECT_RE = re.compile(r"^[A-Za-z0-9._-]+$")
VERSION_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
DEFAULT_ROOT = "/srv/detail-page-versions"
MAX_BODY_BYTES = 30 * 1024 * 1024
MAX_VERSIONS_PER_PROJECT = 20


class VersionStore:
    def __init__(self, root: str | Path):
        self.root = Path(root)
        self.projects_root = self.root / "projects"
        self.projects_root.mkdir(parents=True, exist_ok=True)

    def _validate_project_id(self, project_id: str) -> str:
        project_id = (project_id or "").strip()
        if not PROJECT_RE.fullmatch(project_id):
            raise ValueError("project_id must contain only letters, numbers, dot, underscore, or hyphen")
        return project_id

    def _validate_version_id(self, version_id: str) -> str:
        version_id = (version_id or "").strip()
        if not VERSION_RE.fullmatch(version_id):
            raise ValueError("version_id contains invalid characters")
        return version_id

    def _project_dir(self, project_id: str) -> Path:
        project_id = self._validate_project_id(project_id)
        return self.projects_root / project_id

    def _manifest_path(self, project_id: str) -> Path:
        return self._project_dir(project_id) / "manifest.json"

    def _versions_dir(self, project_id: str) -> Path:
        return self._project_dir(project_id) / "versions"

    def _load_manifest(self, project_id: str) -> dict[str, Any]:
        project_id = self._validate_project_id(project_id)
        path = self._manifest_path(project_id)
        if not path.exists():
            return {"project_id": project_id, "current_version_id": None, "versions": []}
        return json.loads(path.read_text(encoding="utf-8"))

    def _write_json_atomic(self, path: Path, data: dict[str, Any]) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(path.suffix + ".tmp")
        tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
        tmp.replace(path)

    def _write_text_atomic(self, path: Path, text: str) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(path.suffix + ".tmp")
        tmp.write_text(text, encoding="utf-8")
        tmp.replace(path)

    def _new_version_id(self) -> str:
        return time.strftime("%Y%m%d-%H%M%S", time.gmtime()) + f"-{uuid.uuid4().hex[:8]}"

    def _prune_old_versions(self, project_id: str, manifest: dict[str, Any]) -> dict[str, Any]:
        versions = list(manifest.get("versions", []))
        if len(versions) <= MAX_VERSIONS_PER_PROJECT:
            manifest["versions"] = versions
            return manifest
        keep = versions[:MAX_VERSIONS_PER_PROJECT]
        remove = versions[MAX_VERSIONS_PER_PROJECT:]
        manifest["versions"] = keep
        keep_ids = {v.get("version_id") for v in keep}
        if manifest.get("current_version_id") not in keep_ids:
            manifest["current_version_id"] = keep[0].get("version_id") if keep else None
        versions_dir = self._versions_dir(project_id)
        for version in remove:
            version_id = version.get("version_id")
            if not version_id:
                continue
            for suffix in (".html", ".json"):
                path = versions_dir / f"{version_id}{suffix}"
                try:
                    path.unlink()
                except FileNotFoundError:
                    pass
        return manifest

    def save_version(self, project_id: str, html: str, *, title: str = "", note: str = "", url: str = "", user_agent: str = "") -> dict[str, Any]:
        project_id = self._validate_project_id(project_id)
        if not isinstance(html, str) or not html.strip():
            raise ValueError("html is required")
        version_id = self._new_version_id()
        created_at = int(time.time())
        versions_dir = self._versions_dir(project_id)
        html_path = versions_dir / f"{version_id}.html"
        meta = {
            "project_id": project_id,
            "version_id": version_id,
            "created_at": created_at,
            "title": title or "",
            "note": note or "",
            "url": url or "",
            "user_agent": user_agent or "",
            "bytes": len(html.encode("utf-8")),
            "html_path": str(html_path),
        }
        self._write_text_atomic(html_path, html)
        self._write_json_atomic(versions_dir / f"{version_id}.json", meta)
        manifest = self._load_manifest(project_id)
        manifest["project_id"] = project_id
        manifest["current_version_id"] = version_id
        manifest.setdefault("versions", []).insert(0, {k: v for k, v in meta.items() if k != "html_path"})
        manifest = self._prune_old_versions(project_id, manifest)
        self._write_json_atomic(self._manifest_path(project_id), manifest)
        return {k: v for k, v in meta.items() if k != "html_path"} | {"current_version_id": version_id}

    def get_manifest(self, project_id: str) -> dict[str, Any]:
        return self._load_manifest(project_id)

    def list_versions(self, project_id: str) -> dict[str, Any]:
        manifest = self._load_manifest(project_id)
        return {
            "project_id": manifest["project_id"],
            "current_version_id": manifest.get("current_version_id"),
            "versions": manifest.get("versions", []),
        }

    def _meta_for(self, project_id: str, version_id: str) -> dict[str, Any]:
        project_id = self._validate_project_id(project_id)
        version_id = self._validate_version_id(version_id)
        path = self._versions_dir(project_id) / f"{version_id}.json"
        if not path.exists():
            raise FileNotFoundError(version_id)
        return json.loads(path.read_text(encoding="utf-8"))

    def get_version(self, project_id: str, version_id: str) -> dict[str, Any]:
        meta = self._meta_for(project_id, version_id)
        html_path = self._versions_dir(project_id) / f"{version_id}.html"
        if not html_path.exists():
            raise FileNotFoundError(version_id)
        return {k: v for k, v in meta.items() if k != "html_path"} | {"html": html_path.read_text(encoding="utf-8")}

    def get_current(self, project_id: str) -> dict[str, Any]:
        manifest = self._load_manifest(project_id)
        version_id = manifest.get("current_version_id")
        if not version_id:
            raise FileNotFoundError("current")
        return self.get_version(project_id, version_id)

    def set_current(self, project_id: str, version_id: str) -> dict[str, Any]:
        project_id = self._validate_project_id(project_id)
        version_id = self._validate_version_id(version_id)
        self._meta_for(project_id, version_id)
        manifest = self._load_manifest(project_id)
        manifest["current_version_id"] = version_id
        self._write_json_atomic(self._manifest_path(project_id), manifest)
        return {"project_id": project_id, "current_version_id": version_id}


def parse_project_path(path: str) -> tuple[str, str, str | None]:
    # /projects/<project_id>/versions[/<version_id>[/html]] or /projects/<project_id>/current
    parts = [unquote(p) for p in path.strip("/").split("/") if p]
    if len(parts) < 3 or parts[0] != "projects":
        raise ValueError("unknown API path")
    project_id = parts[1]
    collection = parts[2]
    tail = "/".join(parts[3:]) if len(parts) > 3 else None
    return project_id, collection, tail


class VersionApiHandler(BaseHTTPRequestHandler):
    store: VersionStore

    server_version = "DetailPageVersionAPI/1.0"

    def log_message(self, fmt: str, *args: Any) -> None:  # keep systemd logs compact
        print("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), fmt % args))

    def _send_headers(self, status: int, content_type: str = "application/json; charset=utf-8") -> None:
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type, Accept")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()

    def _json(self, status: int, data: dict[str, Any]) -> None:
        body = json.dumps(data, ensure_ascii=False).encode("utf-8")
        self._send_headers(status)
        self.wfile.write(body)

    def _error(self, status: int, message: str) -> None:
        self._json(status, {"error": message})

    def _read_json(self) -> dict[str, Any]:
        length = int(self.headers.get("Content-Length") or "0")
        if length <= 0:
            return {}
        if length > MAX_BODY_BYTES:
            raise ValueError("request body too large")
        raw = self.rfile.read(length)
        return json.loads(raw.decode("utf-8"))

    def do_OPTIONS(self) -> None:
        self._send_headers(HTTPStatus.NO_CONTENT)

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path == "/healthz":
            self._json(HTTPStatus.OK, {"ok": True})
            return
        try:
            project_id, collection, tail = parse_project_path(parsed.path)
            if collection == "versions" and tail is None:
                self._json(HTTPStatus.OK, self.store.list_versions(project_id))
                return
            if collection == "versions" and tail:
                if tail.endswith("/html"):
                    version_id = tail[:-5]
                    html = self.store.get_version(project_id, version_id)["html"]
                    self._send_headers(HTTPStatus.OK, "text/html; charset=utf-8")
                    self.wfile.write(html.encode("utf-8"))
                    return
                self._json(HTTPStatus.OK, self.store.get_version(project_id, tail))
                return
            if collection == "current":
                self._json(HTTPStatus.OK, self.store.get_current(project_id))
                return
            raise ValueError("unknown API path")
        except FileNotFoundError as exc:
            self._error(HTTPStatus.NOT_FOUND, str(exc))
        except ValueError as exc:
            self._error(HTTPStatus.BAD_REQUEST, str(exc))
        except Exception as exc:  # pragma: no cover - last-resort server safety
            self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))

    def do_POST(self) -> None:
        parsed = urlparse(self.path)
        try:
            project_id, collection, tail = parse_project_path(parsed.path)
            data = self._read_json()
            if collection == "versions" and tail is None:
                body_project = data.get("project_id") or project_id
                if body_project != project_id:
                    raise ValueError("project_id in body does not match path")
                result = self.store.save_version(
                    project_id,
                    data.get("html") or "",
                    title=data.get("title") or "",
                    note=data.get("note") or "",
                    url=data.get("url") or "",
                    user_agent=data.get("user_agent") or "",
                )
                self._json(HTTPStatus.CREATED, result)
                return
            if collection == "current":
                version_id = data.get("version_id") or ""
                self._json(HTTPStatus.OK, self.store.set_current(project_id, version_id))
                return
            raise ValueError("unknown API path")
        except json.JSONDecodeError:
            self._error(HTTPStatus.BAD_REQUEST, "invalid JSON")
        except FileNotFoundError as exc:
            self._error(HTTPStatus.NOT_FOUND, str(exc))
        except ValueError as exc:
            self._error(HTTPStatus.BAD_REQUEST, str(exc))
        except Exception as exc:  # pragma: no cover - last-resort server safety
            self._error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))


def make_server(host: str, port: int, root: str | Path) -> ThreadingHTTPServer:
    VersionApiHandler.store = VersionStore(root)
    return ThreadingHTTPServer((host, port), VersionApiHandler)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--host", default="127.0.0.1")
    ap.add_argument("--port", type=int, default=8766)
    ap.add_argument("--root", default=DEFAULT_ROOT)
    args = ap.parse_args()
    server = make_server(args.host, args.port, args.root)
    print(f"Detail page version API listening on http://{args.host}:{args.port}/ root={args.root}", flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
