"""
Operator-tunable settings + startup validators.

Settings are read once at module import via ``clean_setting`` and
exposed as module-level constants. Operators set values in their
Auth ``local.py``. Validators run from ``apps.ready()`` and raise
``ImproperlyConfigured`` on misconfiguration that would silently
defeat a documented safety guarantee.
"""

from __future__ import annotations

from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Final, cast
from urllib.parse import urlsplit

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured

from ._log import get_logger
from .constants import DISCORD_SERVICE_APP_LABEL, MIDDLEWARE_PATH
from .permissions import APP_LABEL
from .utils import clean_setting

logger = get_logger(__name__)

# --- Settings ---

AA_DISCORD_AUDIT_POLICY: Final[Mapping[str, object]] = clean_setting(
    "AA_DISCORD_AUDIT_POLICY",
    {"unknown_guest": "report", "linked_no_perm": "report"},
    required_type=dict,
)

AA_DISCORD_AUDIT_NOTIFY_ADMINS: Final[bool] = clean_setting(
    "AA_DISCORD_AUDIT_NOTIFY_ADMINS", True
)

AA_DISCORD_AUDIT_WEBHOOK_URL: Final[str | None] = clean_setting(
    "AA_DISCORD_AUDIT_WEBHOOK_URL", None, required_type=str
)

AA_DISCORD_AUDIT_BOT_UIDS: Final[list[int]] = clean_setting(
    "AA_DISCORD_AUDIT_BOT_UIDS", [], required_type=list
)

AA_DISCORD_AUDIT_AUTO_DISCOVER_BY_NICKNAME: Final[bool] = clean_setting(
    "AA_DISCORD_AUDIT_AUTO_DISCOVER_BY_NICKNAME", False
)

# clean_setting does not preserve operator-set ``None`` against a
# non-None default, so retention is a plain int. ``0`` is the
# indefinite-retention sentinel (see ``_validate_retention_opt_out``).
AA_DISCORD_AUDIT_RUN_RETENTION_DAYS: Final[int] = clean_setting(
    "AA_DISCORD_AUDIT_RUN_RETENTION_DAYS", 180, min_value=0
)

AA_DISCORD_AUDIT_RETENTION_OPT_OUT_ACKNOWLEDGED: Final[bool] = clean_setting(
    "AA_DISCORD_AUDIT_RETENTION_OPT_OUT_ACKNOWLEDGED", False
)

# Idempotency-key TTL applied independently of the run-retention window.
# ``AuditRun.idempotency_key`` is ``unique=True`` so a caller-supplied
# dedup token (CLI retry-on-error, webhook redelivery) is permanently
# bound to the original run for the full retention window. Operators who
# want to reuse the same key after a shorter cooldown — e.g. a daily
# Beat-triggered run keyed by the date — set this to a positive day
# count; the **management command** ``python manage.py prune_audit_runs``
# (NOT the Celery beat task of the same name — that one only deletes
# old rows) then NULL-ifies the key on runs older than the cutoff
# during its Phase 1, while the run row itself stays for
# ``RUN_RETENTION_DAYS``. Operators relying on automatic key cleanup
# must therefore wire the management command into cron / a separate
# beat schedule. ``0`` (default) disables key expiry entirely so the
# existing "key dies with the row" behaviour is preserved for every
# install unaffected by this knob.
AA_DISCORD_AUDIT_IDEMPOTENCY_KEY_TTL_DAYS: Final[int] = clean_setting(
    "AA_DISCORD_AUDIT_IDEMPOTENCY_KEY_TTL_DAYS", 0, min_value=0
)

AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES: Final[int] = clean_setting(
    "AA_DISCORD_AUDIT_RUN_DEADLINE_MINUTES", 60, min_value=1
)

AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY: Final[int] = clean_setting(
    "AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY", 5, min_value=0
)

AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED: Final[bool] = clean_setting(
    "AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED", False
)

# Opt-in for destructive (strip / kick) actions on the unattended Celery
# beat. Default ``False``: the periodic audit reports but never mutates
# Discord unless the operator explicitly arms it. The first-run lock is a
# one-shot human gate — releasing it for a single manual CLI run would
# otherwise arm the beat permanently — so the beat destructive path has
# its own dedicated flag, enforced by the engine's
# ``coerce_beat_destructive`` gate and surfaced as a startup warning by
# ``_validate_beat_destructive_policy``.
AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE: Final[bool] = clean_setting(
    "AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE", False
)

# Coarse global floor between beat audit runs, in minutes. ``0`` (the
# default) disables the floor: the operator's Celery beat schedule is
# the primary cadence control. When set, a beat tick that fires within
# this many minutes of the previous accepted beat run is skipped — a
# backstop against a misconfigured fast schedule repeatedly sweeping the
# guild. ``QueueOnce`` only dedupes CONCURRENT ticks; this bounds
# back-to-back ones.
AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES: Final[int] = clean_setting(
    "AA_DISCORD_AUDIT_BEAT_MIN_INTERVAL_MINUTES", 0, min_value=0
)

# Webhook delivery: per-attempt request timeout in seconds. Tuned
# higher than the default ``requests`` timeout because Discord's
# global rate limiter occasionally pauses the response stream for a
# few seconds; lower than the per-finding deadline so the embed POST
# does not eat the apply_plan budget.
AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT: Final[int] = clean_setting(
    "AA_DISCORD_AUDIT_WEBHOOK_TIMEOUT", 10, min_value=1
)

# Webhook delivery: number of retry attempts on transient failures
# (HTTP 429 / 502 / 503 / 504, ConnectionError, Timeout). 0 disables
# retries entirely. Each retry waits for either ``Retry-After`` (if
# the server supplied it) or a bounded exponential backoff.
AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES: Final[int] = clean_setting(
    "AA_DISCORD_AUDIT_WEBHOOK_MAX_RETRIES", 3, min_value=0
)

# Phase 2 of ``_apply_finding`` strips managed roles either via N
# DELETE requests (one per role, the default) or one PATCH request
# replacing the whole roles array. The PATCH path is faster but
# carries a TOCTOU surface: a concurrently-added role between phase
# 1 (member fetch) and phase 2 (PATCH) is silently dropped because
# the keep-list was computed against stale state. Default is False;
# operators with high-cardinality findings (members holding 5+
# managed roles) and low concurrent role-edit rate flip this to True
# to compress the per-finding HTTP cost from N to 1.
AA_DISCORD_AUDIT_USE_BULK_ROLE_STRIP: Final[bool] = clean_setting(
    "AA_DISCORD_AUDIT_USE_BULK_ROLE_STRIP", False
)


# AA_DISCORD_AUDIT_CLI_ACTOR (username string) overrides the CLI actor
# resolution: the management command has no HTTP request, so the acting
# user is normally derived from the OS login name matched to a Django
# username. Setting this to a username pins the CLI actor (the
# "service principal" pattern — set once in ``local.py``, both cron and
# manual CLI get clean ConfigChangeLog attribution). DELIBERATELY NOT a
# module-level constant here: the other ``AA_DISCORD_AUDIT_*`` knobs are
# read once at import via ``clean_setting`` and cached, which would
# defeat the per-invocation revalidation requirement (a late
# deactivation / perm-revoke must be observed on the next run). The
# live read happens inside ``resolve_cli_actor`` in
# ``management/commands/_audit/_invocation.py`` via ``getattr(settings,
# "AA_DISCORD_AUDIT_CLI_ACTOR", None)``.


# --- Validators ---


def validate_webhook_url(url: str) -> None:
    """
    Structural validator for the Discord webhook URL.

    The check is deliberately strict: scheme, host, path shape, port,
    and absence of query/fragment/userinfo are all enforced so that a
    pasted-from-browser link or a homoglyph imitation is rejected at
    boot rather than at first webhook delivery.
    """
    p = urlsplit(url)
    if p.scheme != "https":
        raise ValueError("scheme must be https")
    # IDN-homograph note: hostnames like "disc<U+03BF>rd.com" (Greek
    # omicron replacing the Latin 'o') parse as a different hostname
    # and the set membership check below rejects them naturally.
    if p.hostname not in {"discord.com", "discordapp.com"}:
        raise ValueError("host must be discord.com or discordapp.com")
    # Reject non-default ports — Discord webhooks are 443-only and any
    # custom port indicates either a misconfigured proxy or a
    # smuggled redirect target.
    if p.port not in (None, 443):
        raise ValueError("port must be 443 (or omitted)")
    if not p.path.startswith("/api/webhooks/"):
        raise ValueError("path must start with /api/webhooks/")
    # Webhook URL shape per Discord docs:
    # /api/webhooks/{webhook_id}/{webhook_token}. Reject percent-encoded
    # traversal segments (``%2e%2e``) and ensure both id and token are
    # present and non-empty before urlsplit-decoded normalisation.
    expected_segment_count = 4  # api, webhooks, {id}, {token}
    segments = [seg for seg in p.path.split("/") if seg]
    if (
        len(segments) != expected_segment_count
        or segments[:2] != ["api", "webhooks"]
        or "%" in p.path
    ):
        raise ValueError(
            "path must be exactly /api/webhooks/{id}/{token} with no "
            "percent-encoded segments"
        )
    if p.query or p.fragment:
        raise ValueError("no query string or fragment permitted")
    if p.username is not None or p.password is not None:
        raise ValueError("no userinfo permitted")


def _check_action_value(
    value: object, label: str, *, valid_actions: set[str]
) -> str:
    """Reject anything that is not a known ``OrphanAction`` enum value."""
    if not isinstance(value, str) or value not in valid_actions:
        raise ImproperlyConfigured(
            f"AA_DISCORD_AUDIT_POLICY[{label}]: unknown action "
            f"{value!r}; valid: {sorted(valid_actions)}"
        )
    return value


def _assert_action_allowed(
    category: str,
    action: str,
    slot: str,
    *,
    forbidden_actions: Mapping[str, set[str]],
) -> None:
    """Raise if ``action`` is on the category's forbidden list."""
    if action in forbidden_actions.get(category, set()):
        raise ImproperlyConfigured(
            f"AA_DISCORD_AUDIT_POLICY[{category!r}].{slot}={action!r} "
            f"is forbidden for that category; use 'strip_kick' "
            f"instead, which chains discord.tasks.delete_user."
        )


def _validate_policy_entry(
    category: str,
    value: object,
    *,
    valid_actions: set[str],
    forbidden_actions: Mapping[str, set[str]],
) -> None:
    """Validate the shape + enum membership of one policy entry."""
    # ``value: object`` because operators can write anything in
    # ``local.py``; the ``isinstance`` checks below are the actual
    # runtime contract, not a type-checker concession.
    if isinstance(value, str):
        action = _check_action_value(
            value, repr(category), valid_actions=valid_actions
        )
        _assert_action_allowed(
            category, action, "<root>", forbidden_actions=forbidden_actions
        )
        return

    if not isinstance(value, Mapping):
        raise ImproperlyConfigured(
            f"AA_DISCORD_AUDIT_POLICY[{category!r}] must be a string or "
            f"a mapping with 'default' / 'by_group' / 'by_state' "
            f"keys; got {type(value).__name__}"
        )
    if "default" not in value:
        raise ImproperlyConfigured(
            f"AA_DISCORD_AUDIT_POLICY[{category!r}] mapping is missing the "
            f"required 'default' key"
        )
    default_action = _check_action_value(
        value["default"], f"{category!r}.default", valid_actions=valid_actions
    )
    _assert_action_allowed(
        category,
        default_action,
        "default",
        forbidden_actions=forbidden_actions,
    )
    for nested_key in ("by_group", "by_state"):
        nested = value.get(nested_key)
        if nested is None:
            continue
        if not isinstance(nested, Mapping):
            raise ImproperlyConfigured(
                f"AA_DISCORD_AUDIT_POLICY[{category!r}].{nested_key} must "
                f"be a mapping; got {type(nested).__name__}"
            )
        # ``isinstance(nested, Mapping)`` narrows to
        # ``Mapping[Unknown, Unknown]`` under pyright (the generic-arg
        # pair becomes ``Never``). The cast pins the value type so the
        # iteration is well-typed.
        for inner_key, inner_value in cast(
            "Mapping[str, object]", nested
        ).items():
            inner_action = _check_action_value(
                inner_value,
                f"{category!r}.{nested_key}[{inner_key!r}]",
                valid_actions=valid_actions,
            )
            _assert_action_allowed(
                category,
                inner_action,
                f"{nested_key}[{inner_key!r}]",
                forbidden_actions=forbidden_actions,
            )


def _validate_policy() -> None:
    """Verify policy shape, enum membership, and forbidden combinations."""
    from .models import OrphanAction, OrphanCategory

    valid_categories = {c.value for c in OrphanCategory}
    valid_actions = {a.value for a in OrphanAction}
    forbidden_actions: dict[str, set[str]] = {
        # LINKED_NO_PERM x STRIP fights the regular update_groups task.
        # Forbidden everywhere on that category — default action,
        # per-group override, per-state override.
        OrphanCategory.LINKED_NO_PERM.value: {OrphanAction.STRIP.value},
    }

    for category, value in AA_DISCORD_AUDIT_POLICY.items():
        if category not in valid_categories:
            raise ImproperlyConfigured(
                f"AA_DISCORD_AUDIT_POLICY: unknown category {category!r}; "
                f"valid: {sorted(valid_categories)}"
            )
        _validate_policy_entry(
            category,
            value,
            valid_actions=valid_actions,
            forbidden_actions=forbidden_actions,
        )


def _validate_beat_destructive_policy() -> None:
    """
    Warn (not raise) when a destructive policy can run on the unattended beat.

    Unlike the other validators this is non-fatal: a destructive policy
    is legitimate for CLI / manual runs even when the operator has not
    opted the beat into destructive actions. The risk is specific to the
    unattended path, where the engine's ``coerce_beat_destructive`` gate
    is the fail-safe — this warning makes the misconfiguration visible at
    boot instead of only as a per-tick log line.
    """
    from .policy import policy_from_snapshot, policy_has_destructive

    if AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE:
        return
    policy = policy_from_snapshot(AA_DISCORD_AUDIT_POLICY)
    if policy_has_destructive(policy):
        logger.warning(
            "AA_DISCORD_AUDIT_POLICY carries a destructive action "
            "(strip/kick) but AA_DISCORD_AUDIT_BEAT_ALLOW_DESTRUCTIVE is "
            "not set. The periodic beat audit will be coerced to REPORT "
            "(no Discord mutation) until you opt in. CLI / manual runs are "
            "unaffected."
        )


def _validate_auto_discover_disabled() -> None:
    if AA_DISCORD_AUDIT_AUTO_DISCOVER_BY_NICKNAME:
        raise ImproperlyConfigured(
            "AA_DISCORD_AUDIT_AUTO_DISCOVER_BY_NICKNAME=True is reserved "
            "for v2 and is not implemented in MVP. Discord nicknames are user-"
            "controlled and would expand the identity-spoofing surface; the "
            "feature is intentionally inert until shipped behind an "
            "ESI-verified ownership signal."
        )


def _validate_webhook_or_none() -> None:
    if AA_DISCORD_AUDIT_WEBHOOK_URL is None:
        return
    try:
        validate_webhook_url(AA_DISCORD_AUDIT_WEBHOOK_URL)
    except ValueError as exc:
        raise ImproperlyConfigured(
            f"AA_DISCORD_AUDIT_WEBHOOK_URL: {exc}. Unset the variable to "
            f"disable webhook delivery entirely."
        ) from exc
    if getattr(settings, "DEBUG", False):
        raise ImproperlyConfigured(
            "AA_DISCORD_AUDIT_WEBHOOK_URL is set with DEBUG=True. "
            "Django emits unredacted tracebacks in DEBUG mode and the "
            "audit's webhook redactor is best-effort; refusing to start "
            "to prevent webhook URL leakage to operator browsers / log "
            "aggregators."
        )


@dataclass(frozen=True, slots=True)
class _SentinelRequiresAck:
    """Pair a sentinel knob with the explicit-acknowledgement flag it needs."""

    # Callable rather than raw int so the snapshot is read at validation
    # time, not at module-import time (matters under
    # ``django.test.override_settings`` re-imports).
    value: Callable[[], int]
    ack: Callable[[], bool]
    sentinel: int
    message: str

    def check(self) -> None:
        if self.value() == self.sentinel and not self.ack():
            raise ImproperlyConfigured(self.message)


_SENTINEL_VALIDATORS: tuple[_SentinelRequiresAck, ...] = (
    _SentinelRequiresAck(
        value=lambda: AA_DISCORD_AUDIT_RUN_RETENTION_DAYS,
        ack=lambda: AA_DISCORD_AUDIT_RETENTION_OPT_OUT_ACKNOWLEDGED,
        sentinel=0,
        message=(
            "AA_DISCORD_AUDIT_RUN_RETENTION_DAYS=0 retains Discord PII "
            "(uids + character names) indefinitely. Set "
            "AA_DISCORD_AUDIT_RETENTION_OPT_OUT_ACKNOWLEDGED=True to confirm "
            "this is intentional."
        ),
    ),
    _SentinelRequiresAck(
        value=lambda: AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY,
        ack=lambda: AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED,
        sentinel=0,
        message=(
            "AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY=0 disables the "
            "reconnaissance mitigation. Set "
            "AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED=True to confirm this is "
            "intentional, or pick a non-zero per-day limit."
        ),
    ),
)


def _validate_sentinels() -> None:
    """Run every sentinel-requires-ack check from ``_SENTINEL_VALIDATORS``."""
    for validator in _SENTINEL_VALIDATORS:
        validator.check()


# Thin per-knob shims preserved for legacy direct callers — the unit
# tests import each validator by name and exercise it in isolation, and
# downstream operator code may import the same names from
# ``app_settings``. Both delegate to the matching ``_SENTINEL_VALIDATORS``
# row so the message text and ack-flag stay single-sourced.
def _validate_retention_opt_out() -> None:
    """Retention-opt-out sentinel check (legacy entry point)."""
    _SENTINEL_VALIDATORS[0].check()


def _validate_rate_limit_disable() -> None:
    """Rate-limit-disable sentinel check (legacy entry point)."""
    _SENTINEL_VALIDATORS[1].check()


def _validate_bot_uids() -> None:
    """Reject non-int / non-positive elements in AA_DISCORD_AUDIT_BOT_UIDS."""
    # ``clean_setting(required_type=list)`` validates the container but
    # not its elements; the cast intentionally drops the declared
    # ``list[int]`` so we can runtime-check what the operator actually
    # put in their ``local.py``.
    raw_uids: list[object] = cast("list[object]", AA_DISCORD_AUDIT_BOT_UIDS)
    for index, value in enumerate(raw_uids):
        # ``bool`` is an ``int`` subclass; explicitly exclude it so a
        # stray ``True``/``False`` does not become a snowflake.
        if isinstance(value, bool) or not isinstance(value, int):
            raise ImproperlyConfigured(
                f"AA_DISCORD_AUDIT_BOT_UIDS[{index}]={value!r}: every element "
                f"must be a positive integer Discord snowflake."
            )
        if value <= 0:
            raise ImproperlyConfigured(
                f"AA_DISCORD_AUDIT_BOT_UIDS[{index}]={value}: snowflakes are "
                f"strictly positive."
            )


def _validate_installed_apps_order() -> None:
    """Ensure the AA Discord module is registered before this app."""
    installed_apps = list(getattr(settings, "INSTALLED_APPS", ()))
    if DISCORD_SERVICE_APP_LABEL not in installed_apps:
        raise ImproperlyConfigured(
            f"{APP_LABEL} requires {DISCORD_SERVICE_APP_LABEL} in "
            f"INSTALLED_APPS, and ensure it appears before {APP_LABEL}."
        )
    if APP_LABEL not in installed_apps:
        # apps.ready() should never fire for an unregistered app; if we
        # reach this branch the AA bootstrap is doing something weird
        # and silently skipping the order check would defeat the
        # validator's purpose.
        raise RuntimeError(
            f"{APP_LABEL} not present in INSTALLED_APPS during its own "
            f"AppConfig.ready(); AA bootstrap is in an unexpected state."
        )
    if installed_apps.index(DISCORD_SERVICE_APP_LABEL) > installed_apps.index(
        APP_LABEL
    ):
        raise ImproperlyConfigured(
            f"{DISCORD_SERVICE_APP_LABEL} must appear BEFORE {APP_LABEL} in "
            f"INSTALLED_APPS so its models are loaded first."
        )


def _validate_middleware_registered() -> None:
    """
    Ensure CurrentUserMiddleware is registered.

    Without registration the thread-local actor capture never
    populates and every ConfigChangeLog row would write '<system>'.
    """
    middleware = list(getattr(settings, "MIDDLEWARE", ()))
    if MIDDLEWARE_PATH not in middleware:
        raise ImproperlyConfigured(
            f"{APP_LABEL} requires {MIDDLEWARE_PATH!r} in MIDDLEWARE. "
            f"Without it, ConfigChangeLog cannot attribute admin / shell / "
            f"management-command edits to a real user, silently defeating "
            f"the audit-trail's actor-attribution guarantee. Add the entry "
            f"alongside the AA core middleware in your settings."
        )


def run_startup_validators() -> None:
    """
    Run every startup validator in order.

    Called from ``apps.ready()``. Each validator raises
    ``ImproperlyConfigured`` on failure; Django surfaces the
    exception at app load so misconfiguration is loud, not silent.

    Order matters: ``_validate_installed_apps_order`` runs first so the
    subsequent ``_validate_policy`` lazy-import of ``.models`` happens
    with a known-good app registry.
    """
    _validate_installed_apps_order()
    _validate_middleware_registered()
    _validate_auto_discover_disabled()
    _validate_policy()
    _validate_beat_destructive_policy()
    _validate_webhook_or_none()
    _validate_sentinels()
    _validate_bot_uids()
