"""Per-run logging for aa_auto_sdr.

This is the only module in src/ that imports logging.handlers or instantiates
handlers. Other modules call ``logging.getLogger(__name__)`` and trust that
``setup_logging(namespace)`` has been invoked once from ``cli/main.run()``.

Fast-path entries in ``__main__.py`` (--version, --help, --exit-codes,
--explain-exit-code, --completion) deliberately do NOT initialize logging --
they exit in milliseconds and writing a per-run file would be noise.
"""

from __future__ import annotations

import argparse
import atexit
import json
import logging
import os
import re
import sys
import uuid
from datetime import UTC, datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any

from aa_auto_sdr.core.version import __version__

LOG_FILE_MAX_BYTES = 10 * 1024 * 1024  # 10 MB
LOG_FILE_BACKUP_COUNT = 5
VALID_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
RUN_ID = uuid.uuid4().hex[:12]

_atexit_registered = False


_REDACTION_PATTERNS = (
    # Character class covers URL-safe base64 (`-`, `_`) and standard base64 (`+`, `/`, `=`).
    (re.compile(r"Bearer\s+[A-Za-z0-9._\-+/=]+", re.IGNORECASE), "Bearer [REDACTED]"),
    (re.compile(r"Authorization:\s*[^\r\n]+", re.IGNORECASE), "Authorization: [REDACTED]"),
    (re.compile(r"client_secret=[A-Za-z0-9._\-]+", re.IGNORECASE), "client_secret=[REDACTED]"),
    (re.compile(r"access_token=[A-Za-z0-9._\-]+", re.IGNORECASE), "access_token=[REDACTED]"),
)
_SENSITIVE_FIELDS = frozenset({"secret", "client_secret", "access_token", "authorization", "bearer"})
_REDACTION_SENTINEL = "[log-redaction-error]"


def _redact_text(text: str) -> str:
    try:
        for pattern, replacement in _REDACTION_PATTERNS:
            text = re.sub(pattern, replacement, text)
        return text
    except Exception:
        return _REDACTION_SENTINEL


# Public alias for boundary callers (e.g. watch event emitter) that need to
# scrub secrets from strings they emit on stdout. The private name is kept
# for backward compatibility with existing in-module call sites.
redact_text = _redact_text


class SensitiveDataFilter(logging.Filter):
    """Strip sensitive credentials from records before they reach handlers.

    Coverage:

    - **Message + args**: renders ``record.msg % record.args`` first then
      redacts the result, so credentials that live in an arg with surrounding
      context (e.g. ``Bearer``, ``Authorization:``) in the format string are
      caught — a shape that defeats per-component redaction. After redaction,
      ``record.msg`` holds the redacted rendered text and ``record.args`` is
      cleared so downstream formatters see the finalized message.
    - **Non-str msg**: any non-str ``record.msg`` (e.g. an exception object
      passed to ``logger.error(exc)``) is coerced to ``str`` and redacted.
    - **exc_info traceback**: when ``logger.exception(...)`` or
      ``exc_info=True`` populates ``record.exc_info``, the traceback is
      pre-formatted, redacted, and stuffed into ``record.exc_text`` so
      downstream formatters use the redacted copy. ``exc_info`` is cleared
      to prevent re-formatting.
    - **stack_info**: redacted in place when present.
    - **Sensitive attrs**: any record attribute whose name matches a
      known-sensitive field (``secret``, ``client_secret``, etc.) is replaced
      with ``[REDACTED]``.

    A regex failure surfaces as the sentinel ``[log-redaction-error]`` rather
    than leaking the raw value.

    **Single-pass marker**: the same ``LogRecord`` instance is filtered once
    per handler (e.g. console + file, each with its own ``SensitiveDataFilter``
    instance) since filters run per-handler but records are shared across a
    logger's handlers. The first pass sets ``record._redacted = True``; later
    passes short-circuit immediately instead of re-redacting already-redacted
    (idempotent but wasted) text. The leading underscore keeps the marker out
    of ``JSONFormatter`` output (reserved/underscore-prefixed keys are
    excluded from the extras payload).
    """

    def filter(self, record: logging.LogRecord) -> bool:
        if getattr(record, "_redacted", False):
            return True
        if isinstance(record.msg, str):
            try:
                rendered = record.getMessage()
            except (TypeError, ValueError):  # fmt: skip
                # %-format mismatch (rare; usually a malformed call site).
                # Falling back to raw msg is safer than dropping the record.
                rendered = record.msg
            record.msg = _redact_text(rendered)
            record.args = None
        else:
            # Non-str msg (e.g. exception object). Coerce + redact so the
            # record's str() doesn't surface a credential at format time.
            record.msg = _redact_text(str(record.msg))
            record.args = None
        if record.exc_info:
            # Pre-format the traceback so we can redact it; downstream
            # formatters use record.exc_text when set instead of re-formatting
            # exc_info. Clearing exc_info prevents the re-format leak.
            try:
                exc_text = record.exc_text or logging.Formatter().formatException(record.exc_info)
            except Exception:
                exc_text = _REDACTION_SENTINEL
            record.exc_text = _redact_text(exc_text)
            record.exc_info = None
        if record.stack_info:
            record.stack_info = _redact_text(record.stack_info)
        for attr in list(vars(record)):
            if attr in _SENSITIVE_FIELDS or attr.lower() in _SENSITIVE_FIELDS:
                setattr(record, attr, "[REDACTED]")
        record._redacted = True
        return True


class WorkerIdFilter(logging.Filter):
    """Inject worker_id from threading.local onto every log record.

    Filters run on the calling thread BEFORE handlers serialize records.
    Reading thread-local state at filter-time captures the worker_id of
    the thread that made the log call. Sequential mode (no worker pool
    active) leaves _worker_local.worker_id as None; the filter omits the
    field — preserves byte-equivalence with v1.7.2 logs.

    See spec §3.2.
    """

    def filter(self, record: logging.LogRecord) -> bool:
        # If pipeline.workers was never imported, no worker pool can possibly be
        # active — skip the import entirely. This keeps the heavy stack
        # (workers.py imports api.client → aanalytics2 and sdr.builder → pandas)
        # off the light-command runtime path; the first log record must not
        # drag it in. Once the batch path imports workers.py, this resolves
        # real worker ids exactly as before.
        if "aa_auto_sdr.pipeline.workers" not in sys.modules:
            return True
        # Lazy import to avoid circular: pipeline.workers imports core.logging indirectly.
        from aa_auto_sdr.pipeline.workers import get_current_worker_id

        wid = get_current_worker_id()
        if wid is not None:
            record.worker_id = wid
        return True


_RESERVED_LOGRECORD_FIELDS = frozenset(logging.makeLogRecord({}).__dict__.keys()) | {"message", "asctime"}


class JSONFormatter(logging.Formatter):
    """NDJSON formatter: one JSON object per record, terminated with newline.

    Schema:
        {timestamp, level, logger, message, run_id, run_mode, tool_version, ...extra}
    Reserved LogRecord fields (pathname, lineno, etc.) are excluded.
    """

    def __init__(self, *, run_mode: str) -> None:
        super().__init__()
        self._run_mode = run_mode

    def format(self, record: logging.LogRecord) -> str:
        payload: dict[str, Any] = {
            "timestamp": datetime.now(UTC).isoformat(),
            "level": record.levelname,
            "logger": record.name,
            "message": record.getMessage(),
            "run_id": RUN_ID,
            "run_mode": self._run_mode,
            "tool_version": __version__,
        }
        # Framework keys (timestamp/level/logger/message/run_id/run_mode/
        # tool_version) are owned by the formatter; an `extra={}` from a
        # call site cannot override them. Two existing call sites pass
        # redundant `tool_version` / `run_mode` keys for the vocabulary
        # meta-test (which keys on message+extras pairs); their values
        # always equal the framework values, so the guard is a no-op
        # today. The guard exists to prevent a future call site from
        # silently overwriting these fields.
        framework_keys = frozenset(payload)
        for key, value in record.__dict__.items():
            if key in _RESERVED_LOGRECORD_FIELDS:
                continue
            if key.startswith("_"):
                continue
            if key in framework_keys:
                continue
            payload[key] = value
        # v1.5 carry-over #1: surface exc_text in JSON output. v1.4's
        # SensitiveDataFilter pre-formats and redacts tracebacks into
        # record.exc_text; v1.4 excluded it via _RESERVED_LOGRECORD_FIELDS,
        # leaving JSON consumers without traceback parity. Surface it here
        # (already redacted, safe to emit).
        if record.exc_text:
            payload["exc_text"] = record.exc_text
        try:
            return json.dumps(payload, default=str)
        except (TypeError, ValueError):  # fmt: skip
            return json.dumps(
                {
                    "timestamp": payload["timestamp"],
                    "level": payload["level"],
                    "logger": payload["logger"],
                    "message": "[json-format-error]",
                    "run_id": RUN_ID,
                    "run_mode": self._run_mode,
                    "tool_version": __version__,
                }
            )


def infer_run_mode(ns: argparse.Namespace) -> str:
    """Map a parsed argparse Namespace to a run-mode string.

    Returns one of: 'diff', 'batch', 'discovery', 'inspect', 'snapshot',
    'profile', 'config', 'stats', 'interactive', 'single', 'other'.
    """
    if getattr(ns, "diff", None):
        return "diff"
    if getattr(ns, "batch", None) or len(getattr(ns, "rsids", []) or []) > 1:
        return "batch"
    if getattr(ns, "list_reportsuites", False) or getattr(ns, "list_virtual_reportsuites", False):
        return "discovery"
    if (
        getattr(ns, "describe_reportsuite", None)
        or getattr(ns, "list_metrics", None)
        or getattr(ns, "list_dimensions", None)
        or getattr(ns, "list_segments", None)
        or getattr(ns, "list_calculated_metrics", None)
        or getattr(ns, "list_classification_datasets", None)
    ):
        return "inspect"
    if getattr(ns, "list_snapshots", False) or getattr(ns, "prune_snapshots", False):
        return "snapshot"
    if (
        getattr(ns, "profile_add", None)
        or getattr(ns, "profile_test", None)
        or getattr(ns, "profile_show", None)
        or getattr(ns, "profile_list", False)
        or getattr(ns, "profile_import", None)
    ):
        return "profile"
    if (
        getattr(ns, "show_config", False)
        or getattr(ns, "config_status", False)
        or getattr(ns, "validate_config", False)
        or getattr(ns, "sample_config", False)
    ):
        return "config"
    if getattr(ns, "stats", False):
        return "stats"
    if getattr(ns, "interactive", False):
        return "interactive"
    if getattr(ns, "rsids", []):
        return "single"
    return "other"


def _log_filename(run_mode: str, ns: argparse.Namespace, ts: str) -> str:
    """Map run mode + namespace to log filename (no path prefix)."""
    if run_mode == "single":
        rsids = getattr(ns, "rsids", []) or []
        if rsids:
            return f"SDR_Generation_{rsids[0]}_{ts}.log"
    if run_mode == "batch":
        return f"SDR_Batch_Generation_{ts}.log"
    if run_mode == "diff":
        return f"SDR_Diff_{ts}.log"
    return f"SDR_Run_{ts}.log"


def setup_logging(
    namespace: argparse.Namespace,
    *,
    log_dir: Path = Path("logs"),
) -> logging.Logger:
    """Configure root logger from a parsed argparse Namespace.

    Idempotent: a second call replaces handlers cleanly. Returns the package
    logger (``aa_auto_sdr``).

    Wires a stderr console handler plus a best-effort ``RotatingFileHandler``
    whose filename is run-mode-aware. If the log directory cannot be created
    (PermissionError / OSError) the file handler is skipped and a warning is
    emitted on stderr — the run continues with console-only logging.

    Closes and removes any preexisting handlers on the root logger before
    wiring its own. Embedders that pre-configure logging on the root logger
    will lose those handlers — attach to a named logger instead.
    """
    global _atexit_registered  # noqa: PLW0603 — module-level guard for atexit registration

    log_level = (getattr(namespace, "log_level", None) or os.environ.get("LOG_LEVEL", "INFO")).upper()
    if log_level not in VALID_LEVELS:
        print(f"Warning: invalid log level '{log_level}', using INFO", file=sys.stderr)  # noqa: T201
        log_level = "INFO"
    numeric_level = getattr(logging, log_level)

    for handler in logging.root.handlers[:]:
        handler.close()
        logging.root.removeHandler(handler)

    run_mode = infer_run_mode(namespace)
    log_format = getattr(namespace, "log_format", "text")
    if log_format == "json":
        formatter: logging.Formatter = JSONFormatter(run_mode=run_mode)
    else:
        formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")

    # Best-effort log directory creation.
    log_file: Path | None = None
    try:
        log_dir.mkdir(parents=True, exist_ok=True)
        ts = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
        log_file = log_dir / _log_filename(run_mode, namespace, ts)
    except (PermissionError, OSError) as exc:
        print(  # noqa: T201
            f"Warning: Cannot create logs directory: {exc}. Logging to console only.",
            file=sys.stderr,
        )

    # Console handler — stderr, not stdout.
    quiet = bool(getattr(namespace, "quiet", False))
    console = logging.StreamHandler(sys.stderr)
    console.setFormatter(formatter)
    console.setLevel(logging.WARNING if quiet else numeric_level)
    console.addFilter(SensitiveDataFilter())
    console.addFilter(WorkerIdFilter())
    logging.root.addHandler(console)

    # File handler — best-effort.
    if log_file is not None:
        fh = RotatingFileHandler(log_file, maxBytes=LOG_FILE_MAX_BYTES, backupCount=LOG_FILE_BACKUP_COUNT)
        fh.setFormatter(formatter)
        fh.setLevel(numeric_level)
        fh.addFilter(SensitiveDataFilter())
        fh.addFilter(WorkerIdFilter())
        logging.root.addHandler(fh)

    logging.root.setLevel(numeric_level)

    if not _atexit_registered:
        atexit.register(logging.shutdown)
        _atexit_registered = True

    logger = logging.getLogger("aa_auto_sdr")
    # NOTSET on the package logger means "inherit effective level from root" —
    # so a single root.setLevel() call above gates filtering for every child
    # logger created via logging.getLogger("aa_auto_sdr.<module>").
    logger.setLevel(logging.NOTSET)

    # Startup banner — five INFO records, always emitted (file handler always
    # captures them; console may suppress under --quiet).
    if log_file is not None:
        logger.info("Logging initialized. Log file: %s", log_file)
    else:
        logger.info("Logging initialized. Console output only.")
    logger.info("aa_auto_sdr version: %s", __version__)
    logger.info("Python %s on %s", sys.version.split()[0], sys.platform)
    logger.info("Dependencies: %s", _dep_summary())
    logger.info(
        "Run mode: %s | log level: %s | log format: %s",
        run_mode,
        log_level,
        log_format,
    )
    for handler in logging.root.handlers:
        handler.flush()

    return logger


def _dep_summary() -> str:
    """Comma-separated 'pkg=version' string for the three runtime deps that
    matter for triage. Missing packages report '?'."""
    from importlib.metadata import PackageNotFoundError, version

    pkgs = ("aanalytics2", "pandas", "xlsxwriter")
    parts: list[str] = []
    for p in pkgs:
        try:
            parts.append(f"{p}={version(p)}")
        except PackageNotFoundError:
            parts.append(f"{p}=?")
    return ", ".join(parts)
