"""Regression tests for multiplex profile-aware own-policy authorization."""

from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock

import pytest

from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.session import SessionSource


def _clear_auth_env(monkeypatch) -> None:
    for key in (
        "WECOM_ALLOWED_USERS",
        "GATEWAY_ALLOWED_USERS",
        "GATEWAY_ALLOW_ALL_USERS",
        "WECOM_ALLOW_ALL_USERS",
    ):
        monkeypatch.delenv(key, raising=False)


def _make_multiplex_runner(monkeypatch):
    """Runner with default allowlist WeCom and secondary open-policy WeCom."""
    from gateway.run import GatewayRunner

    _clear_auth_env(monkeypatch)

    runner = object.__new__(GatewayRunner)
    runner.config = GatewayConfig(multiplex_profiles=True)

    default_adapter = SimpleNamespace(
        send=AsyncMock(),
        enforces_own_access_policy=True,
        _dm_policy="allowlist",
        _group_policy="pairing",
    )
    secondary_adapter = SimpleNamespace(
        send=AsyncMock(),
        enforces_own_access_policy=True,
        _dm_policy="open",
        _group_policy="open",
    )

    runner.adapters = {Platform.WECOM: default_adapter}
    runner._profile_adapters = {
        "coder": {Platform.WECOM: secondary_adapter},
    }
    runner.pairing_store = MagicMock()
    runner.pairing_store.is_approved.return_value = False
    return runner, default_adapter, secondary_adapter


def test_default_profile_still_trusts_own_allowlist(monkeypatch):
    """Default-profile allowlist trust is unchanged when profile is unstamped."""
    runner, _default_adapter, _secondary_adapter = _make_multiplex_runner(monkeypatch)

    source = SessionSource(
        platform=Platform.WECOM,
        user_id="allowed-user",
        chat_id="dm-chat",
        user_name="allowed-user",
        chat_type="dm",
        profile=None,
    )

    assert runner._is_user_authorized(source) is True


def test_active_profile_stamp_resolves_primary_adapter(monkeypatch):
    """A single-profile gateway stamps its active profile but stores adapters as primary."""
    runner, default_adapter, _secondary_adapter = _make_multiplex_runner(monkeypatch)
    runner._active_profile_name = lambda: "dev"

    assert runner._authorization_adapter(Platform.WECOM, profile="dev") is default_adapter


def test_scoped_secondary_profile_still_uses_profile_adapters(monkeypatch):
    """Runtime scope must not redirect secondary authz to primary adapters.

    ``_make_profile_message_handler`` wraps ``_handle_message`` in
    ``_profile_runtime_scope``, which overrides HERMES_HOME so
    ``get_active_profile_name()`` equals the secondary profile for that turn.
    Authorization must still read ``_profile_adapters[profile]``, not the
    empty primary ``self.adapters`` map — otherwise upstream-auth platforms
    such as A2A default-deny an already-authenticated peer (#80884). A
    secondary profile with NO registry entry still fails closed.
    """
    from gateway.run import GatewayRunner

    _clear_auth_env(monkeypatch)

    runner = object.__new__(GatewayRunner)
    runner.config = GatewayConfig(multiplex_profiles=True)
    runner.adapters = {}
    runner.pairing_store = MagicMock()
    runner.pairing_store.is_approved.return_value = False

    secondary = SimpleNamespace(
        authorization_is_upstream=True,
        enforces_own_access_policy=False,
    )
    runner._profile_adapters = {"beta": {Platform("a2a"): secondary}}
    # Simulate the scoped turn: active profile name collapses to the secondary.
    runner._active_profile_name = lambda: "beta"

    assert runner._authorization_adapter(Platform("a2a"), profile="beta") is secondary

    source = SessionSource(
        platform=Platform("a2a"),
        chat_id="a2a-context",
        user_id="alpha",
        user_name="alpha",
        chat_type="dm",
        profile="beta",
    )
    assert runner._is_user_authorized(source) is True

    # Fail-closed guard is untouched: no registry entry -> no default fallback.
    runner._profile_adapters = {"beta": {}}
    assert runner._authorization_adapter(Platform("a2a"), profile="beta") is None


def test_secondary_allowlist_dm_behavior_ignores_unauthorized(monkeypatch):
    """Unauthorized-DM behavior must read the secondary adapter's dm_policy."""
    runner, _default_adapter, secondary_adapter = _make_multiplex_runner(monkeypatch)
    secondary_adapter._dm_policy = "allowlist"

    assert runner._get_unauthorized_dm_behavior(
        Platform.WECOM,
        profile="coder",
    ) == "ignore"
    assert runner._get_unauthorized_dm_behavior(Platform.WECOM) == "ignore"


def test_adapter_auth_check_stamps_secondary_profile(monkeypatch):
    """The adapter auth-check callback must stamp its own secondary profile.

    Regression for the gap where ``_make_adapter_auth_check`` built a
    profile-less ``SessionSource``, so a secondary adapter's external-context
    authorization (e.g. Slack/Discord thread-reply lookups) silently
    resolved the *active* profile's allowlist scope instead of its own.
    """
    from gateway.run import GatewayRunner

    _clear_auth_env(monkeypatch)

    runner = object.__new__(GatewayRunner)
    runner.config = GatewayConfig(multiplex_profiles=True)

    captured: dict = {}

    def fake_is_user_authorized(source):
        captured["profile"] = source.profile
        return True

    runner._is_user_authorized = fake_is_user_authorized

    check = runner._make_adapter_auth_check(Platform.WECOM, profile_name="coder")
    assert check("some-user", "dm", "dm-chat") is True
    assert captured["profile"] == "coder"


def test_startup_guard_gateway_allow_all_reads_scope_not_environ(monkeypatch):
    """The GATEWAY_ALLOW_ALL_USERS opt-in check inside the startup guard
    must honor the active profile secret scope (#93522): the default
    profile's env-only opt-in must not leak into a secondary profile that
    never opted in, and a secondary profile's own scoped opt-in must be
    honored."""
    from agent import secret_scope
    from gateway.run import _own_policy_open_startup_violation

    _clear_auth_env(monkeypatch)
    cfg = GatewayConfig(multiplex_profiles=True)
    cfg.platforms = {
        Platform.WECOM: PlatformConfig(enabled=True, extra={"dm_policy": "open"}),
    }

    previous_multiplex = secret_scope.is_multiplex_active()
    secret_scope.set_multiplex_active(True)
    monkeypatch.setenv("GATEWAY_ALLOW_ALL_USERS", "true")
    try:
        token = secret_scope.set_secret_scope({"SOMETHING_ELSE": "x"})
        try:
            violation = _own_policy_open_startup_violation(cfg)
        finally:
            secret_scope.reset_secret_scope(token)
        assert violation is not None, "default profile's env opt-in must not leak into the scoped secondary profile"

        token = secret_scope.set_secret_scope({"GATEWAY_ALLOW_ALL_USERS": "true"})
        try:
            violation = _own_policy_open_startup_violation(cfg)
        finally:
            secret_scope.reset_secret_scope(token)
        assert violation is None, "the secondary profile's own scoped opt-in must be honored"
    finally:
        secret_scope.set_multiplex_active(previous_multiplex)


def test_secondary_open_policy_fails_startup_guard(monkeypatch):
    """Secondary profiles must pass the same open-policy startup guard."""
    from gateway.run import _own_policy_open_startup_violation

    _clear_auth_env(monkeypatch)

    secondary_cfg = GatewayConfig(multiplex_profiles=True)
    secondary_cfg.platforms = {
        Platform.WECOM: PlatformConfig(
            enabled=True,
            extra={"dm_policy": "open"},
        ),
    }

    violation = _own_policy_open_startup_violation(secondary_cfg)
    assert violation is not None
    assert "wecom" in violation
    assert "open policy" in violation


# ─────────────────────────────────────────────────────────────────────
# Plugin-platform extra.allowed_users fallback (#98738 / #82871)
# ─────────────────────────────────────────────────────────────────────

# Buzz has no static Platform member: plugin platforms get a dynamic
# member created on demand by Platform._missing_ (value lookup). Resolve
# it that way — attribute access only works after an earlier lookup in
# the same process, which a fresh CI shard cannot rely on.
_BUZZ = Platform("buzz")


def _make_buzz_multiplex_runner(monkeypatch, extra):
    """Runner whose secondary 'coder' profile runs a live Buzz adapter."""
    from gateway.run import GatewayRunner
    from tests.gateway.test_buzz_adapter import _normalize_user_ref

    for key in (
        "BUZZ_ALLOWED_USERS",
        "BUZZ_ALLOW_ALL_USERS",
        "GATEWAY_ALLOWED_USERS",
        "GATEWAY_ALLOW_ALL_USERS",
    ):
        monkeypatch.delenv(key, raising=False)

    runner = object.__new__(GatewayRunner)
    runner.config = GatewayConfig(multiplex_profiles=True)

    adapter = SimpleNamespace(
        config=PlatformConfig(enabled=True, extra=extra),
        # The Buzz adapter exposes this hook so npub allowlist entries match
        # the hex-pubkey user ids the gateway authorizes.
        normalize_user_id=_normalize_user_ref,
    )
    runner.adapters = {}
    runner._profile_adapters = {"coder": {_BUZZ: adapter}}
    runner.pairing_store = MagicMock()
    runner.pairing_store.is_approved.return_value = False
    return runner


def _buzz_source(user_id):
    return SessionSource(
        platform=_BUZZ,
        user_id=user_id,
        chat_id="chat-1",
        user_name="member",
        chat_type="dm",
        profile="coder",
    )


def _patch_buzz_registry(monkeypatch, allowed_users_env="BUZZ_ALLOWED_USERS"):
    from gateway.platform_registry import platform_registry

    real_get = platform_registry.get

    def _get(key):
        if key == "buzz":
            return SimpleNamespace(allowed_users_env=allowed_users_env)
        return real_get(key)

    monkeypatch.setattr(platform_registry, "get", _get)


def test_secondary_buzz_extra_allowed_users_authorizes_listed_user(monkeypatch):
    """A secondary profile's extra.allowed_users must authorize its users when
    the env var only ever carried the default profile's list (#98738/#82871)."""
    from tests.gateway.test_buzz_adapter import SELF_NPUB, SELF_PUBKEY

    runner = _make_buzz_multiplex_runner(
        monkeypatch, extra={"allowed_users": [SELF_NPUB]}
    )
    _patch_buzz_registry(monkeypatch)

    # user_id arrives as the hex pubkey while the allowlist entry is an npub.
    assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is True


def test_secondary_buzz_extra_allowed_users_denies_unlisted_sender(monkeypatch):
    """Default-deny is preserved: a sender not in the profile's allowlist
    stays denied even though the adapter-level list admitted the message."""
    from tests.gateway.test_buzz_adapter import SELF_PUBKEY

    runner = _make_buzz_multiplex_runner(
        monkeypatch, extra={"allowed_users": ["npub1" + "b" * 56]}
    )
    _patch_buzz_registry(monkeypatch)

    assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is False


def test_secondary_buzz_without_extra_allowlist_stays_default_deny(monkeypatch):
    """No extra.allowed_users configured: nothing changes, the default-deny
    path applies (no fail-open via an empty list)."""
    from tests.gateway.test_buzz_adapter import SELF_PUBKEY

    runner = _make_buzz_multiplex_runner(monkeypatch, extra={})
    _patch_buzz_registry(monkeypatch)

    assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is False


def test_extra_allowed_users_not_consulted_without_registry_declaration(monkeypatch):
    """The fallback is gated on the platform's registry entry declaring
    allowed_users_env — a platform without that contract keeps the previous
    behavior even if its extra happens to hold an allowed_users key."""
    from tests.gateway.test_buzz_adapter import SELF_PUBKEY

    runner = _make_buzz_multiplex_runner(
        monkeypatch, extra={"allowed_users": ["someone"]}
    )
    _patch_buzz_registry(monkeypatch, allowed_users_env="")

    assert runner._is_user_authorized(_buzz_source("someone")) is False


def test_extra_allowed_users_wildcard_authorizes_any_sender(monkeypatch):
    """\"*\" in the profile's extra.allowed_users keeps the env-var wildcard
    semantics: any sender is authorized (still gated on the registry
    declaration)."""
    from tests.gateway.test_buzz_adapter import SELF_PUBKEY

    runner = _make_buzz_multiplex_runner(monkeypatch, extra={"allowed_users": ["*"]})
    _patch_buzz_registry(monkeypatch)

    assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is True


def test_extra_allowed_users_blank_entries_are_dropped_not_denials(monkeypatch):
    """Blank/whitespace entries are dropped at parse; an otherwise-empty
    list behaves like the absent case (default-deny), not like a wildcard."""
    from tests.gateway.test_buzz_adapter import SELF_PUBKEY

    runner = _make_buzz_multiplex_runner(
        monkeypatch, extra={"allowed_users": ["", "   ", ","]}
    )
    _patch_buzz_registry(monkeypatch)

    assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is False


def test_extra_allowed_users_case_insensitive_hex_and_uppercase_npub(monkeypatch):
    """Entry spellings normalize to the same principal: upper-case hex and
    upper-case npub entries both match the hex user id Buzz dispatches
    (entries are normalized; the inbound id is already hex)."""
    from tests.gateway.test_buzz_adapter import SELF_NPUB, SELF_PUBKEY

    runner = _make_buzz_multiplex_runner(
        monkeypatch, extra={"allowed_users": [SELF_PUBKEY.upper(), SELF_NPUB.upper()]}
    )
    _patch_buzz_registry(monkeypatch)

    assert runner._is_user_authorized(_buzz_source(SELF_PUBKEY)) is True


def test_adapter_intake_and_central_authz_agree_on_the_same_list(monkeypatch):
    """Policy-layer agreement (#98738): a sender admitted by the adapter's
    construction-time intake allowlist (extra.allowed_users normalized to
    hex) is exactly the sender the central check authorizes, and an
    unlisted sender is rejected at BOTH layers."""
    from gateway.session import SessionSource as _SessionSource

    from tests.gateway.test_buzz_adapter import SELF_NPUB, SELF_PUBKEY

    monkeypatch.delenv("BUZZ_ALLOWED_USERS", raising=False)
    monkeypatch.delenv("BUZZ_ALLOW_ALL_USERS", raising=False)

    other_hex = "b" * 64
    adapter_extra = {"allowed_users": [SELF_NPUB]}

    # Layer 1 — adapter intake: construction normalizes npub entries to hex.
    from tests.gateway.test_buzz_adapter import _make_adapter as _base_adapter

    adapter = _base_adapter(adapter_extra)
    assert adapter._allowed_pubkeys == {SELF_PUBKEY}

    # Layer 2 — central authz over the same adapter config.
    runner = _make_buzz_multiplex_runner(monkeypatch, extra=adapter_extra)
    _patch_buzz_registry(monkeypatch)

    for sender, admitted in ((SELF_PUBKEY, True), (other_hex, False)):
        # Adapter layer: intake filter admits/denies...
        assert (sender in adapter._allowed_pubkeys) is admitted
        # ...and central authz returns the SAME verdict for that sender.
        assert runner._is_user_authorized(
            _SessionSource(
                platform=_BUZZ,
                user_id=sender,
                chat_id="chat-1",
                user_name="member",
                chat_type="dm",
                profile="coder",
            )
        ) is admitted
