"""SP18 — Structured JSON logging.

Wraps Python's stdlib ``logging`` to emit newline-delimited JSON
(or a dev-friendly tabular format) and to scrub obvious PHI
patterns (NPIs, SSNs, DOBs, patient names) from the message +
extra fields.

Design choices
--------------

* **No third-party deps.** stdlib ``logging`` + ``json`` + ``re``
  is enough. ``loguru`` / ``structlog`` were considered; both add
  a dependency for marginal gain.

* **JSON by default.** Operators running Cyclone in production
  almost certainly want logs in a format their aggregator
  (Loki/ELK/Vector) can parse. The dev format (``CycloneDevFormatter``)
  is the opt-out for ``tail -f`` in dev.

* **Conservative PII scrubber.** Redacts unambiguous PHI patterns
  only. False positives are not free — an operator's diagnostic
  dump that says ``<redacted:npi>`` instead of the actual NPI
  makes root-causing a parse failure harder. The scrubber can be
  disabled with ``CYCLONE_LOG_NO_PII_SCRUB=1`` for tests /
  forensic mode.

* **Idempotent setup.** :func:`setup_logging` can be called
  multiple times (CLI re-invocation, FastAPI lifespan re-entry
  under TestClient). Each call clears existing handlers on the
  root logger before attaching fresh ones — so the format toggle
  actually takes effect on the second call.
"""
from __future__ import annotations

import json
import logging
import os
import re
import sys
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
from typing import Any, Optional


# ---------------------------------------------------------------------------
# Formatters
# ---------------------------------------------------------------------------


# Stdlib LogRecord attributes we don't want to dump into the
# structured payload (they're noise for log consumers).
_RESERVED_LOGRECORD_ATTRS = frozenset({
    "args", "asctime", "created", "exc_info", "exc_text", "filename",
    "funcName", "levelname", "levelno", "lineno", "module", "msecs",
    "message", "msg", "name", "pathname", "process", "processName",
    "relativeCreated", "stack_info", "thread", "threadName",
    "taskName",
})


class JsonFormatter(logging.Formatter):
    """Format a LogRecord as a single JSON line.

    Fields:
        ts      — ISO 8601 UTC timestamp with milliseconds.
        level   — uppercase level name (INFO, WARNING, etc.).
        logger  — the logger name (e.g. "cyclone.scheduler").
        msg     — the formatted log message (after %-substitution).
        extra   — dict of any non-reserved LogRecord attributes.

    If ``exc_info`` is set, the formatter appends a ``traceback``
    field with the formatted exception text (NOT a serialized
    object — just the stdlib-rendered string).
    """

    def format(self, record: logging.LogRecord) -> str:
        ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
            timespec="milliseconds",
        )
        payload: dict[str, Any] = {
            "ts": ts,
            "level": record.levelname,
            "logger": record.name,
            "msg": record.getMessage(),
        }
        # Collect user-provided extras.
        extras = {
            k: v
            for k, v in record.__dict__.items()
            if k not in _RESERVED_LOGRECORD_ATTRS and not k.startswith("_")
        }
        if extras:
            payload["extra"] = extras
        if record.exc_info:
            payload["traceback"] = self.formatException(record.exc_info)
        if record.stack_info:
            payload["stack"] = self.formatStack(record.stack_info)
        return json.dumps(payload, default=str, sort_keys=True)


class CycloneDevFormatter(logging.Formatter):
    """Dev-friendly tabular format.

    Example:
        2026-06-21T15:30:00.123Z INFO  cyclone.scheduler Processed inbound foo.x12 parser=parse_999 claims=3

    Same fields as ``JsonFormatter`` but human-readable. Useful for
    ``tail -f cyclone.log`` in dev.
    """

    def format(self, record: logging.LogRecord) -> str:
        ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
            timespec="milliseconds",
        )
        extras = {
            k: v
            for k, v in record.__dict__.items()
            if k not in _RESERVED_LOGRECORD_ATTRS and not k.startswith("_")
        }
        extra_str = ""
        if extras:
            pairs = " ".join(f"{k}={v!r}" for k, v in extras.items())
            extra_str = " " + pairs
        base = f"{ts} {record.levelname:<7s} {record.name} {record.getMessage()}{extra_str}"
        if record.exc_info:
            base += "\n" + self.formatException(record.exc_info)
        return base


# ---------------------------------------------------------------------------
# PII scrubber
# ---------------------------------------------------------------------------


# Conservative PHI patterns. Each pattern is (label, compiled regex,
# replacement). Some patterns use a backreference so the field name
# (e.g. "dob=") is preserved and only the value is redacted — that
# keeps the surrounding context readable in the log line.
_PII_PATTERNS: tuple[tuple[str, "re.Pattern[str]", str], ...] = (
    # 10-digit NPI. Word-boundary anchored so we don't redact, e.g.,
    # the "10" in "10 claims processed".
    ("npi", re.compile(r"\b\d{10}\b"), "<redacted:npi>"),
    # SSN: NNN-NN-NNNN or NNNNNNNNN.
    (
        "ssn",
        re.compile(r"\b\d{3}-\d{2}-\d{4}\b|\b\d{9}\b(?=[\s,;)}])"),
        "<redacted:ssn>",
    ),
    # DOB: "dob=YYYY-MM-DD" / "date_of_birth=YYYY-MM-DD". Capture the
    # field name + separator, redact only the date — keeps the
    # surrounding sentence readable.
    (
        "dob",
        re.compile(
            r"(?i)(\b(?:dob|date[ _]?of[ _]?birth)[:=]\s*)\d{4}-\d{2}-\d{2}"
        ),
        r"\1<redacted:dob>",
    ),
    # Patient name: explicit field marker, redact the whole
    # "patient_name=..." chunk so the value can't leak in a quoted form.
    (
        "patient_name",
        re.compile(
            r'(?i)\bpatient[_ ]?name[:=]\s*"?[^\",\s}]+',
        ),
        "<redacted:patient_name>",
    ),
)

# Extra-field KEYS that we treat as PHI by themselves — if a log call
# passes an extra like ``extra={"date_of_birth": "1980-04-12"}`` we
# redact the value even though the value alone isn't PHI-shaped. The
# key is the signal. Matched case-insensitively against the full key
# (with underscores normalized to spaces for "date of birth").
_PHI_EXTRA_KEYS: dict[str, str] = {
    "npi": "npi",
    "provider_npi": "npi",
    "rendering_npi": "npi",
    "billing_npi": "npi",
    "ssn": "ssn",
    "dob": "dob",
    "date_of_birth": "dob",
    "patient_name": "patient_name",
    "patient first name": "patient_name",
    "patient last name": "patient_name",
}

# When an extra key matches one of these, redact any string value
# wholesale (don't try to parse it — just replace).
_PHI_EXTRA_WHOLE_VALUE = {"npi", "ssn", "dob", "patient_name"}


class PiiScrubber(logging.Filter):
    """Filter that redacts obvious PHI from log records.

    Walks the formatted message + every ``extra`` field value (if
    it's a string) and rewrites matches to ``<redacted:<name>``.
    Non-string extras are left alone (we don't try to serialize and
    re-scrub dicts — too risky for false positives).
    """

    def __init__(self, name: str = "pii_scrubber") -> None:
        super().__init__(name)
        self._enabled = True

    def disable(self) -> None:
        """Disable scrubbing (for tests / forensic mode)."""
        self._enabled = False

    def enable(self) -> None:
        self._enabled = True

    def _scrub(self, text: str) -> str:
        for label, pat, repl in _PII_PATTERNS:
            text = pat.sub(repl, text)
        return text

    @staticmethod
    def _normalize_extra_key(key: str) -> set[str]:
        """Return all candidate normalizations of a key.

        ``date_of_birth`` should match a lookup table that uses either
        ``date_of_birth`` or ``date of birth`` — so return both. Same
        for ``patient_name`` vs ``patient name``.
        """
        norm = key.strip().lower()
        spaced = norm.replace("_", " ")
        return {norm, spaced}

    def _redact_extra_value(self, key: str, value: Any) -> Any:
        """Redact a single extra field value if its key signals PHI."""
        for norm in self._normalize_extra_key(key):
            label = _PHI_EXTRA_KEYS.get(norm)
            if label:
                if not isinstance(value, str):
                    return value
                return f"<redacted:{label}>"
        return value

    def filter(self, record: logging.LogRecord) -> bool:
        if not self._enabled:
            return True
        # Scrub the formatted message.
        try:
            msg = record.getMessage()
            scrubbed_msg = self._scrub(msg)
            if scrubbed_msg != msg:
                record.msg = scrubbed_msg
                record.args = ()
        except Exception:  # noqa: BLE001
            pass  # never let the scrubber crash a log call
        # Scrub string extras in place. We mutate the record's
        # __dict__ directly so the formatter sees the scrubbed value.
        for k, v in list(record.__dict__.items()):
            if k in _RESERVED_LOGRECORD_ATTRS or k.startswith("_"):
                continue
            # First, key-based redaction (covers `extra={"dob": "..."}`).
            redacted = self._redact_extra_value(k, v)
            if redacted is not v:
                record.__dict__[k] = redacted
                continue
            # Second, value-pattern redaction (covers `extra={"note":
            # "patient_name=John Doe"}`).
            if isinstance(v, str):
                scrubbed = self._scrub(v)
                if scrubbed != v:
                    record.__dict__[k] = scrubbed
        return True


# Module-level singleton so tests / callers can disable it cleanly.
_scrubber = PiiScrubber()


def get_scrubber() -> PiiScrubber:
    """Return the module-level PII scrubber singleton."""
    return _scrubber


# ---------------------------------------------------------------------------
# setup_logging entry point
# ---------------------------------------------------------------------------


def _resolve_level(level: str | int | None) -> int:
    """Resolve a level string/int, falling back to INFO."""
    if level is None:
        return logging.INFO
    if isinstance(level, int):
        return level
    name = str(level).strip().upper()
    return logging.getLevelNamesMapping().get(name, logging.INFO)


def setup_logging(
    *,
    level: str | int | None = None,
    log_file: str | None = None,
    json_format: bool = True,
    scrub_pii: bool = True,
    propagate_from: str | None = None,
) -> logging.Logger:
    """Configure the root logger + attach handlers.

    Idempotent: re-calling clears existing handlers on the root
    logger before attaching fresh ones. Safe to call from
    ``click.command`` invocations and the FastAPI lifespan.

    Args:
        level: ``"DEBUG"`` / ``"INFO"`` / etc. or an int. ``None``
            means honor ``CYCLONE_LOG_LEVEL`` env var, then INFO.
        log_file: Path to a rotating log file. ``None`` means
            honor ``CYCLONE_LOG_FILE`` env var, then stderr.
        json_format: Emit JSON lines (default). ``False`` uses
            :class:`CycloneDevFormatter`.
        scrub_pii: Apply the PII scrubber (default). Honored via
            ``CYCLONE_LOG_NO_PII_SCRUB=1`` to disable.
        propagate_from: Optional logger name to attach the scrubber
            to (defaults to root).

    Returns:
        The configured root logger.
    """
    # Resolve env-var defaults.
    if level is None:
        level = os.environ.get("CYCLONE_LOG_LEVEL", "INFO")
    if log_file is None:
        log_file = os.environ.get("CYCLONE_LOG_FILE") or None
    if not json_format and os.environ.get("CYCLONE_LOG_JSON", "").lower() in (
        "false", "0", "no",
    ):
        json_format = True
    if os.environ.get("CYCLONE_LOG_NO_PII_SCRUB", "").lower() in ("1", "true", "yes"):
        scrub_pii = False

    root = logging.getLogger()
    root.setLevel(_resolve_level(level))

    # Clear existing handlers (idempotent re-setup).
    for h in list(root.handlers):
        root.removeHandler(h)
    # Also clear our scrubber so we don't add duplicates.
    target = logging.getLogger(propagate_from) if propagate_from else root
    for flt in list(target.filters):
        if isinstance(flt, PiiScrubber):
            target.removeFilter(flt)

    # Build the formatter.
    fmt: logging.Formatter
    if json_format:
        fmt = JsonFormatter()
    else:
        fmt = CycloneDevFormatter()

    # Build the handler.
    if log_file:
        handler: logging.Handler = RotatingFileHandler(
            log_file,
            maxBytes=10 * 1024 * 1024,
            backupCount=5,
            encoding="utf-8",
        )
    else:
        handler = logging.StreamHandler(stream=sys.stderr)
    handler.setFormatter(fmt)
    root.addHandler(handler)

    # Attach the scrubber.
    if scrub_pii:
        _scrubber.enable()
    else:
        _scrubber.disable()
    target.addFilter(_scrubber)

    # Quiet down noisy third-party libs.
    for noisy in ("urllib3", "paramiko", "sqlalchemy.engine"):
        logging.getLogger(noisy).setLevel(max(root.level, logging.WARNING))

    return root
