"""Canonical structured log record — the cross-language ``LogRecord`` schema.

The single shape every Magogi runtime (Python / Zig / TS) emits and every
consumer reads — CLI tail, the per-app web stream, logging statistics, and
the centralized shipper. It is the logging analog of
:class:`mgf.common.testing.TestRecord`: one versioned, language-neutral
JSON contract, owned here in the cornerstone.

It **formalizes the wire shape** :class:`~mgf.common.observability.JsonFormatter`
already emits (``ts`` / ``level`` / ``logger`` / ``msg`` / ``module`` /
``func`` / ``line`` / ``thread`` / ``process`` + ``trace_id`` / ``span_id``
+ ``exception`` / ``stack``) and adds the fields unified access +
centralized collection need: ``app`` / ``app_version`` / ``host`` /
``lang`` / ``level_no`` / ``env`` / ``labels``, with arbitrary structured
context in ``fields``.

Design: ``docs/inprogress/UNIFIED_LOGGING_DESIGN.md`` (Wave 1).

Extending the schema — **add new things without a redesign or a break.**
The schema is built to grow. Four rules guarantee it (OB-09):

1. **Need a new field NOW? Don't touch the schema — use ``fields``**
   (arbitrary structured context) or ``labels`` (string tags). They
   round-trip losslessly; nothing else changes. This is the everyday
   extension path.
2. **Promoting a field to a typed top-level key later** is additive: add
   it as an *optional* field with a default and bump
   :data:`LOG_SCHEMA_VERSION`. Old readers ignore it; new readers default
   it for old records. Non-breaking in both directions.
3. **Forward-compatible + lossless:** a record carrying keys this version
   doesn't know is parsed without error AND those keys are *preserved*
   through a round-trip (kept in :attr:`extra`), so an older relay/reader
   never silently strips a newer producer's data.
4. **Never rename, repurpose, remove, or change the type of an existing
   key without a MAJOR ``schema_version`` bump.** That is the only
   breaking move, and it's loud.
"""

from __future__ import annotations

import json
import socket
import traceback
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    import logging

LOG_SCHEMA_VERSION = "1.0.0"

# Standard :class:`logging.LogRecord` attributes — everything NOT in here
# that a record carries is treated as caller-supplied ``extra=`` structured
# context and harvested into :attr:`LogRecord.fields`.
_STDLIB_LOGRECORD_ATTRS = frozenset(
    {
        "name",
        "msg",
        "args",
        "levelname",
        "levelno",
        "pathname",
        "filename",
        "module",
        "exc_info",
        "exc_text",
        "stack_info",
        "lineno",
        "funcName",
        "created",
        "msecs",
        "relativeCreated",
        "thread",
        "threadName",
        "processName",
        "process",
        "taskName",
        "message",
        "asctime",
    }
)


# Top-level keys this schema version recognises. Anything else on the
# wire is a newer-schema field, preserved via LogRecord.extra (rule 3).
_KNOWN_TOP_LEVEL_KEYS = frozenset(
    {
        "ts",
        "level",
        "level_no",
        "logger",
        "msg",
        "app",
        "app_version",
        "lang",
        "env",
        "module",
        "func",
        "line",
        "thread",
        "process",
        "host",
        "trace_id",
        "span_id",
        "request_id",
        "exception",
        "stack",
        "fields",
        "labels",
        "schema_version",
    }
)


def _iso_utc(epoch_seconds: float) -> str:
    """RFC3339 UTC timestamp from an epoch float (matches JsonFormatter)."""
    return datetime.fromtimestamp(epoch_seconds, tz=UTC).isoformat()


@dataclass
class LogRecord:
    """One structured log event in the canonical Magogi shape.

    **Mutable by design (DP-03 exception).** Unlike the frozen domain
    types DP-03 mandates, this is a wire-shape *data carrier*: a relay
    reads a record, enriches ``fields``/``labels``, and forwards it, so
    the pass-through must be writable. Mutation is the intended use, not
    an accident — hence not ``frozen=True``.
    """

    # --- core (formalizes JsonFormatter's keys) ---
    ts: str  # RFC3339 UTC
    level: str  # level NAME (DEBUG/INFO/WARNING/ERROR/CRITICAL)
    logger: str  # the per-component axis (logging.LogRecord.name)
    msg: str  # redacted message

    # --- origin ---
    module: str = ""
    func: str = ""
    line: int = 0
    thread: str = ""
    process: int = 0
    host: str = ""

    # --- identity (cross-app / cross-language) ---
    app: str = ""
    app_version: str = ""
    lang: str = "python"
    env: str | None = None

    # --- severity (numeric — free, for ordering/threshold filters) ---
    level_no: int = 0

    # --- correlation ---
    trace_id: str | None = None
    span_id: str | None = None
    request_id: str | None = None

    # --- error ---
    exception: str | None = None
    stack: str | None = None

    # --- structured context + tags (the everyday extension path) ---
    fields: dict[str, Any] = field(default_factory=dict)
    labels: dict[str, str] = field(default_factory=dict)

    # --- forward-compat pass-through ---
    # Unknown top-level keys from a NEWER schema, preserved verbatim so an
    # older reader/relay round-trips them losslessly (rule 3 above). Not an
    # extension point you write to directly — use `fields` for that.
    extra: dict[str, Any] = field(default_factory=dict)

    # --- versioning ---
    schema_version: str = LOG_SCHEMA_VERSION

    # Always-present core keys; everything else is omitted from the wire
    # when empty/None to keep lines compact (matches JsonFormatter, which
    # only adds optional keys when present).
    _CORE = ("ts", "level", "level_no", "logger", "msg", "app", "lang")

    def to_dict(self) -> dict[str, object]:
        """Serialise to the wire dict. Core keys always present; optional
        keys included only when set (compact lines)."""
        out: dict[str, object] = {
            "ts": self.ts,
            "level": self.level,
            "level_no": self.level_no,
            "logger": self.logger,
            "msg": self.msg,
            "app": self.app,
            "lang": self.lang,
            "schema_version": self.schema_version,
        }
        # Origin + identity: include when non-default.
        for key in (
            "module",
            "func",
            "line",
            "thread",
            "process",
            "host",
            "app_version",
            "env",
            "trace_id",
            "span_id",
            "request_id",
            "exception",
            "stack",
        ):
            val = getattr(self, key)
            if val not in (None, "", 0):
                out[key] = val
        if self.fields:
            out["fields"] = dict(self.fields)
        if self.labels:
            out["labels"] = dict(self.labels)
        # Re-emit preserved unknown keys from a newer schema (never
        # clobber a key this version owns).
        for key, value in self.extra.items():
            out.setdefault(key, value)
        return out

    def to_jsonl_line(self) -> str:
        """Serialise to one JSONL line (single-line JSON + trailing newline).

        ``ensure_ascii=False`` so the bytes match what the Zig/TS emitters
        produce natively (raw UTF-8, no ``\\uXXXX`` escaping) — the
        cross-language byte-identity contract (OB-10)."""
        return (
            json.dumps(self.to_dict(), separators=(",", ":"), ensure_ascii=False, default=str)
            + "\n"
        )

    @classmethod
    def from_dict(cls, data: dict[str, object]) -> LogRecord:
        """Parse from the wire dict. Tolerant: unknown keys ignored,
        missing optionals defaulted (forward/backward compatible)."""
        return cls(
            ts=str(data["ts"]),
            level=str(data["level"]),
            logger=str(data["logger"]),
            msg=str(data["msg"]),
            module=str(data.get("module", "")),
            func=str(data.get("func", "")),
            line=_as_int(data.get("line")),
            thread=str(data.get("thread", "")),
            process=_as_int(data.get("process")),
            host=str(data.get("host", "")),
            app=str(data.get("app", "")),
            app_version=str(data.get("app_version", "")),
            lang=str(data.get("lang", "python")),
            env=_opt_str(data.get("env")),
            level_no=_as_int(data.get("level_no")),
            trace_id=_opt_str(data.get("trace_id")),
            span_id=_opt_str(data.get("span_id")),
            request_id=_opt_str(data.get("request_id")),
            exception=_opt_str(data.get("exception")),
            stack=_opt_str(data.get("stack")),
            fields=_as_dict(data.get("fields")),
            labels={str(k): str(v) for k, v in _as_dict(data.get("labels")).items()},
            # Preserve any keys a future schema added that this version
            # doesn't recognise (rule 3 — lossless forward-compat).
            extra={k: v for k, v in data.items() if k not in _KNOWN_TOP_LEVEL_KEYS},
            schema_version=str(data.get("schema_version", LOG_SCHEMA_VERSION)),
        )

    @classmethod
    def from_jsonl_line(cls, line: str) -> LogRecord:
        """Parse one JSONL line. Trailing newlines tolerated."""
        return cls.from_dict(json.loads(line))

    @classmethod
    def from_python(
        cls,
        record: logging.LogRecord,
        *,
        app: str = "",
        app_version: str = "",
        env: str | None = None,
    ) -> LogRecord:
        """Build a canonical ``LogRecord`` from a stdlib
        :class:`logging.LogRecord`. Caller-supplied ``extra=`` fields are
        harvested into :attr:`fields`; ``exc_info`` is formatted into
        :attr:`exception` / :attr:`stack`."""
        extras = {
            k: v
            for k, v in record.__dict__.items()
            if k not in _STDLIB_LOGRECORD_ATTRS and not k.startswith("_")
        }
        # OTel/request correlation may be injected onto the record by a
        # filter; pull them out of extras into the typed slots.
        trace_id = _pop_str(extras, "trace_id")
        span_id = _pop_str(extras, "span_id")
        request_id = _pop_str(extras, "request_id")

        exception = stack = None
        if record.exc_info:
            exception = "".join(traceback.format_exception(*record.exc_info)).rstrip()
        if record.stack_info:
            stack = str(record.stack_info)

        return cls(
            ts=_iso_utc(record.created),
            level=record.levelname,
            level_no=record.levelno,
            logger=record.name,
            msg=record.getMessage(),
            module=record.module,
            func=record.funcName,
            line=record.lineno,
            thread=record.threadName or "",
            process=record.process or 0,
            host=socket.gethostname(),
            app=app,
            app_version=app_version,
            lang="python",
            env=env,
            trace_id=trace_id,
            span_id=span_id,
            request_id=request_id,
            exception=exception,
            stack=stack,
            fields=extras,
        )


def _opt_str(value: object) -> str | None:
    if value is None:
        return None
    return str(value)


def _as_int(value: object, default: int = 0) -> int:
    if isinstance(value, bool):  # bool is an int subclass — treat as default
        return default
    if isinstance(value, (int, float, str)):
        try:
            return int(value)
        except (TypeError, ValueError):
            return default
    return default


def _as_dict(value: object) -> dict[str, Any]:
    if isinstance(value, dict):
        return {str(k): v for k, v in value.items()}
    return {}


def _pop_str(d: dict[str, Any], key: str) -> str | None:
    if key in d:
        v = d.pop(key)
        return None if v is None else str(v)
    return None


__all__ = ["LOG_SCHEMA_VERSION", "LogRecord"]
