"""Unit tests for :mod:`mgf.common.observability.json_formatter`.

Verifies the JSON shape (every documented top-level key present),
extras are promoted, exceptions are formatted, redaction runs, and
OpenTelemetry trace context is attached when a span is active (and
absent when no span / no SDK).
"""

from __future__ import annotations

import json
import logging
from typing import Any

import pytest

from mgf.common._identity import _set_identity
from mgf.common.observability.json_formatter import JsonFormatter
from mgf.common.observability.redaction import REDACTED_PLACEHOLDER, Redactor


@pytest.fixture(autouse=True)
def _identity() -> None:
    # JsonFormatter now embeds app/app_version (canonical LogRecord shape);
    # set identity so app_name() doesn't fire UnconfiguredWarning (the suite
    # escalates warnings to errors). In production the file handler only runs
    # post-bootstrap, where identity is always configured.
    _set_identity("fmt-test", "1.2.3")


def _make_record(
    *,
    name: str = "mgf.test",
    level: int = logging.INFO,
    msg: str = "hello",
    extra: dict[str, Any] | None = None,
    exc_info: object = None,
) -> logging.LogRecord:
    record = logging.LogRecord(
        name=name,
        level=level,
        pathname="test.py",
        lineno=42,
        msg=msg,
        args=None,
        exc_info=exc_info,  # type: ignore[arg-type]
        func="test_func",
    )
    if extra:
        for k, v in extra.items():
            setattr(record, k, v)
    return record


# ---------------------------------------------------------------------------
# Shape
# ---------------------------------------------------------------------------


def test_record_shape_has_documented_top_level_keys() -> None:
    fmt = JsonFormatter(redactor=Redactor.default())
    record = _make_record(msg="started")
    out = json.loads(fmt.format(record))
    assert set(out.keys()) >= {
        "ts",
        "level",
        "logger",
        "msg",
        "module",
        "func",
        "line",
        "thread",
        "process",
    }
    assert out["msg"] == "started"
    assert out["level"] == "INFO"
    assert out["logger"] == "mgf.test"


def test_output_parses_as_canonical_log_record() -> None:
    # F1 (unified-logging review): a file line is a valid canonical LogRecord —
    # identity/version embedded, not defaulted. Makes the at-rest sink directly
    # shippable + parsable, closing the at-rest vs live/shipped divergence.
    from mgf.common.observability import LOG_SCHEMA_VERSION, LogRecord

    fmt = JsonFormatter(redactor=Redactor.default())
    rec = LogRecord.from_jsonl_line(fmt.format(_make_record(name="app.db", msg="connected")))
    assert rec.logger == "app.db"
    assert rec.msg == "connected"
    assert rec.app == "fmt-test"  # embedded identity, not the "" default
    assert rec.app_version == "1.2.3"
    assert rec.lang == "python"
    assert rec.level_no == logging.INFO
    assert rec.schema_version == LOG_SCHEMA_VERSION


def test_extras_are_promoted_to_top_level_keys() -> None:
    fmt = JsonFormatter(redactor=Redactor.default())
    record = _make_record(extra={"vm_name": "prod-1", "duration_ms": 142})
    out = json.loads(fmt.format(record))
    assert out["vm_name"] == "prod-1"
    assert out["duration_ms"] == 142


def test_redaction_runs_on_extras() -> None:
    fmt = JsonFormatter(redactor=Redactor.default())
    record = _make_record(extra={"password": "supersecret", "user": "alice"})
    out = json.loads(fmt.format(record))
    assert out["password"] == REDACTED_PLACEHOLDER
    assert out["user"] == "alice"


def test_one_record_is_one_line() -> None:
    fmt = JsonFormatter(redactor=Redactor.default())
    record = _make_record(extra={"vm_name": "x", "msg_count": 5})
    line = fmt.format(record)
    assert "\n" not in line


# ---------------------------------------------------------------------------
# Exception formatting
# ---------------------------------------------------------------------------


def test_exc_info_is_attached_as_exception_field() -> None:
    fmt = JsonFormatter(redactor=Redactor.default())
    try:
        raise RuntimeError("boom")
    except RuntimeError:
        import sys

        record = _make_record(level=logging.ERROR, msg="fail", exc_info=sys.exc_info())
    out = json.loads(fmt.format(record))
    assert "exception" in out
    assert "RuntimeError" in out["exception"]
    assert "boom" in out["exception"]


# ---------------------------------------------------------------------------
# OpenTelemetry trace context
# ---------------------------------------------------------------------------


def test_no_otel_no_trace_keys_in_record() -> None:
    """Without an active span (or SDK), trace keys are absent."""
    fmt = JsonFormatter(redactor=Redactor.default())
    record = _make_record(msg="no span")
    out = json.loads(fmt.format(record))
    # Either OTel is not installed at all (keys absent) OR OTel is
    # installed but no active span (keys absent because span is the
    # invalid no-op span). Either way, no trace_id / span_id present.
    assert "trace_id" not in out
    assert "span_id" not in out


# ---------------------------------------------------------------------------
# RA-20 — Dynamic _RESERVED_RECORD_ATTRS derivation
# ---------------------------------------------------------------------------


class TestRA20DynamicReservedAttrs:
    """RA-20: ``_RESERVED_RECORD_ATTRS`` is derived at module-load
    time from a probe ``LogRecord``, not hardcoded.

    Why: hardcoding risked Python 3.13+ adding a new ``LogRecord``
    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 shipped a patch. The dynamic
    derivation captures whatever the current interpreter provides.
    """

    def test_derived_set_covers_every_logrecord_init_attr(self) -> None:
        """The probe's ``__dict__`` keys MUST all be in the
        reserved set — that's the whole point. If
        ``LogRecord.__init__`` added a new attribute, the test
        would fail loudly and force a refresh."""
        import logging as _logging

        from mgf.common.observability.json_formatter import (
            _RESERVED_RECORD_ATTRS,
        )

        probe = _logging.LogRecord(
            name="probe",
            level=_logging.INFO,
            pathname="<probe>",
            lineno=0,
            msg="probe",
            args=None,
            exc_info=None,
        )
        missing = set(probe.__dict__.keys()) - _RESERVED_RECORD_ATTRS
        assert missing == set(), (
            f"RA-20: probe LogRecord has attributes the reserved set "
            f"does not cover: {missing}. The derivation in "
            f"_derive_reserved_record_attrs() should already include "
            f"them — investigate the import-time invocation."
        )

    def test_derived_set_includes_formatter_added_attrs(self) -> None:
        """``message`` and ``asctime`` are populated by
        ``Formatter.format`` / ``LogRecord.getMessage``, not by
        ``__init__``, so they don't show up in the probe's
        ``__dict__``. The derivation explicitly unions them in;
        pin that union here so a refactor doesn't drop them."""
        from mgf.common.observability.json_formatter import (
            _RESERVED_RECORD_ATTRS,
        )

        assert "message" in _RESERVED_RECORD_ATTRS
        assert "asctime" in _RESERVED_RECORD_ATTRS

    def test_no_spurious_top_level_keys_on_plain_record(self) -> None:
        """The user-facing guarantee in JSON-output terms: a record
        with no ``extra={...}`` MUST produce only the documented
        top-level keys. If a future Python adds a new ``LogRecord``
        attribute that the dynamic derivation missed, that attribute
        would surface here as a spurious top-level field — exactly
        the failure mode this test exists to catch."""
        fmt = JsonFormatter(redactor=Redactor.default())
        record = _make_record(msg="hi")
        out = json.loads(fmt.format(record))
        documented = {
            "ts",
            "level",
            "level_no",
            "logger",
            "msg",
            "module",
            "func",
            "line",
            "thread",
            "process",
            # canonical LogRecord identity/version (so a file line is a valid
            # canonical record — directly shippable / parsable as LogRecord)
            "app",
            "app_version",
            "lang",
            "host",
            "schema_version",
        }
        allowed = documented | {"exception", "stack", "trace_id", "span_id"}
        spurious = set(out.keys()) - allowed
        assert spurious == set(), (
            f"RA-20: JsonFormatter produced unexpected top-level keys "
            f"on a record with no extras: {spurious}. Usually means "
            f"a LogRecord attribute was added in a Python upgrade and "
            f"the reserved-set derivation missed it — investigate "
            f"_derive_reserved_record_attrs() in json_formatter.py."
        )

    def test_explicit_extras_still_promote(self) -> None:
        """Sanity: the dynamic derivation must NOT over-reserve.
        Genuine extras (passed via ``extra={...}``) must still land
        as top-level JSON keys; otherwise the formatter is broken."""
        fmt = JsonFormatter(redactor=Redactor.default())
        record = _make_record(msg="hi")
        # Simulate ``logger.info("hi", extra={"request_id": "abc"})``.
        record.request_id = "abc"  # type: ignore[attr-defined]
        out = json.loads(fmt.format(record))
        assert out["request_id"] == "abc"


# ---------------------------------------------------------------------------
# PAPER-45 (v1.0) — ``field_map=`` wire-shape adapter
# ---------------------------------------------------------------------------


class TestPaper45FieldMap:
    """Closes PAPER-45 — ``JsonFormatter(field_map={...})`` for
    consumers whose log-aggregator parser expects a different
    canonical-key naming (``{timestamp, message, ...}`` rather than
    ``{ts, msg, ...}``).
    """

    def test_default_is_strict_no_op_pre_paper45_shape(self) -> None:
        """No ``field_map`` argument → output is byte-identical to
        the pre-PAPER-45 shape. The whole point of Approach A: zero
        migration risk for existing consumers."""
        baseline_fmt = JsonFormatter(redactor=Redactor.default())
        v1_fmt = JsonFormatter(
            redactor=Redactor.default(),
            field_map=None,
        )
        empty_map_fmt = JsonFormatter(
            redactor=Redactor.default(),
            field_map={},
        )
        # Use a stable record so timestamp jitter doesn't break the
        # byte-comparison.
        record = _make_record(msg="hello world")
        # Drop time-jittery fields before comparing.
        baseline = json.loads(baseline_fmt.format(record))
        with_v1 = json.loads(v1_fmt.format(record))
        with_empty = json.loads(empty_map_fmt.format(record))
        baseline.pop("ts", None)
        with_v1.pop("ts", None)
        with_empty.pop("ts", None)
        assert baseline == with_v1 == with_empty

    def test_field_map_renames_canonical_top_level_keys(self) -> None:
        """``{"ts": "timestamp", "msg": "message"}`` produces
        ``{"timestamp": ..., "message": ...}``; the canonical names
        are NOT present in the output."""
        fmt = JsonFormatter(
            redactor=Redactor.default(),
            field_map={"ts": "timestamp", "msg": "message"},
        )
        out = json.loads(fmt.format(_make_record(msg="hello")))
        assert "timestamp" in out
        assert "message" in out
        assert out["message"] == "hello"
        assert "ts" not in out
        assert "msg" not in out

    def test_field_map_preserves_insertion_order(self) -> None:
        """A renamed key appears at the same position it would have
        in the unrenamed shape. Aggregator-side schemas that walk
        keys in order (rare but real) stay valid."""
        fmt = JsonFormatter(
            redactor=Redactor.default(),
            field_map={"ts": "timestamp"},
        )
        # json.dumps preserves dict insertion order; the raw JSON
        # string shape is what aggregator parsers see.
        raw = fmt.format(_make_record(msg="hi"))
        # ``timestamp`` is the FIRST key in the JSON (canonical ts
        # is the first emitted key in the formatter).
        assert raw.startswith('{"timestamp"'), (
            f"renamed key should keep its original first-position; got: {raw[:80]}"
        )

    def test_extras_are_not_renamed_by_field_map(self) -> None:
        """The map applies to canonical mgf fields ONLY. Extras
        promoted from ``extra={...}`` come from caller-controlled
        names and are not subject to rename."""
        fmt = JsonFormatter(
            redactor=Redactor.default(),
            field_map={"ts": "timestamp", "request_id": "should_not_apply"},
        )
        record = _make_record(msg="hi")
        record.request_id = "abc"  # type: ignore[attr-defined]
        out = json.loads(fmt.format(record))
        # Canonical rename applied.
        assert "timestamp" in out
        # Extras key untouched even though it appears in field_map.
        assert out["request_id"] == "abc"
        assert "should_not_apply" not in out

    def test_redactor_runs_before_rename(self) -> None:
        """Redactor scrubs by canonical-extras-name BEFORE the
        rename pass. A consumer's redaction policy doesn't need to
        know about the wire-shape map."""
        fmt = JsonFormatter(
            redactor=Redactor.default(),
            field_map={"msg": "message"},
        )
        record = _make_record(msg="ok")
        # ``password`` is one of the default-redacted extras keys.
        record.password = "p@ss"  # type: ignore[attr-defined]
        out = json.loads(fmt.format(record))
        # Rename applied to canonical msg → message.
        assert out["message"] == "ok"
        # Redactor still scrubs the extras-side secret.
        assert out["password"] == REDACTED_PLACEHOLDER

    def test_field_map_can_rename_multiple_canonical_keys(self) -> None:
        """A larger map produces a full wire-shape conversion in
        one construction — the inthewords-shape
        ``{timestamp, level, logger, message, extra: {...}}``
        partial migration is just multiple kwargs."""
        fmt = JsonFormatter(
            redactor=Redactor.default(),
            field_map={
                "ts": "timestamp",
                "msg": "message",
                "logger": "channel",
                "level": "severity",
            },
        )
        out = json.loads(fmt.format(_make_record(msg="x")))
        assert {"timestamp", "message", "channel", "severity"} <= set(out)
        for canonical in ("ts", "msg", "logger", "level"):
            assert canonical not in out

    def test_field_map_target_collision_later_wins(self) -> None:
        """Documented gotcha: mapping two canonical keys to the
        same target yields a single key whose value is whichever
        canonical name was emitted LATER (per dict semantics).
        Test pins the behaviour so it can't silently regress."""
        fmt = JsonFormatter(
            redactor=Redactor.default(),
            # ``level`` is emitted AFTER ``ts`` in the formatter, so
            # the level value should win.
            field_map={"ts": "collision_target", "level": "collision_target"},
        )
        out = json.loads(fmt.format(_make_record(msg="x")))
        assert out["collision_target"] == "INFO"
        # The earlier-emitted key (ts) is overwritten and gone.
        assert "ts" not in out
        assert "level" not in out


# ---------------------------------------------------------------------------
# LG-14 — request-id correlation; LG-17 — exception fingerprint
# ---------------------------------------------------------------------------


class TestRequestIdCorrelation:
    """LG-14: the formatter promotes the request-id contextvar onto records."""

    def test_request_id_promoted_when_set(self) -> None:
        from mgf.common._request_id import reset_request_id, set_request_id

        fmt = JsonFormatter(redactor=Redactor.default())
        tok = set_request_id("req-xyz")
        try:
            out = json.loads(fmt.format(_make_record(msg="in-request")))
        finally:
            reset_request_id(tok)
        assert out["request_id"] == "req-xyz"

    def test_request_id_absent_outside_request(self) -> None:
        fmt = JsonFormatter(redactor=Redactor.default())
        out = json.loads(fmt.format(_make_record(msg="no-request")))
        assert "request_id" not in out

    def test_explicit_extra_request_id_wins(self) -> None:
        from mgf.common._request_id import reset_request_id, set_request_id

        fmt = JsonFormatter(redactor=Redactor.default())
        tok = set_request_id("ctx-id")
        try:
            out = json.loads(fmt.format(_make_record(extra={"request_id": "explicit-id"})))
        finally:
            reset_request_id(tok)
        assert out["request_id"] == "explicit-id"


class TestExceptionFingerprint:
    """LG-17: exception records carry a stable type fingerprint."""

    def test_fingerprint_from_exception_type(self) -> None:
        fmt = JsonFormatter(redactor=Redactor.default())
        try:
            raise ValueError("boom")
        except ValueError:
            import sys

            rec = _make_record(level=logging.ERROR, exc_info=sys.exc_info())
        out = json.loads(fmt.format(rec))
        assert out["fingerprint"] == "builtins.ValueError"

    def test_no_fingerprint_without_exception(self) -> None:
        fmt = JsonFormatter(redactor=Redactor.default())
        out = json.loads(fmt.format(_make_record(msg="plain")))
        assert "fingerprint" not in out
