"""
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 Mapping
from typing import Final, cast
from urllib.parse import urlsplit

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

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

# --- 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
)

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
)

# 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
)


# --- 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 _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}

    def _validate_action(action_value: object, label: str) -> str:
        if (
            not isinstance(action_value, str)
            or action_value not in valid_actions
        ):
            raise ImproperlyConfigured(
                f"AA_DISCORD_AUDIT_POLICY[{label}]: unknown action "
                f"{action_value!r}; valid: {sorted(valid_actions)}"
            )
        return action_value

    def _check_forbidden(
        category: str, action: str, slot: str, forbidden: set[str]
    ) -> None:
        """Raise if ``action`` is forbidden for ``category`` at ``slot``."""
        if action in forbidden:
            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."
            )

    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 key, value in AA_DISCORD_AUDIT_POLICY.items():
        if key not in valid_categories:
            raise ImproperlyConfigured(
                f"AA_DISCORD_AUDIT_POLICY: unknown category {key!r}; "
                f"valid: {sorted(valid_categories)}"
            )
        forbidden = forbidden_actions.get(key, set())

        # ``isinstance`` checks are runtime-required: operators can
        # write whatever they want in ``local.py``; pyright narrows
        # against the declared ``Mapping[str, object]`` and will
        # report these checks as unnecessary, but the annotation is
        # an aspirational contract, not a runtime guarantee.
        if isinstance(value, str):  # pyright: ignore[reportUnnecessaryIsInstance]
            action = _validate_action(value, repr(key))
            _check_forbidden(key, action, "<root>", forbidden)
            continue

        if not isinstance(value, Mapping):  # pyright: ignore[reportUnnecessaryIsInstance]
            raise ImproperlyConfigured(
                f"AA_DISCORD_AUDIT_POLICY[{key!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[{key!r}] mapping is missing the "
                f"required 'default' key"
            )
        default_action = _validate_action(value["default"], f"{key!r}.default")
        _check_forbidden(key, default_action, "default", forbidden)
        for nested_key in ("by_group", "by_state"):
            nested = value.get(nested_key)
            if nested is None:
                continue
            if not isinstance(nested, Mapping):  # pyright: ignore[reportUnnecessaryIsInstance]
                raise ImproperlyConfigured(
                    f"AA_DISCORD_AUDIT_POLICY[{key!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():
                action = _validate_action(
                    inner_value, f"{key!r}.{nested_key}[{inner_key!r}]"
                )
                _check_forbidden(
                    key, action, f"{nested_key}[{inner_key!r}]", forbidden
                )


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."
        )


def _validate_retention_opt_out() -> None:
    # 0 is the indefinite-retention sentinel; any other value is a
    # finite day count and exits the validator immediately.
    if (
        AA_DISCORD_AUDIT_RUN_RETENTION_DAYS == 0
        and not AA_DISCORD_AUDIT_RETENTION_OPT_OUT_ACKNOWLEDGED
    ):
        raise ImproperlyConfigured(
            "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."
        )


def _validate_rate_limit_disable() -> None:
    if (
        AA_DISCORD_AUDIT_RUN_RATE_LIMIT_PER_DAY == 0
        and not AA_DISCORD_AUDIT_RUN_RATE_LIMIT_DISABLED
    ):
        raise ImproperlyConfigured(
            "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_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."""
    apps_list = list(getattr(settings, "INSTALLED_APPS", ()))
    if DISCORD_SERVICE_APP_LABEL not in apps_list:
        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 apps_list:
        # 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 apps_list.index(DISCORD_SERVICE_APP_LABEL) > apps_list.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 M4 guarantee. Add the entry alongside the "
            f"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_webhook_or_none()
    _validate_retention_opt_out()
    _validate_rate_limit_disable()
    _validate_bot_uids()
