"""Shared admission evaluation helpers for Python CLI paths.

These helpers intentionally mirror the admission ordering used by the Go
gateway/watcher:

1. Explicit block list entries override everything.
2. Asset policy can block denied/unregistered/default-denied assets.
3. Explicit allow list entries skip scan/enforcement after asset policy.
4. Policy-managed allow entries (for example first-party bundles) may bypass
   scan depending on the active policy data.
5. If no scan result exists yet, the active policy decides whether scanning is
   required.
6. Once a scan result exists, the effective per-target action mapping decides
   whether the result is rejected or only warned.
"""

from __future__ import annotations

import json
import os
from dataclasses import dataclass, field
from typing import Any

from defenseclaw.config import SeverityAction


@dataclass(frozen=True)
class AdmissionDecision:
    verdict: str
    reason: str
    action: SeverityAction = field(default_factory=SeverityAction)
    source: str = ""


@dataclass(frozen=True)
class AdmissionPolicyData:
    allow_list_bypass_scan: bool = True
    scan_on_install: bool = True
    actions: dict[str, SeverityAction] = field(default_factory=dict)
    scanner_overrides: dict[str, dict[str, SeverityAction]] = field(default_factory=dict)
    first_party_allow: dict[tuple[str, str], tuple[str, list[str]]] = field(default_factory=dict)


def _default_admission_policy() -> AdmissionPolicyData:
    return AdmissionPolicyData(
        allow_list_bypass_scan=True,
        scan_on_install=True,
        actions={
            "CRITICAL": SeverityAction(file="quarantine", runtime="disable", install="block"),
            "HIGH": SeverityAction(file="quarantine", runtime="disable", install="block"),
            "MEDIUM": SeverityAction(file="none", runtime="enable", install="none"),
            "LOW": SeverityAction(file="none", runtime="enable", install="none"),
            "INFO": SeverityAction(file="none", runtime="enable", install="none"),
        },
        scanner_overrides={
            "mcp": {
                "MEDIUM": SeverityAction(file="quarantine", runtime="disable", install="block"),
                "LOW": SeverityAction(file="none", runtime="disable", install="none"),
            },
            "plugin": {
                "HIGH": SeverityAction(file="quarantine", runtime="disable", install="block"),
                "MEDIUM": SeverityAction(file="none", runtime="enable", install="none"),
            },
        },
        first_party_allow={
            ("plugin", "defenseclaw"): (
                "first-party DefenseClaw plugin",
                [".defenseclaw", "extensions/defenseclaw"],
            ),
            ("skill", "codeguard"): (
                "first-party DefenseClaw skill",
                [".defenseclaw", "workspace/skills/codeguard", "skills/codeguard"],
            ),
        },
    )


def evaluate_admission(
    pe: Any,
    *,
    policy_dir: str,
    target_type: str,
    name: str,
    source_path: str = "",
    connector: str = "",
    url: str = "",
    command: str = "",
    args: list[str] | None = None,
    transport: str = "",
    runtime_surface: str = "cli",
    asset_policy: Any | None = None,
    scan_result: Any | None = None,
    action_entry: Any | None = None,
    fallback_actions: Any | None = None,
    include_quarantine: bool = False,
) -> AdmissionDecision:
    """Evaluate admission for a target using active policy data when available.

    Explicit block entries always win. Explicit allow entries skip scanning
    after asset policy has had a chance to enforce admin deny/default-deny
    controls. Policy-managed allow entries from ``first_party_allow_list`` are
    still subject to the policy's ``allow_list_bypass_scan`` setting.
    """
    blocked_reason = _action_reason(action_entry, default=f"{target_type} '{name}' is on the block list")
    if pe.is_blocked(target_type, name):
        return AdmissionDecision("blocked", blocked_reason, source="manual-block")

    asset_decision = evaluate_asset_policy(
        asset_policy,
        target_type=target_type,
        name=name,
        connector=connector,
        source_path=source_path,
        url=url,
        command=command,
        args=args or [],
        transport=transport,
        runtime_surface=runtime_surface,
    )
    if asset_decision.verdict == "blocked":
        return asset_decision

    allowed_reason = _action_reason(action_entry, default=f"{target_type} '{name}' is on the allow list — scan skipped")
    if pe.is_allowed(target_type, name):
        return AdmissionDecision("allowed", allowed_reason, source="manual-allow")

    if include_quarantine and pe.is_quarantined(target_type, name):
        reason = _action_reason(action_entry, default="quarantined")
        return AdmissionDecision("rejected", f"quarantined: {reason}", source="quarantine")

    policy = load_admission_policy(policy_dir)

    fp_entry = policy.first_party_allow.get((target_type, name))
    if fp_entry is not None and policy.allow_list_bypass_scan:
        fp_reason, fp_constraints = fp_entry
        if _matches_provenance(fp_constraints, source_path):
            return AdmissionDecision("allowed", fp_reason, source="policy-allow")

    if scan_result is None:
        if not policy.scan_on_install:
            return AdmissionDecision(
                "allowed",
                "scan_on_install disabled — allowed without scan",
                source="scan-disabled",
            )
        return AdmissionDecision("scan", "scan required", source="scan-required")

    finding_count, severity = _scan_summary(scan_result)
    action = effective_action_for(
        policy,
        target_type=target_type,
        severity=severity,
        fallback_actions=fallback_actions,
    )

    if finding_count <= 0:
        return AdmissionDecision("clean", "scan clean", action=action, source="scan-clean")

    detail = f"{finding_count} findings, max {severity}"
    if action.install == "block" or action.runtime == "disable":
        return AdmissionDecision("rejected", detail, action=action, source="scan-rejected")

    return AdmissionDecision("warning", detail, action=action, source="scan-warning")


def evaluate_asset_policy(
    asset_policy: Any | None,
    *,
    target_type: str,
    name: str,
    connector: str = "",
    source_path: str = "",
    url: str = "",
    command: str = "",
    args: list[str] | None = None,
    transport: str = "",
    runtime_surface: str = "cli",
) -> AdmissionDecision:
    if not getattr(asset_policy, "enabled", False):
        return AdmissionDecision("allowed", "asset policy disabled", source="asset-policy-disabled")

    policy = getattr(asset_policy, target_type, None)
    if policy is None:
        return AdmissionDecision("allowed", "asset policy unsupported target", source="asset-policy-unsupported")

    rule_args = args or []
    if rule := _find_asset_rule(
        getattr(policy, "denied", []),
        name,
        connector,
        source_path,
        url,
        command,
        rule_args,
        transport,
    ):
        reason = getattr(rule, "reason", "") or f"{target_type} {name!r} is denied by asset policy"
        return _asset_policy_block_or_observe(asset_policy, reason, "asset-policy-deny")

    if rule := _find_asset_rule(
        getattr(policy, "allowed", []),
        name,
        connector,
        source_path,
        url,
        command,
        rule_args,
        transport,
    ):
        reason = getattr(rule, "reason", "") or f"{target_type} {name!r} is explicitly allowed"
        return AdmissionDecision("allowed", reason, source="asset-policy-allow")

    registry = getattr(policy, "registry", [])
    registered = _find_asset_rule(
        registry,
        name,
        connector,
        source_path,
        url,
        command,
        rule_args,
        transport,
    ) is not None
    if registry and registered:
        return AdmissionDecision("allowed", f"{target_type} {name!r} is registered", source="asset-policy-registry")

    if getattr(policy, "registry_required", False):
        return _asset_policy_block_or_observe(
            asset_policy,
            f"{target_type} {name!r} is not in the approved registry",
            "asset-policy-registry-required",
        )

    if str(getattr(policy, "default", "allow")).strip().lower() in {"deny", "block"}:
        return _asset_policy_block_or_observe(
            asset_policy,
            f"{target_type} {name!r} is denied by default asset policy",
            "asset-policy-default-deny",
        )

    return AdmissionDecision(
        "allowed",
        f"{target_type} {name!r} allowed by default asset policy",
        source="asset-policy-default-allow",
    )


def _asset_policy_block_or_observe(asset_policy: Any, reason: str, source: str) -> AdmissionDecision:
    if str(getattr(asset_policy, "mode", "observe")).strip().lower() == "action":
        return AdmissionDecision("blocked", reason, source=source)
    return AdmissionDecision("allowed", reason, source=source + "-observe")


def _find_asset_rule(
    rules: list[Any],
    name: str,
    connector: str,
    source_path: str,
    url: str,
    command: str,
    args: list[str],
    transport: str,
) -> Any | None:
    for rule in rules:
        if _asset_rule_matches(rule, name, connector, source_path, url, command, args, transport):
            return rule
    return None


def _asset_rule_matches(
    rule: Any,
    name: str,
    connector: str,
    source_path: str,
    url: str,
    command: str,
    args: list[str],
    transport: str,
) -> bool:
    constrained = False
    if getattr(rule, "name", ""):
        constrained = True
        if str(rule.name).strip().lower() != name.strip().lower():
            return False
    if getattr(rule, "connector", ""):
        constrained = True
        if str(rule.connector).strip().lower() != connector.strip().lower():
            return False
    if getattr(rule, "url", ""):
        constrained = True
        if str(rule.url).strip() != url.strip():
            return False
    if getattr(rule, "command", ""):
        constrained = True
        if os.path.basename(str(rule.command).strip()) != os.path.basename(command.strip()):
            return False
    prefix = getattr(rule, "args_prefix", []) or []
    if prefix:
        constrained = True
        if len(args) < len(prefix):
            return False
        for idx, want in enumerate(prefix):
            if str(want).strip() != str(args[idx]).strip():
                return False
    if getattr(rule, "transport", ""):
        constrained = True
        if str(rule.transport).strip().lower() != transport.strip().lower():
            return False
    needles = getattr(rule, "source_path_contains", []) or []
    if needles:
        constrained = True
        normalized = source_path.replace("\\", "/").lower()
        if not any(str(needle).replace("\\", "/").lower() in normalized for needle in needles):
            return False
    return constrained


def effective_action_for(
    policy: AdmissionPolicyData,
    *,
    target_type: str,
    severity: str,
    fallback_actions: Any | None = None,
) -> SeverityAction:
    sev = severity.upper()
    target_overrides = policy.scanner_overrides.get(target_type, {})
    if sev in target_overrides:
        return target_overrides[sev]
    if sev in policy.actions:
        return policy.actions[sev]
    if fallback_actions is not None:
        return fallback_actions.for_severity(sev)
    return SeverityAction()


def load_admission_policy(policy_dir: str) -> AdmissionPolicyData:
    data = _read_policy_data(policy_dir)
    if not data:
        return _default_admission_policy()

    defaults = _default_admission_policy()

    cfg = data.get("config", {}) or {}
    raw_actions = data.get("actions", {}) or {}
    raw_overrides = data.get("scanner_overrides", {}) or {}
    first_party = data.get("first_party_allow_list", []) or []

    actions = {
        severity.upper(): _severity_action_from_policy(raw)
        for severity, raw in raw_actions.items()
        if isinstance(raw, dict)
    }

    scanner_overrides: dict[str, dict[str, SeverityAction]] = {}
    for target_type, overrides in raw_overrides.items():
        if not isinstance(overrides, dict):
            continue
        scanner_overrides[target_type] = {
            severity.upper(): _severity_action_from_policy(raw)
            for severity, raw in overrides.items()
            if isinstance(raw, dict)
        }

    first_party_allow: dict[tuple[str, str], tuple[str, list[str]]] = dict(defaults.first_party_allow)
    for entry in first_party:
        if not isinstance(entry, dict):
            continue
        target_type = str(entry.get("target_type", ""))
        target_name = str(entry.get("target_name", ""))
        if target_type and target_name:
            reason = str(entry.get("reason", "first-party allow"))
            source_path_contains = entry.get("source_path_contains", [])
            if not isinstance(source_path_contains, list):
                source_path_contains = []
            first_party_allow[(target_type, target_name)] = (reason, source_path_contains)

    merged_actions = dict(defaults.actions)
    merged_actions.update(actions)

    merged_overrides = {
        target_type: dict(overrides)
        for target_type, overrides in defaults.scanner_overrides.items()
    }
    for target_type, overrides in scanner_overrides.items():
        merged_overrides.setdefault(target_type, {}).update(overrides)

    return AdmissionPolicyData(
        allow_list_bypass_scan=bool(cfg.get("allow_list_bypass_scan", defaults.allow_list_bypass_scan)),
        scan_on_install=bool(cfg.get("scan_on_install", defaults.scan_on_install)),
        actions=merged_actions,
        scanner_overrides=merged_overrides,
        first_party_allow=first_party_allow,
    )


def _severity_action_from_policy(raw: dict[str, Any]) -> SeverityAction:
    runtime = "disable" if raw.get("runtime", "allow") == "block" else "enable"
    return SeverityAction(
        file=str(raw.get("file", "none")),
        runtime=runtime,
        install=str(raw.get("install", "none")),
    )


def _read_policy_data(policy_dir: str) -> dict[str, Any] | None:
    for candidate in _policy_data_candidates(policy_dir):
        try:
            with open(candidate) as f:
                data = json.load(f)
        except (OSError, json.JSONDecodeError):
            continue
        if isinstance(data, dict):
            return data
    return None


def _policy_data_candidates(policy_dir: str) -> list[str]:
    candidates: list[str] = []
    if policy_dir:
        candidates.append(os.path.join(policy_dir, "rego", "data.json"))
        candidates.append(os.path.join(policy_dir, "data.json"))
    return candidates


def _scan_summary(scan_result: Any) -> tuple[int, str]:
    if hasattr(scan_result, "findings") and hasattr(scan_result, "max_severity"):
        findings = getattr(scan_result, "findings", []) or []
        return len(findings), str(scan_result.max_severity())

    if isinstance(scan_result, dict):
        count = scan_result.get("total_findings")
        if count is None:
            count = scan_result.get("finding_count", 0)
        severity = scan_result.get("max_severity", "INFO")
        return int(count or 0), str(severity)

    return 0, "INFO"


def _matches_provenance(constraints: list[str], source_path: str) -> bool:
    """True if no constraints exist, or if source_path contains one of them."""
    if not constraints:
        return True
    if not source_path:
        return False
    normalised = source_path.replace("\\", "/").lower()
    return any(c.lower() in normalised for c in constraints)


def _action_reason(action_entry: Any | None, *, default: str) -> str:
    reason = getattr(action_entry, "reason", "") if action_entry is not None else ""
    return reason or default
