#!/usr/bin/env python3
"""Stdlib localhost HTTP wrapper for the triage prototype.

Endpoints:
- GET /healthz
- GET /models
- POST /triage       JSON: {"path":"/local/file", "options": {...}}
- POST /triage/batch JSON: {"paths":["/local/file"], "options": {...}}

The server binds to 127.0.0.1 by default and accepts only local file paths under
configured allowed roots. It never uploads document/image contents externally.
"""
from __future__ import annotations

import argparse
import ipaddress
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

from triage import DEFAULT_EMBED_URL, TriageOptions, read_npu_busy, triage_batch, triage_file


def _validate_loopback_host(host: str) -> str:
    """Reject non-loopback binds; this prototype is never a LAN service."""
    normalized = host.strip()
    if normalized == "localhost":
        return normalized
    try:
        if ipaddress.ip_address(normalized).is_loopback:
            return normalized
    except ValueError:
        pass
    raise ValueError("host must be localhost/loopback for this prototype")


def _roots_within_configured(requested_roots: list[Any], configured_roots: list[Path]) -> list[Path]:
    """Return request roots only when they narrow the startup allowlist."""
    narrowed: list[Path] = []
    configured = [root.expanduser().resolve() for root in configured_roots]
    for raw in requested_roots:
        candidate = Path(str(raw)).expanduser().resolve()
        if any(candidate == root or candidate.is_relative_to(root) for root in configured):
            narrowed.append(candidate)
        else:
            raise ValueError("requested allowed_roots must be within configured allowed roots")
    return narrowed


def _validated_embedding_url(raw_url: Any) -> str:
    """Allow only the configured local loopback embeddings service."""
    url = str(raw_url)
    parsed = urlparse(url)
    host = parsed.hostname or ""
    if (
        parsed.scheme == "http"
        and host in {"127.0.0.1", "localhost", "::1"}
        and (parsed.port or 80) == 18817
        and parsed.path == "/v1/embeddings"
        and not parsed.username
        and not parsed.password
    ):
        return url
    raise ValueError("embedding_url override must target the configured local loopback embeddings service")


def make_options(payload: dict[str, Any], default_roots: list[Path]) -> TriageOptions:
    opts = payload.get("options") or {}
    requested_roots = opts.get("allowed_roots", [])
    if requested_roots:
        if not isinstance(requested_roots, list):
            raise ValueError("allowed_roots must be a list")
        roots = _roots_within_configured(requested_roots, default_roots)
    else:
        roots = default_roots
    embedding_url = DEFAULT_EMBED_URL
    if "embedding_url" in opts:
        embedding_url = _validated_embedding_url(opts["embedding_url"])
    return TriageOptions(
        max_pages=int(opts.get("max_pages", 3)),
        include_ocr_text=bool(opts.get("include_ocr_text", False)),
        dry_run=bool(opts.get("dry_run", False)),
        use_embeddings=bool(opts.get("use_embeddings", True)),
        embedding_url=embedding_url,
        allowed_roots=roots,
        include_full_path=bool(opts.get("include_full_path", False)),
    )


class Handler(BaseHTTPRequestHandler):
    server_version = "openvino-doc-image-triage-npu/0.1"

    def _json(self, status: int, body: dict[str, Any]) -> None:
        data = json.dumps(body, sort_keys=True).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, format: str, *args: Any) -> None:
        # Do not log request bodies, OCR text, or file paths.
        return

    @property
    def allowed_roots(self) -> list[Path]:
        return self.server.allowed_roots  # type: ignore[attr-defined]

    def do_GET(self) -> None:  # noqa: N802
        if self.path in ("/", "/healthz", "/health"):
            self._json(200, {
                "ok": True,
                "service": "openvino-doc-image-triage-npu",
                "bind_policy": "localhost-default",
                "npu_busy_time_us": read_npu_busy(),
                "npu_busy_check_enabled": True,
                "allowed_roots": [str(p) for p in self.allowed_roots],
                "privacy": {"external_uploads": False, "raw_text_logged": False},
            })
            return
        if self.path == "/models":
            self._json(200, {
                "models": [
                    {
                        "stage": "needs_attention_embedding",
                        "model": "bge-base-en-v1.5-int8-ov via local :18817",
                        "target_device": "NPU",
                        "verification": "sysfs npu_busy_time_us before/after embedding call",
                    },
                    {
                        "stage": "image_category_classification",
                        "model": "rule-based fallback in prototype v1",
                        "target_device": "CPU",
                        "npu_status": "not configured; future static-shape MobileNet/EfficientNet/ResNet OV IR",
                    },
                    {"stage": "ocr_text_extraction", "model": "optional local sidecar/PDF text", "target_device": "CPU"},
                ]
            })
            return
        self._json(404, {"ok": False, "error": "not_found"})

    def _read_payload(self) -> dict[str, Any]:
        length = int(self.headers.get("Content-Length", "0"))
        if length > 512 * 1024:
            raise ValueError("request JSON too large")
        raw = self.rfile.read(length)
        if not raw:
            return {}
        return json.loads(raw.decode())

    def do_POST(self) -> None:  # noqa: N802
        try:
            payload = self._read_payload()
            options = make_options(payload, self.allowed_roots)
            if self.path == "/triage":
                path = payload.get("path")
                if not path:
                    self._json(400, {"ok": False, "error": "missing_path"})
                    return
                self._json(200, {"ok": True, "result": triage_file(path, options)})
                return
            if self.path == "/triage/batch":
                paths = payload.get("paths") or []
                if not isinstance(paths, list) or not paths:
                    self._json(400, {"ok": False, "error": "missing_paths"})
                    return
                self._json(200, triage_batch([str(p) for p in paths], options))
                return
            self._json(404, {"ok": False, "error": "not_found"})
        except Exception as exc:
            self._json(400, {"ok": False, "error": type(exc).__name__, "message": str(exc)})


def main() -> int:
    parser = argparse.ArgumentParser(description="Local-only doc/image triage HTTP server")
    parser.add_argument("--host", default=os.environ.get("DOC_IMAGE_TRIAGE_HOST", "127.0.0.1"))
    parser.add_argument("--port", type=int, default=int(os.environ.get("DOC_IMAGE_TRIAGE_PORT", "18829")))
    parser.add_argument("--allowed-root", action="append", default=[], help="allowed local root; may repeat")
    args = parser.parse_args()
    try:
        host = _validate_loopback_host(args.host)
    except ValueError as exc:
        parser.error(str(exc))
    roots = [Path(p).expanduser().resolve() for p in args.allowed_root] or [Path.cwd().resolve()]
    httpd = ThreadingHTTPServer((host, args.port), Handler)
    httpd.allowed_roots = roots  # type: ignore[attr-defined]
    print(json.dumps({"service": "openvino-doc-image-triage-npu", "host": host, "port": args.port, "allowed_roots": [str(p) for p in roots]}), flush=True)
    httpd.serve_forever()
    return 0


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