"""Policy evaluation engine — gather, match, merge, deny, SA intersection.

Implements the MinIO-style access model from spec Section 5. Replaces
the 4-step boolean merge in resolver.py.
"""

from __future__ import annotations

from pydantic import BaseModel

from hindclaw_ext.models import AttachedPolicyRecord
from hindclaw_ext.policy_models import BankPolicyDocument, PolicyDocument, PolicyStatement

# Budget ordering for merge rules
_BUDGET_ORDER = {"low": 0, "mid": 1, "high": 2}

# Core action → implicit extended actions
_CORE_ACTION_GRANTS = {
    "bank:recall": {"bank:memories:list", "bank:memories:get"},
}

# Principal type specificity (user > group)
_PRINCIPAL_SPECIFICITY = {"user": 2, "group": 1}


class AccessResult(BaseModel):
    """Result of policy evaluation for a single action + bank.

    Contains the access decision and merged behavioral parameters.
    """

    allowed: bool = False
    resolved_user_id: str | None = None
    recall_budget: str | None = None
    recall_max_tokens: int | None = None
    recall_tag_groups: list[dict] | None = None
    retain_roles: list[str] | None = None
    retain_tags: list[str] | None = None
    retain_every_n_turns: int | None = None
    retain_strategy: str | None = None
    llm_model: str | None = None
    llm_provider: str | None = None
    exclude_providers: list[str] | None = None


def bank_matches(bank_id: str, pattern: str) -> bool:
    """Check if a bank ID matches a policy statement's bank pattern.

    Matching rules:
      - ``*`` matches all banks
      - ``"yoda"`` matches exactly ``"yoda"``
      - ``"yoda::*"`` matches banks starting with ``"yoda::"`` (children only,
        not the base bank ``"yoda"`` itself)

    Args:
        bank_id: Actual bank ID from the request.
        pattern: Bank pattern from a policy statement.

    Returns:
        True if the bank ID matches the pattern.
    """
    if pattern == "*":
        return True
    if pattern.endswith("::*"):
        prefix = pattern[:-1]  # "yoda::*" -> "yoda::"
        return bank_id.startswith(prefix)
    return bank_id == pattern


def bank_specificity(pattern: str) -> int:
    """Return specificity rank of a bank pattern.

    Higher value = more specific. Used for tie-breaking when multiple
    statements match the same bank.

    Args:
        pattern: Bank pattern from a policy statement.

    Returns:
        Integer specificity rank (exact=3, prefix=2, wildcard=1).
    """
    if pattern == "*":
        return 1
    if pattern.endswith("::*"):
        return 2
    return 3


def _action_matches(requested: str, granted: str) -> bool:
    """Check if a requested action is covered by a granted action.

    Args:
        requested: The action being checked (e.g., "bank:recall").
        granted: The action in the policy statement (e.g., "bank:*").

    Returns:
        True if the granted action covers the requested action.
    """
    if granted == requested:
        return True
    # Wildcard: bank:* matches any bank:... action
    if granted.endswith(":*") and requested.startswith(granted[:-1]):
        return True
    # Core action grants: bank:recall -> bank:memories:list, etc.
    if granted in _CORE_ACTION_GRANTS and requested in _CORE_ACTION_GRANTS[granted]:
        return True
    return False


def evaluate_access(
    policies: list[AttachedPolicyRecord],
    action: str,
    bank_id: str,
) -> AccessResult:
    """Evaluate access policies for a specific action on a specific bank.

    Gathers matching allow/deny statements from all policies, applies
    merge rules for behavioral parameters, and applies deny-overrides-allow.

    Args:
        policies: List of AttachedPolicyRecord from db.get_policies_for_user().
        action: The action being checked (e.g., "bank:recall").
        bank_id: The target bank ID.

    Returns:
        AccessResult with allowed flag and merged behavioral parameters.
    """
    # Collect all matching statements with their metadata
    allows: list[tuple[PolicyStatement, int, int, int, str]] = []
    has_deny = False

    for policy_data in policies:
        doc = PolicyDocument(**policy_data.document_json)
        principal_type = policy_data.principal_type
        priority = policy_data.priority
        principal_spec = _PRINCIPAL_SPECIFICITY.get(principal_type, 0)

        for stmt in doc.statements:
            # Check if statement matches action + bank
            action_match = any(_action_matches(action, a) for a in stmt.actions)
            bank_match = any(bank_matches(bank_id, b) for b in stmt.banks)

            if not action_match or not bank_match:
                continue

            if stmt.effect == "deny":
                has_deny = True
                break
            elif stmt.effect == "allow":
                matching_specs = [bank_specificity(b) for b in stmt.banks if bank_matches(bank_id, b)]
                best_bank_spec = max(matching_specs) if matching_specs else 0
                allows.append((stmt, principal_spec, best_bank_spec, priority, policy_data.id))

        if has_deny:
            break

    if has_deny or not allows:
        return AccessResult(allowed=False)

    # Merge behavioral parameters from all matching allow statements
    result = AccessResult(allowed=True)

    # Additive fields: merge from ALL matching statements
    all_tag_groups: list[dict] = []
    all_retain_roles: set[str] = set()
    all_retain_tags: set[str] = set()
    all_exclude_providers: set[str] = set()
    best_budget: str | None = None
    best_max_tokens: int | None = None
    best_every_n: int | None = None

    # Single-value fields: track best source by (principal_spec, bank_spec, priority, policy_id)
    best_llm_model: tuple[tuple[int, int, int, str], str | None] = ((-1, -1, -1, ""), None)
    best_llm_provider: tuple[tuple[int, int, int, str], str | None] = ((-1, -1, -1, ""), None)
    best_strategy: tuple[tuple[int, int, int, str], str | None] = ((-1, -1, -1, ""), None)

    for stmt, principal_spec, best_bank_spec, priority, policy_id in allows:
        specificity_key = (principal_spec, best_bank_spec, priority, policy_id)

        # Additive: recall_budget — most permissive
        if stmt.recall_budget is not None:
            if best_budget is None or _BUDGET_ORDER.get(stmt.recall_budget, 0) > _BUDGET_ORDER.get(best_budget, 0):
                best_budget = stmt.recall_budget

        # Additive: recall_max_tokens — highest
        if stmt.recall_max_tokens is not None:
            best_max_tokens = max(best_max_tokens or 0, stmt.recall_max_tokens)

        # Additive: recall_tag_groups — collected
        if stmt.recall_tag_groups is not None:
            all_tag_groups.extend(stmt.recall_tag_groups)

        # Additive: retain_roles — union
        if stmt.retain_roles is not None:
            all_retain_roles.update(stmt.retain_roles)

        # Additive: retain_tags — union
        if stmt.retain_tags is not None:
            all_retain_tags.update(stmt.retain_tags)

        # Additive: exclude_providers — union
        if stmt.exclude_providers is not None:
            all_exclude_providers.update(stmt.exclude_providers)

        # Additive: retain_every_n_turns — lowest (most frequent)
        if stmt.retain_every_n_turns is not None:
            if best_every_n is None or stmt.retain_every_n_turns < best_every_n:
                best_every_n = stmt.retain_every_n_turns

        # Single-value: llm_model — most specific wins
        if stmt.llm_model is not None and specificity_key > best_llm_model[0]:
            best_llm_model = (specificity_key, stmt.llm_model)

        # Single-value: llm_provider — most specific wins
        if stmt.llm_provider is not None and specificity_key > best_llm_provider[0]:
            best_llm_provider = (specificity_key, stmt.llm_provider)

        # Single-value: retain_strategy — most specific wins
        if stmt.retain_strategy is not None and specificity_key > best_strategy[0]:
            best_strategy = (specificity_key, stmt.retain_strategy)

    result.recall_budget = best_budget
    result.recall_max_tokens = best_max_tokens
    result.recall_tag_groups = all_tag_groups if all_tag_groups else None
    result.retain_roles = sorted(all_retain_roles) if all_retain_roles else None
    result.retain_tags = sorted(all_retain_tags) if all_retain_tags else None
    result.exclude_providers = sorted(all_exclude_providers) if all_exclude_providers else None
    result.retain_every_n_turns = best_every_n
    result.llm_model = best_llm_model[1]
    result.llm_provider = best_llm_provider[1]
    result.retain_strategy = best_strategy[1]

    return result


def intersect_sa_policy(
    parent: AccessResult,
    scoping: AccessResult | None,
) -> AccessResult:
    """Intersect a service account's scoping policy with the parent user's effective policy.

    If scoping is None, the SA inherits the parent's full policy.
    If either denies, the result denies.
    For access-adjacent fields, the more restrictive value wins.
    For metadata fields, the scoping policy's value wins if set.

    Args:
        parent: Parent user's effective access result.
        scoping: SA's scoping policy access result, or None for full inheritance.

    Returns:
        AccessResult with intersected permissions.
    """
    if scoping is None:
        return parent.model_copy()

    if not parent.allowed or not scoping.allowed:
        return AccessResult(allowed=False)

    # Access-adjacent: more restrictive
    budget = None
    if parent.recall_budget is not None and scoping.recall_budget is not None:
        budget = parent.recall_budget if _BUDGET_ORDER.get(parent.recall_budget, 0) < _BUDGET_ORDER.get(scoping.recall_budget, 0) else scoping.recall_budget
    elif scoping.recall_budget is not None:
        budget = scoping.recall_budget
    else:
        budget = parent.recall_budget

    max_tokens = None
    if parent.recall_max_tokens is not None and scoping.recall_max_tokens is not None:
        max_tokens = min(parent.recall_max_tokens, scoping.recall_max_tokens)
    elif scoping.recall_max_tokens is not None:
        max_tokens = scoping.recall_max_tokens
    else:
        max_tokens = parent.recall_max_tokens

    every_n = None
    if parent.retain_every_n_turns is not None and scoping.retain_every_n_turns is not None:
        every_n = max(parent.retain_every_n_turns, scoping.retain_every_n_turns)
    elif scoping.retain_every_n_turns is not None:
        every_n = scoping.retain_every_n_turns
    else:
        every_n = parent.retain_every_n_turns

    # Roles: intersection (only roles in both)
    roles = None
    if parent.retain_roles is not None and scoping.retain_roles is not None:
        roles = sorted(set(parent.retain_roles) & set(scoping.retain_roles))
    else:
        roles = scoping.retain_roles or parent.retain_roles

    # Tags, tag_groups, exclude_providers: union (additive)
    tags = sorted(set(parent.retain_tags or []) | set(scoping.retain_tags or [])) or None
    tag_groups = (parent.recall_tag_groups or []) + (scoping.recall_tag_groups or []) or None
    excludes = sorted(set(parent.exclude_providers or []) | set(scoping.exclude_providers or [])) or None

    # Metadata: scoping wins if set, else parent
    strategy = scoping.retain_strategy if scoping.retain_strategy is not None else parent.retain_strategy
    model = scoping.llm_model if scoping.llm_model is not None else parent.llm_model
    provider = scoping.llm_provider if scoping.llm_provider is not None else parent.llm_provider

    return AccessResult(
        allowed=True,
        recall_budget=budget,
        recall_max_tokens=max_tokens,
        recall_tag_groups=tag_groups if tag_groups else None,
        retain_roles=roles,
        retain_tags=tags,
        retain_every_n_turns=every_n,
        retain_strategy=strategy,
        llm_model=model,
        llm_provider=provider,
        exclude_providers=excludes,
    )


# Context scope priority for bank policy strategy overrides (higher = more specific)
_CONTEXT_SCOPE_PRIORITY = {"provider": 1, "channel": 2, "topic": 3}


def resolve_bank_strategy(
    bank_policy: BankPolicyDocument,
    provider: str | None = None,
    channel: str | None = None,
    topic: str | None = None,
) -> str | None:
    """Resolve the retain strategy from a bank policy using context matching.

    Checks strategy overrides in priority order (topic > channel > provider).
    Falls back to default_strategy if no override matches.

    Args:
        bank_policy: The bank's policy document.
        provider: Provider from JWT claims (e.g., "telegram").
        channel: Channel from JWT claims.
        topic: Topic ID from JWT claims.

    Returns:
        Strategy name, or None if no strategy defined.
    """
    context = {"provider": provider, "channel": channel, "topic": topic}

    best_strategy: str | None = None
    best_priority = -1

    for override in bank_policy.strategy_overrides:
        scope_priority = _CONTEXT_SCOPE_PRIORITY.get(override.scope, 0)
        context_value = context.get(override.scope)

        if context_value is not None and context_value == override.value and scope_priority > best_priority:
            best_strategy = override.strategy
            best_priority = scope_priority

    if best_strategy is not None:
        return best_strategy

    return bank_policy.default_strategy


async def apply_sa_scoping(
    parent_access: AccessResult,
    scoping_policy_id: str,
    sa_id: str,
    action: str,
    bank_id: str,
) -> AccessResult:
    """Apply SA scoping policy intersection on a parent's access result.

    Fetches the scoping policy, evaluates it for the action+bank, then
    intersects with the parent's effective access.

    Args:
        parent_access: Parent user's effective access result.
        scoping_policy_id: Policy ID for the SA's scoping policy.
        sa_id: Service account ID (for the synthetic attachment).
        action: The action being checked.
        bank_id: Target bank ID.

    Returns:
        Intersected AccessResult. Returns parent_access unchanged if
        the scoping policy is not found.
    """
    # Import here to avoid circular imports (models imports from policy_engine)
    from hindclaw_ext import db
    from hindclaw_ext.models import AttachedPolicyRecord

    scoping_policy = await db.get_policy(scoping_policy_id)
    if not scoping_policy:
        return parent_access

    scoping_attached = AttachedPolicyRecord(
        id=scoping_policy.id, display_name=scoping_policy.display_name,
        document_json=scoping_policy.document_json,
        is_builtin=scoping_policy.is_builtin,
        principal_type="user", principal_id=sa_id, priority=0,
    )
    scoping_access = evaluate_access([scoping_attached], action=action, bank_id=bank_id)
    return intersect_sa_policy(parent_access, scoping_access)
