import json
import functools
import logging
import time
import uuid

import jwt as pyjwt

from odoo.http import request

_logger = logging.getLogger(__name__)

REQUEST_ID_HEADER = "X-Request-Id"


def _get_or_create_request_id():
    """Return the X-Request-Id for the current request, minting one if absent.

    The value is cached on ``request`` so the same id is reused for every log
    line emitted during the request lifecycle (dispatcher, controller,
    post-dispatch). Call sites should prefer :func:`current_request_id`.
    """
    existing = getattr(request, "_encoach_request_id", None)
    if existing:
        return existing
    header_val = request.httprequest.headers.get(REQUEST_ID_HEADER)
    req_id = (header_val or uuid.uuid4().hex)[:64]
    try:
        request._encoach_request_id = req_id
    except Exception:
        pass
    return req_id


def current_request_id():
    """Best-effort accessor for the active request id (None outside a request)."""
    try:
        return getattr(request, "_encoach_request_id", None)
    except Exception:
        return None


def _log_json(level, event, **fields):
    """Emit a single structured JSON log line, enriched with the request id.

    Keeping all non-interactive logs JSON-formatted lets ops scrape them with
    Loki / OpenSearch without brittle regexes. Falls back silently if the
    logging backend rejects the payload.
    """
    try:
        payload = {
            "ts": round(time.time(), 3),
            "event": event,
            "rid": current_request_id(),
        }
        payload.update(fields)
        _logger.log(level, json.dumps(payload, default=str, ensure_ascii=False))
    except Exception:
        _logger.log(level, "%s %s", event, fields)

_jwt_secret_cache = {"secret": None, "ts": 0}
# Per-worker JWT secret cache TTL (seconds). Kept short so secret rotation
# via ir.config_parameter propagates quickly across all workers without
# requiring a process restart. See P0.10 in docs/PROJECT_SUMMARY.md §21.
_JWT_SECRET_TTL = 30

_user_exists_cache = {}
_USER_CACHE_TTL = 60
_USER_CACHE_MAX = 200


def invalidate_jwt_secret_cache():
    """Invalidate the per-worker JWT secret cache immediately.

    Called by ``encoach.auth.settings`` whenever the admin rotates the secret
    so that subsequent requests pick up the new value on the next read.
    """
    _jwt_secret_cache["secret"] = None
    _jwt_secret_cache["ts"] = 0


def _get_jwt_secret():
    now = time.time()
    if _jwt_secret_cache["secret"] and (now - _jwt_secret_cache["ts"]) < _JWT_SECRET_TTL:
        return _jwt_secret_cache["secret"]
    secret = (
        request.env["ir.config_parameter"]
        .sudo()
        .get_param("encoach.jwt_secret")
    )
    if secret:
        _jwt_secret_cache["secret"] = secret
        _jwt_secret_cache["ts"] = now
    return secret


def _extract_bearer_token(allow_query_param: bool = False) -> str | None:
    """Pull a JWT off the current request.

    By default we only honour the ``Authorization: Bearer …`` header. Some
    endpoints (notably media streams that get embedded in ``<img>`` /
    ``<audio>`` / ``<video>`` tags, where the browser cannot attach custom
    headers) opt in to ``allow_query_param=True`` so callers can send the
    token via ``?token=<jwt>`` or ``?access_token=<jwt>``. We deliberately
    keep this off by default so a leaked URL never gives access to JSON
    APIs — only to the specific raw-media route that opts in.
    """
    auth_header = request.httprequest.headers.get("Authorization", "")
    if auth_header.startswith("Bearer "):
        return auth_header[7:].strip() or None
    if allow_query_param:
        try:
            args = request.httprequest.args
        except Exception:
            args = {}
        token = (args.get("token") or args.get("access_token") or "").strip()
        if token:
            return token
    return None


def validate_token(allow_query_param: bool = False):
    """Decode JWT Bearer token and return the corresponding ``res.users`` record or None.

    See :func:`_extract_bearer_token` for the ``allow_query_param`` flag.
    """
    token = _extract_bearer_token(allow_query_param=allow_query_param)
    if not token:
        return None
    secret = _get_jwt_secret()
    if not secret:
        _logger.error("System parameter 'encoach.jwt_secret' is not configured")
        return None
    try:
        payload = pyjwt.decode(token, secret, algorithms=["HS256"])
    except pyjwt.ExpiredSignatureError:
        return None
    except pyjwt.InvalidTokenError:
        return None
    # Refresh tokens are only accepted by /api/auth/refresh. Presenting one as
    # a Bearer on any other endpoint is treated as unauthenticated so a
    # leaked refresh token never buys API access on its own.
    token_type = payload.get("type")
    if token_type and token_type != "access":
        return None
    user_id = payload.get("user_id")
    if not user_id:
        return None

    now = time.time()
    cache_key = int(user_id)
    cached = _user_exists_cache.get(cache_key)
    if cached and (now - cached["ts"]) < _USER_CACHE_TTL:
        return request.env["res.users"].sudo().browse(cache_key)

    user = request.env["res.users"].sudo().browse(cache_key)
    if not user.exists():
        return None

    _user_exists_cache[cache_key] = {"ts": now}
    if len(_user_exists_cache) > _USER_CACHE_MAX:
        oldest_key = min(_user_exists_cache, key=lambda k: _user_exists_cache[k]["ts"])
        del _user_exists_cache[oldest_key]
    return user


def jwt_required(func):
    """Decorator that validates the JWT token and sets request.env user context.

    Also handles per-request observability plumbing:

    - Assigns (or honours) an ``X-Request-Id`` so every log line, downstream
      service call, and response can be correlated back to the same request.
    - Emits a structured ``api.request.start`` / ``api.request.end`` log pair
      with duration in ms, status code, user id, route, and method — powering
      the P2.3 metrics pipeline once it lands without any extra wiring.
    - Echoes the request id back to the caller via the ``X-Request-Id``
      response header.
    """
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        req_id = _get_or_create_request_id()
        route = request.httprequest.path
        method = request.httprequest.method
        t0 = time.time()

        user = validate_token()
        if not user:
            _log_json(logging.INFO, "api.request.unauthorized",
                      route=route, method=method, status=401)
            resp = _error_response("Authentication required", status=401)
            try:
                resp.headers[REQUEST_ID_HEADER] = req_id
            except Exception:
                pass
            return resp

        request.update_env(user=user.id)
        _log_json(logging.INFO, "api.request.start",
                  route=route, method=method, user_id=user.id)
        try:
            resp = func(*args, **kwargs)
            status = getattr(resp, "status_code", 200)
            duration_ms = int((time.time() - t0) * 1000)
            _log_json(logging.INFO, "api.request.end",
                      route=route, method=method, user_id=user.id,
                      status=status, duration_ms=duration_ms)
            try:
                resp.headers[REQUEST_ID_HEADER] = req_id
            except Exception:
                pass
            _record_metric(route, status, duration_ms)
            return resp
        except Exception as exc:
            duration_ms = int((time.time() - t0) * 1000)
            _log_json(logging.ERROR, "api.request.error",
                      route=route, method=method, user_id=user.id,
                      duration_ms=duration_ms,
                      error=type(exc).__name__, message=str(exc)[:500])
            _record_metric(route, 500, duration_ms)
            raise
    return wrapper


def _record_metric(route, status, duration_ms):
    """Soft import of the metrics recorder so this module has no hard cycle."""
    try:
        from odoo.addons.encoach_api.controllers.openapi import record_request
        record_request(route, status, duration_ms)
    except Exception:
        pass


def _json_response(data, status=200):
    resp = request.make_json_response(data, status=status)
    try:
        rid = current_request_id()
        if rid:
            resp.headers[REQUEST_ID_HEADER] = rid
    except Exception:
        pass
    return resp


def _error_response(message, status=400, code=None):
    body = {"error": message}
    if code:
        body["code"] = code
    resp = request.make_json_response(body, status=status)
    try:
        rid = current_request_id()
        if rid:
            resp.headers[REQUEST_ID_HEADER] = rid
    except Exception:
        pass
    return resp


def _get_json_body():
    try:
        return json.loads(request.httprequest.get_data(as_text=True))
    except (ValueError, TypeError):
        return {}


def paginated_envelope(items, *, total=None, page=None, size=None, extra=None):
    """Canonical ``{items, total, page, size}`` envelope.

    This is the platform-wide pagination shape — prefer returning the output of
    this helper from any new list endpoint. Legacy keys (``data``, ``results``,
    and custom aliases such as ``students`` / ``subjects``) may be added via
    the ``extra`` dict for transitional backward compatibility with the
    frontend services that still read those keys; new code must read ``items``.
    """
    items = list(items or [])
    envelope = {
        "items": items,
        "total": int(total if total is not None else len(items)),
    }
    if page is not None:
        envelope["page"] = int(page)
    if size is not None:
        envelope["size"] = int(size)
    if extra:
        for k, v in extra.items():
            if k not in envelope:
                envelope[k] = v
    return envelope


def _paginate(model_or_kwargs, domain=None, page=0, size=20, order='id desc'):
    """Paginate an Odoo model search or extract params from a kwargs dict.

    Two calling conventions:
      _paginate(Model, domain, page, size, order)  → (recordset, total_count)
      _paginate(kwargs_dict)                        → (offset, limit, page)
    """
    if isinstance(model_or_kwargs, dict):
        kwargs = model_or_kwargs
        limit = min(max(int(kwargs.get("size", kwargs.get("limit", 20))), 1), 200)
        page_raw = int(kwargs.get("page", 0))
        pg = max(page_raw, 0)
        offset = pg * limit
        return offset, limit, pg

    Model = model_or_kwargs
    limit = min(max(int(size), 1), 200)
    pg = max(int(page), 0)
    offset = pg * limit
    total = Model.search_count(domain or [])
    records = Model.search(domain or [], offset=offset, limit=limit, order=order)
    return records, total


class EncoachMixin:
    """Shared authentication and response helpers for all EnCoach API controllers."""

    def _get_jwt_secret(self):
        return _get_jwt_secret()

    def _authenticate(self):
        return validate_token()

    def _json_response(self, data, status=200):
        return _json_response(data, status=status)

    def _error_response(self, message, status=400, code=None):
        return _error_response(message, status=status, code=code)

    def _get_json_body(self):
        return _get_json_body()

    def _paginate_params(self, kwargs):
        return _paginate(kwargs)

    def _serialize(self, record):
        if hasattr(record, "to_encoach_dict"):
            return record.to_encoach_dict()
        return {"id": record.id}

    def _serialize_list(self, records):
        return [self._serialize(r) for r in records]
