from __future__ import annotations

import base64
import hmac
import json
import mimetypes
import re
import sqlite3
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse

from rights_filter.server.image_store import LocalSubmissionImageStore, SUPPORTED_IMAGE_SUFFIXES
from rights_filter.server.sqlite_store import CopyrighterStore

# Windows Python builds may not know .woff2, which would serve the bundled
# 2MB font as application/octet-stream.
mimetypes.add_type("font/woff2", ".woff2")
mimetypes.add_type("font/woff", ".woff")

# Bundled font binaries never change between releases; without any cache
# validator the browser re-downloads the full file on every page load.
_IMMUTABLE_SUFFIXES = {".woff2", ".woff"}
_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable"

# An operator JSON request body is small (decisions, memos, knowledge edits);
# uploads come base64-encoded inside the body, so allow headroom but never an
# unbounded read that a single request could use to exhaust memory.
_MAX_BODY_BYTES = 10 * 1024 * 1024


class _PayloadTooLarge(Exception):
    """Raised when a request body exceeds _MAX_BODY_BYTES."""


# Media routes loaded via <img>/<link>, which cannot send an Authorization
# header — these rely on the session cookie instead.
_MEDIA_PREFIXES = (
    "/media/",
    "/knowledge-media/",
    "/collected-media/",
    "/face-crop-media/",
)
# Data routes requiring the shared token when COPYRIGHTER_AUTH_TOKEN is set.
# The static GUI shell and /health stay open so the operator can bootstrap.
_PROTECTED_PREFIXES = ("/api/", *_MEDIA_PREFIXES)

# HttpOnly cookie minted after a header-authenticated request so the browser can
# load media without ever carrying the token in a URL.
_SESSION_COOKIE = "copyrighter_session"


def build_server(
    host: str,
    port: int,
    store: CopyrighterStore,
    image_store: LocalSubmissionImageStore,
    static_dir: Path | str,
    auth_token: str | None = None,
) -> ThreadingHTTPServer:
    static_root = Path(static_dir).resolve()

    class CopyrighterHandler(BaseHTTPRequestHandler):
        server_version = "CopyrighterHTTP/0.1"

        def do_GET(self) -> None:
            path = _path(self.path)
            if not self._require_auth():
                return
            active_store = lambda: store.active_submission_image_store(image_store.root)  # noqa: E731 - lazy: only opens a DB connection on routes that use it
            try:
                if path == "/health":
                    self._json({"status": "ok", "port": self.server.server_port})
                elif path == "/api/providers/health":
                    self._json({"status": "ok", "providers": store.providers()})
                elif path == "/api/bootstrap":
                    self._json(store.bootstrap())
                elif path == "/api/review-queue":
                    self._json(store.bootstrap()["submissions"])
                elif path.startswith("/api/submissions/") and path.endswith("/review"):
                    submission_id = unquote(path.split("/")[3])
                    self._json(store.review(submission_id))
                elif path == "/api/providers":
                    self._json(store.providers())
                elif path == "/api/audit-events":
                    self._json(store.audit_events())
                elif path.startswith("/media/"):
                    self._file(active_store().media_path(unquote(path.removeprefix("/media/"))), untrusted=True)
                elif path.startswith("/knowledge-media/"):
                    self._file(store.knowledge_media_path(unquote(path.removeprefix("/knowledge-media/"))), untrusted=True)
                elif path.startswith("/collected-media/"):
                    self._file(store.collected_media_path(unquote(path.removeprefix("/collected-media/"))), untrusted=True)
                elif path.startswith("/face-crop-media/"):
                    self._file(store.face_crop_media_path(unquote(path.removeprefix("/face-crop-media/"))), untrusted=True)
                else:
                    self._static(path, static_root)
            except _PayloadTooLarge:
                self._json({"error": "payload too large"}, HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
            except sqlite3.OperationalError:
                self._json({"error": "service busy, retry"}, HTTPStatus.SERVICE_UNAVAILABLE)
            except KeyError:
                self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
            except ValueError as exc:
                self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

        def do_POST(self) -> None:
            path = _path(self.path)
            if not self._require_auth():
                return
            active_store = lambda: store.active_submission_image_store(image_store.root)  # noqa: E731 - lazy: only opens a DB connection on routes that use it
            try:
                body = self._body()
                if path.startswith("/api/submissions/") and path.endswith("/decision"):
                    submission_id = unquote(path.split("/")[3])
                    self._json(
                        store.record_decision(
                            submission_id,
                            str(body.get("decision", "")),
                            str(body.get("memo", "")),
                            active_store(),
                        )
                    )
                elif path.startswith("/api/submissions/") and path.endswith("/search-auto"):
                    submission_id = unquote(path.split("/")[3])
                    self._json(store.run_auto_search(submission_id, active_store()))
                elif path.startswith("/api/evidence/") and path.endswith("/status"):
                    evidence_id = unquote(path.split("/")[3])
                    self._json(
                        store.mark_evidence_status(
                            str(body.get("submission_id", "")),
                            evidence_id,
                            str(body.get("status", "")),
                            str(body.get("note", "")),
                        )
                    )
                elif path.startswith("/api/submissions/") and path.endswith("/rerun-enrichment"):
                    submission_id = unquote(path.split("/")[3])
                    self._json(store.rerun_enrichment(submission_id, active_store()))
                elif path == "/api/submissions/reload":
                    imported = store.seed_from_image_store(active_store())
                    payload = store.bootstrap()
                    payload["imported"] = imported
                    self._json(payload)
                elif path == "/api/submissions/import-folder":
                    folder = str(body.get("folder", "") or body.get("path", "")).strip()
                    if not folder:
                        raise ValueError("submission folder path is required")
                    folder_path = Path(folder)
                    if not folder_path.exists():
                        raise ValueError("submission folder does not exist")
                    imported = store.seed_from_image_store(LocalSubmissionImageStore(folder_path))
                    payload = store.bootstrap()
                    payload["imported"] = imported
                    self._json(payload)
                elif path == "/api/submissions/upload-image":
                    active = active_store()
                    uploaded_id = _save_submission_upload(active, body.get("image"))
                    imported = store.seed_from_image_store(active)
                    payload = store.bootstrap()
                    payload["imported"] = imported
                    payload["uploadedSubmissionId"] = uploaded_id
                    self._json(payload)
                elif path == "/api/search/manual":
                    self._json(
                        store.manual_search(
                            str(body.get("submission_id", "")),
                            str(body.get("provider", "")),
                            str(body.get("query", "")),
                            active_store(),
                        )
                    )
                elif path == "/api/knowledge/manual":
                    store.register_manual_knowledge_entry(body)
                    self._json(store.bootstrap())
                elif path == "/api/collections/keyword":
                    self._json(
                        store.collect_keyword_candidates(
                            str(body.get("query", "")),
                            str(body.get("provider", "naver")),
                        )
                    )
                elif path == "/api/collections/candidates/promote-batch":
                    self._json(store.promote_collection_candidates(body))
                elif path.startswith("/api/collections/candidates/") and path.endswith("/promote"):
                    candidate_id = unquote(path.split("/")[4])
                    self._json(store.promote_collection_candidate(candidate_id, body))
                elif path.startswith("/api/knowledge/") and path.endswith("/promote-watchlist"):
                    entry_id = unquote(path.split("/")[3])
                    self._json(store.promote_watchlist_entry(entry_id))
                elif path.startswith("/api/knowledge/") and path.endswith("/exclude-watchlist"):
                    entry_id = unquote(path.split("/")[3])
                    self._json(store.exclude_watchlist_entry(entry_id, str(body.get("reason", ""))))
                elif path.startswith("/api/knowledge/") and path.endswith("/deactivate"):
                    entry_id = unquote(path.split("/")[3])
                    self._json(store.deactivate_knowledge_entry(entry_id, str(body.get("reason", ""))))
                elif path.startswith("/api/knowledge/") and path.endswith("/reactivate"):
                    entry_id = unquote(path.split("/")[3])
                    self._json(store.reactivate_knowledge_entry(entry_id, str(body.get("reason", ""))))
                elif path == "/api/providers/emergency-disable":
                    self._json(store.emergency_disable_external_providers())
                else:
                    self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
            except _PayloadTooLarge:
                self._json({"error": "payload too large"}, HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
            except sqlite3.OperationalError:
                self._json({"error": "service busy, retry"}, HTTPStatus.SERVICE_UNAVAILABLE)
            except KeyError:
                self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
            except ValueError as exc:
                self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

        def do_PATCH(self) -> None:
            path = _path(self.path)
            if not self._require_auth():
                return
            try:
                body = self._body()
                if path.startswith("/api/providers/"):
                    provider_id = path.split("/")[3]
                    enabled = body.get("enabled")
                    if enabled is None:
                        current = next(
                            (provider for provider in store.providers() if provider["id"] == provider_id),
                            None,
                        )
                        if current is None:
                            raise KeyError(provider_id)
                        enabled = not current["enabled"]
                    self._json(store.set_provider_enabled(provider_id, bool(enabled)))
                elif path.startswith("/api/knowledge/"):
                    entry_id = unquote(path.split("/")[3])
                    self._json(store.update_knowledge_entry(entry_id, body))
                else:
                    self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
            except _PayloadTooLarge:
                self._json({"error": "payload too large"}, HTTPStatus.REQUEST_ENTITY_TOO_LARGE)
            except sqlite3.OperationalError:
                self._json({"error": "service busy, retry"}, HTTPStatus.SERVICE_UNAVAILABLE)
            except KeyError:
                self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
            except ValueError as exc:
                self._json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)

        def log_message(self, format: str, *args: object) -> None:
            return

        def _require_auth(self) -> bool:
            # No token configured -> open access (dev / single-host default).
            if not auth_token:
                return True
            path = _path(self.path)
            # The GUI shell (static assets) and liveness probe must load
            # unauthenticated so the operator can enter the token; only data
            # routes are protected.
            if not any(path.startswith(prefix) for prefix in _PROTECTED_PREFIXES):
                return True
            header = self.headers.get("Authorization", "")
            if hmac.compare_digest(header, f"Bearer {auth_token}"):
                # First header-authenticated request mints an HttpOnly session
                # cookie so the browser can load <img>/media (which cannot send an
                # Authorization header) without ever putting the token in a URL.
                self._issue_session_cookie = True
                return True
            cookie_header = self.headers.get("Cookie", "")
            if cookie_header:
                jar = SimpleCookie()
                jar.load(cookie_header)
                morsel = jar.get(_SESSION_COOKIE)
                if morsel is not None and hmac.compare_digest(morsel.value, auth_token):
                    return True
            self._json({"error": "unauthorized"}, HTTPStatus.UNAUTHORIZED)
            return False

        def _body(self) -> dict[str, object]:
            length = int(self.headers.get("Content-Length", "0") or "0")
            if length > _MAX_BODY_BYTES:
                raise _PayloadTooLarge()
            if not length:
                return {}
            try:
                return json.loads(self.rfile.read(length).decode("utf-8"))
            except json.JSONDecodeError as exc:
                raise ValueError("invalid JSON body") from exc

        def _maybe_set_session_cookie(self) -> None:
            if getattr(self, "_issue_session_cookie", False):
                self.send_header(
                    "Set-Cookie",
                    f"{_SESSION_COOKIE}={auth_token}; HttpOnly; SameSite=Strict; Path=/",
                )

        def _json(self, payload: object, status: HTTPStatus = HTTPStatus.OK) -> None:
            data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
            self.send_response(status)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self._maybe_set_session_cookie()
            self.end_headers()
            self.wfile.write(data)

        def _static(self, path: str, root: Path) -> None:
            relative = "index.html" if path in {"", "/"} else path.lstrip("/")
            target = (root / relative).resolve()
            if target != root and root not in target.parents:
                self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
                return
            if not target.exists() or target.is_dir():
                self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
                return
            self._file(target)

        def _file(self, path: Path, untrusted: bool = False) -> None:
            if not path.exists() or path.is_dir():
                self._json({"error": "not found"}, HTTPStatus.NOT_FOUND)
                return
            data = path.read_bytes()
            content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
            if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
                content_type = f"{content_type}; charset=utf-8"
            self.send_response(HTTPStatus.OK)
            self.send_header("Content-Type", content_type)
            self.send_header("Content-Length", str(len(data)))
            if path.suffix.lower() in _IMMUTABLE_SUFFIXES:
                self.send_header("Cache-Control", _IMMUTABLE_CACHE_CONTROL)
            if untrusted:
                # Neutralize stored XSS from operator-uploaded / externally
                # collected media (an SVG can carry an inline <script>). `sandbox`
                # blocks script execution on direct navigation while still
                # allowing <img> rendering (CSP does not apply to images embedded
                # via <img>). Only applied to untrusted media — never to the
                # trusted static app, whose own scripts must run.
                self.send_header("X-Content-Type-Options", "nosniff")
                self.send_header(
                    "Content-Security-Policy",
                    "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; sandbox",
                )
                # Biometric/personal media: keep it out of intermediary caches and
                # don't leak the URL via Referer.
                self.send_header("Cache-Control", "no-store")
                self.send_header("Referrer-Policy", "no-referrer")
            self._maybe_set_session_cookie()
            self.end_headers()
            self.wfile.write(data)

    return ThreadingHTTPServer((host, port), CopyrighterHandler)


def _path(raw_path: str) -> str:
    return urlparse(raw_path).path


def _save_submission_upload(image_store: LocalSubmissionImageStore, raw_image: object) -> str:
    if not isinstance(raw_image, dict):
        raise ValueError("image is required")
    filename = Path(str(raw_image.get("filename", "") or "")).name
    if not filename:
        raise ValueError("image filename is required")
    suffix = Path(filename).suffix.lower()
    if not suffix:
        suffix = _suffix_for_content_type(str(raw_image.get("content_type", "")))
        filename = f"{filename}{suffix}"
    if suffix not in SUPPORTED_IMAGE_SUFFIXES:
        raise ValueError("unsupported submission image type")
    try:
        content = base64.b64decode(str(raw_image.get("data", "")), validate=True)
    except Exception as exc:
        raise ValueError("image data must be base64") from exc
    if not content:
        raise ValueError("image data is empty")

    image_dir = image_store.root / "images"
    image_dir.mkdir(parents=True, exist_ok=True)
    safe_name = _safe_upload_name(filename)
    target = _unique_upload_path(image_dir, safe_name)
    target.write_bytes(content)
    return target.stem


def _safe_upload_name(filename: str) -> str:
    path = Path(filename)
    stem = re.sub(r"[^0-9A-Za-z_.-]+", "-", path.stem).strip(".-") or "submission"
    return f"{stem}{path.suffix.lower()}"


def _unique_upload_path(directory: Path, filename: str) -> Path:
    candidate = directory / filename
    if not candidate.exists():
        return candidate
    path = Path(filename)
    for index in range(2, 1000):
        candidate = directory / f"{path.stem}-{index}{path.suffix}"
        if not candidate.exists():
            return candidate
    raise ValueError("too many files with the same submission image name")


def _suffix_for_content_type(content_type: str) -> str:
    return {
        "image/jpeg": ".jpg",
        "image/png": ".png",
        "image/gif": ".gif",
        "image/webp": ".webp",
        "image/bmp": ".bmp",
        "image/svg+xml": ".svg",
        "image/avif": ".avif",
    }.get(content_type.lower(), "")
