"""Single-point logging configuration.

:func:`mgf.common.bootstrap` wires this subsystem once per entry
point (CLI ``main``, GUI ``app.run``, scheduler service entry, mobile
background task entry); the internal :func:`_wire_logging` in this
module is its delegate. Calling bootstrap a second time is harmless
— :func:`logging.basicConfig` is invoked with ``force=True`` so any
prior handlers are replaced.

Sinks installed by default:

  - **Console (stderr)** — text on TTY, JSON when not a TTY (rule LG-02).
  - **Rotating file** under ``<state>/logs/<app>.log`` (rule LG-06).

Optional sinks (opt-in via env var, no-op when unset):

  - **Journald** (Linux) — auto-detected via ``JOURNAL_STREAM`` /
    ``INVOCATION_ID`` env vars set by systemd. Requires
    ``python3-systemd`` to be importable.
  - **Syslog** — set ``<APP>_SYSLOG_ADDR=host:port``.
  - **OTLP logs** — set ``OTEL_EXPORTER_OTLP_ENDPOINT`` and install
    the ``observability`` extra.
  - **HTTP webhook** — set ``<APP>_LOG_HTTP_SINK=https://...``.

The ``<APP>`` prefix on env-var names and the rotating file's
location both come from :func:`mgf.common.app_name` — so each
consumer project gets its own namespace without colliding with others.

Every record carries the OpenTelemetry ``trace_id`` and ``span_id``
when a span is active (rule LG-03). Sensitive fields are redacted
(rule LG-04).

See ``docs/standards/LOGGING.md`` §5 for the design.
"""

from __future__ import annotations

import logging
import os
import sys
from collections.abc import Callable
from logging.handlers import RotatingFileHandler
from typing import TYPE_CHECKING, Literal

from mgf.common._identity import app_name
from mgf.common.observability.json_formatter import JsonFormatter
from mgf.common.observability.paths import default_log_path
from mgf.common.observability.redaction import Redactor
from mgf.common.observability.text_formatter import TextFormatter

if TYPE_CHECKING:
    from pathlib import Path

    from mgf.common.settings import MgfSettings

LogFormat = Literal["text", "json", "auto"]

# Module-level logger for diagnostics from inside setup itself
# (e.g., "OTel handler requested but SDK is not installed"). MUST
# only be used after at least one handler is attached, otherwise
# the message lands on the implicit lastResort handler at WARNING.
_LOG = logging.getLogger(__name__)


def _wire_logging(
    *,
    level: str = "INFO",
    fmt: LogFormat = "auto",
    log_file: Path | None = None,
    redactor: Redactor | None = None,
) -> None:
    """Configure the root logger and standard handlers (rule LG-02).

    Internal — called by :func:`mgf.common.bootstrap`. Consumers
    MUST use :func:`mgf.common.bootstrap` instead of this private
    helper (the public ``setup_logging`` shim was removed pre-v1.0).

    Parameters
    ----------
    level
        Root level. One of ``DEBUG`` / ``INFO`` / ``WARNING`` /
        ``ERROR`` / ``CRITICAL``. Case-insensitive.
    fmt
        ``"text"`` for human-readable, ``"json"`` for structured,
        ``"auto"`` to pick based on ``stderr.isatty()``. The rotating
        file sink ALWAYS uses JSON regardless of this setting — file
        consumers want structure.
    log_file
        Override the rotating-file destination. Default:
        ``<state>/logs/<app>.log`` where ``<state>`` honours
        ``XDG_STATE_HOME`` or falls back to ``~/.local/state`` and
        ``<app>`` comes from :func:`mgf.common.app_name`.
    redactor
        Custom redaction policy. Default: :meth:`Redactor.default`.
    """
    redactor = redactor or Redactor.default()
    chosen_fmt = _resolve_format(fmt)

    handlers: list[logging.Handler] = []

    # 1) Console handler — always present.
    console = logging.StreamHandler(stream=sys.stderr)
    console.setFormatter(_make_formatter(chosen_fmt, redactor))
    handlers.append(console)

    # 2) Rotating file handler — always present, always JSON. Failure
    # to open the file is logged via the console handler we just
    # attached and otherwise ignored — a read-only filesystem must not
    # take down the application.
    file_path = log_file if log_file is not None else default_log_path()
    file_handler = _try_make_file_handler(file_path, redactor)
    if file_handler is not None:
        handlers.append(file_handler)

    # 3) Optional sinks — opt-in via env.
    _maybe_add_journald(handlers, redactor)
    _maybe_add_syslog(handlers, redactor)
    _maybe_add_otlp(handlers, redactor)
    _maybe_add_http(handlers, redactor)

    logging.basicConfig(
        level=getattr(logging, level.upper(), logging.INFO),
        handlers=handlers,
        # ``force=True`` clears any handlers installed earlier (e.g.,
        # by an earlier ``setup_logging`` call, by pytest fixtures, or
        # by the bare ``basicConfig`` we used to call before this module
        # existed). This is what makes :func:`setup_logging` idempotent.
        force=True,
    )

    # Quiet down noisy third-party loggers — defaults are arbitrary
    # but match what we ship today.
    for noisy in ("urllib3", "asyncio", "PIL", "matplotlib"):
        logging.getLogger(noisy).setLevel(logging.WARNING)


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _resolve_format(fmt: LogFormat) -> Literal["text", "json"]:
    """Auto-resolve ``"auto"`` based on whether stderr is a TTY."""
    if fmt == "auto":
        return "text" if sys.stderr.isatty() else "json"
    return fmt


def _make_formatter(fmt: Literal["text", "json"], redactor: Redactor) -> logging.Formatter:
    if fmt == "json":
        return JsonFormatter(redactor=redactor)
    return TextFormatter(redactor=redactor)


def _default_log_path() -> Path:
    """Deprecated alias for :func:`mgf.common.observability.default_log_path`.

    Removed in a future release. Closed-box leak: consumers that
    imported this private helper (it had no public alternative
    before v0.13) should migrate to the public name.
    """
    import warnings

    from mgf.common._warnings import MgfDeprecationWarning

    warnings.warn(
        "mgf.common.observability.logging_setup._default_log_path is "
        "deprecated since v0.13.0; import "
        "mgf.common.observability.default_log_path instead.",
        MgfDeprecationWarning,
        stacklevel=2,
    )
    return default_log_path()


def _try_make_file_handler(path: Path, redactor: Redactor) -> RotatingFileHandler | None:
    """Best-effort: build the rotating file handler, swallow IO errors.

    A read-only filesystem or a sandbox that denies the directory must
    not take down the application — the console handler stays in place
    and operators see the warning.
    """
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        handler = RotatingFileHandler(
            filename=str(path),
            maxBytes=10 * 1024 * 1024,  # 10 MiB
            backupCount=5,
            encoding="utf-8",
        )
    except OSError as e:
        # We can't use the module logger reliably here (it might not be
        # attached yet during the very first ``setup_logging`` call), so
        # we route through the bare root logger which uses Python's
        # ``lastResort`` handler (stderr WARNING+) until our handlers
        # come up.
        logging.getLogger().warning(
            "could not open log file %s: %s; continuing with console only",
            path,
            e,
        )
        return None
    handler.setFormatter(JsonFormatter(redactor=redactor))
    return handler


def _maybe_add_journald(handlers: list[logging.Handler], redactor: Redactor) -> None:
    """Add :class:`systemd.journal.JournalHandler` when running under systemd.

    Auto-detected via ``JOURNAL_STREAM`` (always set by systemd ≥231)
    and ``INVOCATION_ID`` (set by systemd ≥232). When neither is set,
    we are not running under systemd.
    """
    if "JOURNAL_STREAM" not in os.environ and "INVOCATION_ID" not in os.environ:
        return
    try:
        from systemd.journal import JournalHandler
    except ImportError:
        return
    handler = JournalHandler(SYSLOG_IDENTIFIER=app_name())
    handler.setFormatter(JsonFormatter(redactor=redactor))
    handlers.append(handler)


def _maybe_add_syslog(handlers: list[logging.Handler], redactor: Redactor) -> None:
    addr = os.environ.get(f"{app_name().upper()}_SYSLOG_ADDR")
    if not addr:
        return
    from logging.handlers import SysLogHandler

    host, _, port = addr.partition(":")
    handler = SysLogHandler(address=(host, int(port) if port else 514))
    handler.setFormatter(JsonFormatter(redactor=redactor))
    handlers.append(handler)


def _maybe_add_otlp(handlers: list[logging.Handler], redactor: Redactor) -> None:
    """Add the OTLP log exporter handler when an OTLP endpoint is set.

    Lazy import — opentelemetry deps are optional. When the SDK is not
    installed we log a warning via the console handler (which is already
    attached at this point in :func:`setup_logging`) and continue.
    """
    if not os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"):
        return
    try:
        from mgf.common.observability.otel_setup import build_otlp_log_handler
    except ImportError:
        return
    handler = build_otlp_log_handler()
    if handler is None:
        # otel_setup logged the missing dep already; just skip.
        return
    handler.setFormatter(JsonFormatter(redactor=redactor))
    handlers.append(handler)


def _maybe_add_http(handlers: list[logging.Handler], redactor: Redactor) -> None:
    name_upper = app_name().upper()
    url = os.environ.get(f"{name_upper}_LOG_HTTP_SINK")
    if not url:
        return
    from logging.handlers import HTTPHandler
    from urllib.parse import urlparse

    parsed = urlparse(url)
    if not parsed.netloc:
        _LOG.warning(
            "log HTTP sink env var is not a valid URL; ignoring",
            extra={
                "audit": True,
                "event": "logging.http_sink.invalid_url",
                "env_var": f"{name_upper}_LOG_HTTP_SINK",
                "hint": f"set {name_upper}_LOG_HTTP_SINK to a valid http(s)://<host>/<path> URL (or unset to disable)",
            },
        )
        return
    handler = HTTPHandler(
        host=parsed.netloc,
        url=parsed.path or "/",
        method="POST",
        secure=parsed.scheme == "https",
    )
    handler.setFormatter(JsonFormatter(redactor=redactor))
    handlers.append(handler)


# ---------------------------------------------------------------------------
# Log-handler registry (Phase 3 — registry shipped; full wiring Phase 5+)
# ---------------------------------------------------------------------------
#
# Consumers register a NAMED handler factory; the active wiring
# instantiates each one when its name appears in
# ``MgfSettings.logging.extra_handlers`` (a future field — Phase 5
# threads it through ``_wire_logging``).
#
# Phase 3 ships the registry + decorator + tests so consumer
# plug-in code can register at import time. The wiring step that
# instantiates them lands when consumers actually need it.

LogHandlerFactory = Callable[["MgfSettings"], "logging.Handler"]
"""Factory that returns a configured :class:`logging.Handler` given
the active :class:`mgf.common.settings.MgfSettings`."""

_LOG_HANDLERS: dict[str, LogHandlerFactory] = {}


def register_log_handler(
    name: str,
) -> Callable[[LogHandlerFactory], LogHandlerFactory]:
    """Decorator: register a custom log-handler factory.

    Closes the closed-box leak around custom handlers (consumers
    no longer subclass :class:`logging.Handler` and monkey-patch
    ``setup_logging`` to attach it).

    Example::

        from mgf.common.observability import register_log_handler

        @register_log_handler("syslog-rfc5424")
        def _make_handler(settings):
            return MyRFC5424Handler(...)

    Activated by listing the name in
    ``MgfSettings.logging.extra_handlers`` (wiring lands in a
    later phase). Phase 3 ships the registry so consumers can
    register at import time without source-compat impact.

    Threading: register at module-import / bootstrap time.
    """

    def decorator(fn: LogHandlerFactory) -> LogHandlerFactory:
        if name in _LOG_HANDLERS:
            raise ValueError(
                f"log handler {name!r} already registered "
                f"(unregister first if you intend to replace it)"
            )
        _LOG_HANDLERS[name] = fn
        return fn

    return decorator


def unregister_log_handler(name: str) -> None:
    """Remove a registered log handler. No-op if unknown."""
    _LOG_HANDLERS.pop(name, None)


def _registered_log_handlers() -> dict[str, LogHandlerFactory]:
    """Internal: snapshot of the registry."""
    return dict(_LOG_HANDLERS)


__all__ = [
    "LogFormat",
    "LogHandlerFactory",
    "register_log_handler",
    "unregister_log_handler",
]
