"""OpenAPI 3.0 + Prometheus-ish metrics exporter.

Both endpoints are **unauthenticated** by design so they can be scraped by
third-party tooling (Swagger UI, Prometheus, uptime monitors) without needing
to solve a JWT exchange first. The OpenAPI spec is generated by introspecting
every class decorated with :func:`odoo.http.route` and therefore stays in sync
with the running server automatically.

Scope: the generator emits a minimal but valid OpenAPI document — routes,
HTTP methods, path params, tag derived from the route prefix, and a shared
``BearerAuth`` security scheme. Request / response schemas are left open
(``{"type": "object"}``) since we don't have pydantic models yet; a future
iteration can enrich individual routes via ``route_decorated_method.openapi``
annotations.
"""

from __future__ import annotations

import logging
import re
import time
from collections import Counter, defaultdict

from odoo import http
from odoo.http import Controller, Response, request

_logger = logging.getLogger(__name__)

_metrics_lock = None
_metrics: dict = {
    "started_at": time.time(),
    "routes": Counter(),   # key -> hit count
    "statuses": Counter(),  # status bucket -> hit count
    "latencies_ms": defaultdict(list),  # route -> [recent ms samples] (cap 128)
}
_LATENCY_SAMPLE_CAP = 128
_PATH_PARAM_RE = re.compile(r"<(?:(?P<conv>\w+):)?(?P<name>\w+)>")


def record_request(route: str, status: int, duration_ms: int) -> None:
    """Lightweight in-process request counter used by the metrics endpoint.

    Intentionally thread-unsafe for minimum overhead; worst case is a slightly
    off counter on concurrent writes which is fine for ops dashboards. Switch
    to a proper Prometheus client (``prometheus_client``) in P2.3 when needed.
    """
    try:
        _metrics["routes"][route] += 1
        bucket = f"{status // 100}xx" if isinstance(status, int) else "xxx"
        _metrics["statuses"][bucket] += 1
        samples = _metrics["latencies_ms"][route]
        samples.append(int(duration_ms))
        if len(samples) > _LATENCY_SAMPLE_CAP:
            del samples[0:len(samples) - _LATENCY_SAMPLE_CAP]
    except Exception:
        pass


def _convert_odoo_path(path: str) -> str:
    """Translate Odoo's ``<int:foo>`` placeholder syntax to OpenAPI ``{foo}``."""
    def repl(m: re.Match) -> str:
        return "{" + m.group("name") + "}"
    return _PATH_PARAM_RE.sub(repl, path)


def _path_parameters(path: str) -> list[dict]:
    params = []
    for m in _PATH_PARAM_RE.finditer(path):
        conv = (m.group("conv") or "string").lower()
        if conv in ("int", "integer"):
            schema = {"type": "integer"}
        elif conv in ("float", "number"):
            schema = {"type": "number"}
        else:
            schema = {"type": "string"}
        params.append({
            "name": m.group("name"),
            "in": "path",
            "required": True,
            "schema": schema,
        })
    return params


def _iter_routes():
    """Yield ``(route_str, method_name, tag, class_name, doc)`` for every
    ``@http.route`` decorated callable currently registered.

    Uses Odoo's live ``ir.http.routing_map()`` so we pick up every handler the
    dispatcher actually routes to — including extension classes.
    """
    seen = set()
    try:
        routing_map = request.env["ir.http"].routing_map()
    except Exception:
        _logger.exception("could not fetch routing_map")
        return

    try:
        rules = list(routing_map.iter_rules())
    except Exception:
        _logger.exception("routing_map has no iter_rules")
        return

    for rule in rules:
        url = rule.rule
        if not isinstance(url, str) or not url.startswith("/api/"):
            continue
        endpoint = rule.endpoint
        # ``endpoint`` is a wrapped callable; the real controller method is
        # stashed on ``endpoint.func`` by Odoo.
        fn = getattr(endpoint, "func", endpoint)
        routing = getattr(fn, "routing", {}) or {}
        methods = routing.get("methods") or list(rule.methods or {"GET"})
        methods = [m for m in methods if m not in {"HEAD", "OPTIONS"}]
        if not methods:
            methods = ["GET"]
        cls_name = ""
        try:
            qual = getattr(fn, "__qualname__", fn.__name__)
            cls_name = qual.split(".")[0] if "." in qual else ""
        except Exception:
            cls_name = ""
        module = getattr(fn, "__module__", "") or ""
        tag_base = module.split(".")[-2] if "." in module else module or "api"
        doc = ""
        try:
            doc = (fn.__doc__ or "").strip()
        except Exception:
            pass
        for m in methods:
            key = (url, m.upper(), cls_name, fn.__name__)
            if key in seen:
                continue
            seen.add(key)
            yield url, m.upper(), tag_base, cls_name, doc


def _build_openapi_spec() -> dict:
    base_url = (
        request.env["ir.config_parameter"].sudo().get_param("web.base.url")
        or "http://localhost:8069"
    )
    paths: dict[str, dict] = {}
    tags: dict[str, str] = {}

    for route, method, tag_base, cls_name, doc in _iter_routes():
        openapi_path = _convert_odoo_path(route)
        path_entry = paths.setdefault(openapi_path, {})

        # Derive a friendly tag from the URL prefix (``/api/exam/...`` -> ``exam``).
        segs = [s for s in route.split("/") if s and s != "api"]
        tag = segs[0] if segs else tag_base
        tags.setdefault(tag, f"Endpoints under /api/{tag}")

        summary = doc.splitlines()[0][:120] if doc else f"{cls_name}.{route}"

        op: dict = {
            "summary": summary,
            "operationId": f"{cls_name}_{method.lower()}_{route.strip('/').replace('/', '_').replace('<', '_').replace('>', '_').replace(':', '_')}",
            "tags": [tag],
            "security": [{"BearerAuth": []}],
            "responses": {
                "200": {"description": "OK",
                        "content": {"application/json":
                                    {"schema": {"type": "object"}}}},
                "401": {"description": "Authentication required"},
                "400": {"description": "Bad request"},
                "500": {"description": "Server error"},
            },
        }
        params = _path_parameters(route)
        if params:
            op["parameters"] = params
        if method in ("POST", "PUT", "PATCH"):
            op["requestBody"] = {
                "required": False,
                "content": {"application/json":
                            {"schema": {"type": "object"}}},
            }
        path_entry[method.lower()] = op

    return {
        "openapi": "3.0.3",
        "info": {
            "title": "EnCoach Platform API",
            "version": "19.0.1.0",
            "description": (
                "Auto-generated from ``@http.route`` decorators. "
                "Schemas are currently open; see docs/PROJECT_SUMMARY.md §21 for the "
                "roadmap to enrich request/response shapes."
            ),
        },
        "servers": [{"url": base_url}],
        "tags": [{"name": name, "description": desc}
                 for name, desc in sorted(tags.items())],
        "components": {
            "securitySchemes": {
                "BearerAuth": {
                    "type": "http", "scheme": "bearer", "bearerFormat": "JWT",
                },
            },
        },
        "paths": dict(sorted(paths.items())),
    }


class EncoachOpenAPIController(Controller):

    @http.route("/api/openapi.json", type="http", auth="none",
                methods=["GET"], csrf=False)
    def openapi(self, **_kw):
        """Return a freshly generated OpenAPI 3.0 spec for the running server."""
        try:
            spec = _build_openapi_spec()
            return request.make_json_response(spec)
        except Exception:
            _logger.exception("openapi generation failed")
            return request.make_json_response(
                {"error": "could not generate openapi spec"}, status=500,
            )

    @http.route("/api/metrics", type="http", auth="none",
                methods=["GET"], csrf=False)
    def metrics(self, **_kw):
        """Return in-process request counters in a Prometheus-compatible text format.

        This is a minimalist shim, not a full Prometheus client. It exposes:

        - ``encoach_requests_total{route, method, status_bucket}`` — request count
        - ``encoach_request_latency_ms_p95{route}`` — rolling p95 (sample-window based)
        - ``encoach_uptime_seconds`` — process uptime
        """
        lines = [
            "# HELP encoach_uptime_seconds Seconds since this worker booted",
            "# TYPE encoach_uptime_seconds gauge",
            f"encoach_uptime_seconds {int(time.time() - _metrics['started_at'])}",
            "",
            "# HELP encoach_requests_total Total number of /api/* requests handled",
            "# TYPE encoach_requests_total counter",
        ]
        for route, count in _metrics["routes"].most_common():
            safe = route.replace('"', '\\"')
            lines.append(f'encoach_requests_total{{route="{safe}"}} {count}')
        lines.append("")
        lines.append("# HELP encoach_responses_total Count of responses per status bucket")
        lines.append("# TYPE encoach_responses_total counter")
        for bucket, count in _metrics["statuses"].items():
            lines.append(f'encoach_responses_total{{bucket="{bucket}"}} {count}')
        lines.append("")
        lines.append("# HELP encoach_request_latency_ms_p95 Rolling p95 latency per route")
        lines.append("# TYPE encoach_request_latency_ms_p95 gauge")
        for route, samples in _metrics["latencies_ms"].items():
            if not samples:
                continue
            sorted_samples = sorted(samples)
            p95 = sorted_samples[min(len(sorted_samples) - 1,
                                     int(0.95 * len(sorted_samples)))]
            safe = route.replace('"', '\\"')
            lines.append(
                f'encoach_request_latency_ms_p95{{route="{safe}"}} {p95}'
            )
        body = "\n".join(lines) + "\n"
        return Response(body, status=200,
                        content_type="text/plain; version=0.0.4")
