"""JSON formatter — one JSON object per line, OTel-aware, redacted.

This is the formatter every aggregator (Loki / ELK / Splunk / Datadog
/ Cloudwatch / journald with ``--output=json``) understands out of the
box. Every record carries the OpenTelemetry ``trace_id`` and ``span_id``
when a span is active (rule LG-03). Sensitive fields are scrubbed by
the configured :class:`Redactor` before serialization (rule LG-04).

See ``docs/standards/LOGGING.md`` §5.3.

Pure stdlib + optional OTel — no other mgf.common imports beyond
the type-checking-only ``Redactor`` reference.
"""

from __future__ import annotations

import json
import logging
import socket
import time
from typing import TYPE_CHECKING, Any

from mgf.common._request_id import current_request_id
from mgf.common.observability.log_record import LOG_SCHEMA_VERSION

if TYPE_CHECKING:
    from collections.abc import Mapping

    from mgf.common.observability.redaction import Redactor

# Hostname is a per-process constant; resolve it once at import so the
# per-record hot path never pays the ``socket.gethostname()`` syscall.
_HOSTNAME = socket.gethostname()

_identity_fns: tuple[Any, Any] | None = None


def _process_identity() -> tuple[str, str]:
    """``(app_name, app_version)`` for the running process.

    Lazy + cached import so the formatter keeps no top-level dependency on
    :mod:`mgf.common` (avoids an import cycle); the file handler only runs
    post-``bootstrap``, so identity is always configured by call time.
    """
    global _identity_fns
    if _identity_fns is None:
        from mgf.common import app_name, app_version

        _identity_fns = (app_name, app_version)
    name_fn, version_fn = _identity_fns
    return name_fn(), version_fn()


def _derive_reserved_record_attrs() -> frozenset[str]:
    """RA-20: capture the stdlib ``LogRecord`` attribute set for THIS
    Python interpreter.

    Anything in a record's ``__dict__`` that isn't one of these is
    treated as a custom field (came from ``extra={...}``) and promoted
    to a top-level JSON key. Hardcoding the set risked Python 3.13+
    adding a new attribute (the way 3.12 added ``taskName``) and that
    attribute polluting every consumer's JSON schema as a spurious
    top-level field — silently — until someone noticed and shipped a
    patch.

    The derivation:

    1. Construct a probe :class:`logging.LogRecord` and read its
       ``__dict__`` keys — that captures every attribute set in
       ``LogRecord.__init__`` on this interpreter (including any
       future addition).
    2. Union in ``message`` and ``asctime``: those are populated by
       :meth:`logging.Formatter.format` / :meth:`LogRecord.getMessage`,
       not by ``LogRecord.__init__``, so they don't appear in the
       probe's ``__dict__`` — but they ARE stdlib-populated and MUST
       be reserved (otherwise an ``extra={"message": ...}`` would
       collide with the canonical key).

    Module-load time evaluation is intentional: zero runtime cost on
    the per-record path; one probe allocation at import.
    """
    probe = logging.LogRecord(
        name="probe",
        level=logging.INFO,
        pathname="<probe>",
        lineno=0,
        msg="probe",
        args=None,
        exc_info=None,
    )
    base = set(probe.__dict__.keys())
    base |= {"message", "asctime"}
    return frozenset(base)


# Standard ``LogRecord`` attributes — anything else in the record's
# ``__dict__`` is treated as a custom field (came from ``extra={...}``)
# and promoted to a top-level JSON key. Derived from a probe at
# module-load time so future Python versions adding new attributes
# (the way 3.12 added ``taskName``) don't pollute the JSON schema.
_RESERVED_RECORD_ATTRS: frozenset[str] = _derive_reserved_record_attrs()


# PAPER-45 (v1.0). Canonical top-level keys eligible for the
# ``field_map=`` rename. Mirrors the JsonFormatter docstring's
# "Output schema" bullet list. Extras keys (promoted from
# ``extra={...}``) are NOT in this set — those names come from the
# caller and are already under caller control.
_RENAMEABLE_CANONICAL_FIELDS: frozenset[str] = frozenset(
    {
        "ts",
        "level",
        "logger",
        "msg",
        "module",
        "func",
        "line",
        "thread",
        "process",
        "trace_id",
        "span_id",
        "exception",
        "stack",
    }
)


class JsonFormatter(logging.Formatter):
    """Format log records as one JSON object per line.

    Output schema (top-level keys; never rename without a MAJOR bump).
    The shape is a superset of the canonical :class:`LogRecord` core, so a
    file line is a valid canonical record (directly shippable to a collector
    or parsable via ``LogRecord.from_jsonl_line``):
      - ``ts``       — ISO-8601 UTC timestamp with millisecond precision
      - ``level``    — DEBUG / INFO / WARNING / ERROR / CRITICAL
      - ``level_no`` — numeric level (enables threshold filters on readback)
      - ``logger``   — logger name (e.g., ``vmanager.gui.dashboard``)
      - ``msg``      — the formatted message
      - ``app``      — originating app name (``app_name()``)
      - ``app_version`` — originating app version
      - ``lang``     — emit runtime (``"python"``)
      - ``host``     — origin hostname
      - ``module``   — Python module the call was from
      - ``func``     — function name
      - ``line``     — source line number
      - ``thread``   — thread name (e.g., ``MainThread``)
      - ``process``  — process id
      - ``schema_version`` — :data:`LOG_SCHEMA_VERSION` (OB-09 evolution)
      - ``trace_id`` — OpenTelemetry trace id (when a span is active)
      - ``span_id``  — OpenTelemetry span id (when a span is active)
      - ``exception`` — formatted traceback (when ``exc_info`` is set)
      - ``stack``    — formatted call stack (when ``stack_info`` is set)
      - ``<extras>`` — every non-reserved key on the record (came from
                       ``extra={...}``) promoted to a top-level key

    Extras whose key collides with a reserved name are silently
    dropped (their values would be confusing) — callers who want to
    emit a field named ``msg`` MUST rename it.

    **Wire-shape adaptation via ``field_map=``** (closes PAPER-45,
    v1.0). Consumers whose log aggregator's parser expects a
    different canonical-field naming (e.g., ``{timestamp, message}``
    rather than ``{ts, msg}``) can pass a rename map at construction
    time::

        formatter = JsonFormatter(
            redactor=my_redactor,
            field_map={"ts": "timestamp", "msg": "message"},
        )

    Rules:

    - The map renames **canonical mgf fields only** — the keys listed
      in the bulleted schema above. It does NOT rename extras
      promoted from ``extra={...}`` (those names come from the
      caller; they're already under caller control).
    - The default (no ``field_map`` argument, or ``field_map={}``) is
      a strict no-op — output is byte-identical to pre-PAPER-45
      behaviour.
    - Insertion order is preserved: a renamed key appears at the
      same position it would have in the unrenamed shape.
    - If the consumer maps two canonical names to the same target
      (e.g. ``{"ts": "x", "msg": "x"}``), the later-emitted value
      wins per dict semantics. Don't do that.
    - The redactor runs against the **canonical field names**, NOT
      the rename targets. A consumer's redaction policy doesn't need
      to know about the rename map.
    """

    def __init__(
        self,
        *,
        redactor: Redactor,
        field_map: Mapping[str, str] | None = None,
    ) -> None:
        super().__init__()
        self._redactor = redactor
        # Frozen at construction so the per-record hot path stays
        # validation-free. Empty dict (the default) is the no-op
        # branch in :meth:`format`.
        self._field_map: dict[str, str] = dict(field_map or {})

    @property
    def redactor(self) -> Redactor:
        """The :class:`Redactor` this formatter applies before emit.

        Public, stable accessor (v0.23.0). Used by
        :func:`mgf.common.process._streamer.get_active_redactor` to
        discover the bootstrap-wired redactor without depending on
        the private ``_redactor`` attribute name.
        """
        return self._redactor

    def format(self, record: logging.LogRecord) -> str:
        app, app_version = _process_identity()
        payload: dict[str, Any] = {
            "ts": _iso_utc(record.created),
            "level": record.levelname,
            "level_no": record.levelno,
            "logger": record.name,
            "msg": record.getMessage(),
            "app": app,
            "app_version": app_version,
            "lang": "python",
            "host": _HOSTNAME,
            "module": record.module,
            "func": record.funcName,
            "line": record.lineno,
            "thread": record.threadName,
            "process": record.process,
            "schema_version": LOG_SCHEMA_VERSION,
        }

        # Promote extras (anything passed via `extra={...}`).
        for key, value in record.__dict__.items():
            if key in _RESERVED_RECORD_ATTRS or key.startswith("_"):
                continue
            payload[key] = value

        # OpenTelemetry trace context (rule LG-03). Lazy: when OTel is
        # not installed or no span is active, the keys are simply absent.
        _add_otel_context(payload)

        # Request-id correlation (rule LG-14). Promote the contextvar the
        # framework adapter's request-id middleware sets, so every line
        # emitted inside a request carries it. Empty outside a request →
        # the key is simply absent. `setdefault` preserves an explicit
        # `extra={"request_id": ...}` if the caller passed one.
        request_id = current_request_id()
        if request_id:
            payload.setdefault("request_id", request_id)

        if record.exc_info:
            payload["exception"] = self.formatException(record.exc_info)
            # Exception fingerprint (rule LG-17) — a stable group key
            # ("the same error class") computed automatically from the
            # exception type, unless the caller set one explicitly.
            exc_type = record.exc_info[0]
            if exc_type is not None:
                payload.setdefault(
                    "fingerprint",
                    f"{exc_type.__module__}.{exc_type.__qualname__}",
                )
        if record.stack_info:
            payload["stack"] = self.formatStack(record.stack_info)

        # Redact (rule LG-04) — must happen AFTER extras are merged so
        # secrets passed via ``extra={"password": ...}`` are scrubbed.
        # Redactor runs against the canonical field names; the
        # PAPER-45 rename happens AFTER redact so consumer redaction
        # policies don't need to know about the wire-shape map.
        self._redactor.redact(payload)

        # PAPER-45 wire-shape adaptation (v1.0). Renames CANONICAL
        # keys only — extras keys (caller-controlled names already)
        # aren't subject to the map. Insertion order is preserved so
        # a renamed key stays at the same position it would have in
        # the unrenamed shape. The truthiness short-circuit keeps
        # the default path (no rename) zero-cost.
        if self._field_map:
            new_payload: dict[str, Any] = {}
            for k, v in payload.items():
                if k in _RENAMEABLE_CANONICAL_FIELDS and k in self._field_map:
                    new_payload[self._field_map[k]] = v
                else:
                    new_payload[k] = v
            payload = new_payload

        return json.dumps(payload, default=_json_default, ensure_ascii=False)


def _iso_utc(epoch: float) -> str:
    """Format a Unix epoch as ISO-8601 UTC with millisecond precision."""
    base = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(epoch))
    millis = int((epoch - int(epoch)) * 1000)
    return f"{base}.{millis:03d}Z"


def _json_default(obj: Any) -> Any:
    """Best-effort JSON encoder for non-JSON-native types.

    ``bytes`` decoded as UTF-8 (replacement on bad sequences). Anything
    else falls back to ``repr`` so the record still serializes.
    """
    if isinstance(obj, bytes):
        return obj.decode("utf-8", errors="replace")
    return repr(obj)


def _add_otel_context(payload: dict[str, Any]) -> None:
    """Attach ``trace_id`` and ``span_id`` when an OTel span is active.

    Lazy import so the formatter works in installs without the OTel
    extra. When no span is active or OTel is uninitialised, the
    no-op ``InvalidSpan`` is returned and the keys are not added.
    """
    try:
        # ``opentelemetry.trace`` is the documented public surface for
        # ``get_current_span``. The pyproject mypy override flags the
        # whole package as missing-imports for installs without the
        # ``[observability]`` extra; with the extra installed mypy
        # gets stricter stubs but still does not surface
        # ``get_current_span`` as a top-level attribute reliably
        # across SDK versions. ``getattr`` keeps both paths working.
        from opentelemetry import trace
    except ImportError:
        return
    get_current_span = getattr(trace, "get_current_span", None)
    if get_current_span is None:
        return
    span = get_current_span()
    ctx = span.get_span_context() if span is not None else None
    if ctx is not None and ctx.is_valid:
        payload["trace_id"] = format(ctx.trace_id, "032x")
        payload["span_id"] = format(ctx.span_id, "016x")


__all__ = ["JsonFormatter"]
