from __future__ import annotations

import copy
import fnmatch
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from .models import PolicyDecision, ToolCall


class PolicyError(RuntimeError):
    pass


@dataclass
class Rule:
    rule_id: str
    effect: str
    priority: int
    subjects: list[str]
    actions: list[str]
    resources: list[str]
    conditions: dict[str, Any] = field(default_factory=dict)
    reason: str = ""

    @classmethod
    def from_dict(cls, raw: dict[str, Any]) -> "Rule":
        effect = str(raw["effect"])
        if effect not in {"allow", "deny"}:
            raise PolicyError(f"Invalid effect in {raw.get('rule_id')}")
        return cls(
            rule_id=str(raw["rule_id"]),
            effect=effect,
            priority=int(raw["priority"]),
            subjects=list(raw["subjects"]),
            actions=list(raw["actions"]),
            resources=list(raw["resources"]),
            conditions=dict(raw.get("conditions", {})),
            reason=str(raw.get("reason", "")),
        )

    def as_dict(self) -> dict[str, Any]:
        return {
            "rule_id": self.rule_id,
            "effect": self.effect,
            "priority": self.priority,
            "subjects": self.subjects,
            "actions": self.actions,
            "resources": self.resources,
            "conditions": self.conditions,
            "reason": self.reason,
        }


@dataclass
class PolicySet:
    policy_id: str
    version: str
    fail_closed: bool
    reset_required: bool
    delegation_require_intersection: bool
    rules: list[Rule]
    mutation_id: str = ""

    @classmethod
    def load(cls, path: Path) -> "PolicySet":
        raw = json.loads(path.read_text(encoding="utf-8"))
        policy = cls(
            policy_id=str(raw["policy_id"]),
            version=str(raw["version"]),
            fail_closed=bool(raw["fail_closed"]),
            reset_required=bool(raw["reset_required"]),
            delegation_require_intersection=bool(
                raw["delegation_require_intersection"]
            ),
            rules=[Rule.from_dict(item) for item in raw["rules"]],
        )
        policy.validate()
        return policy

    def clone(self) -> "PolicySet":
        return copy.deepcopy(self)

    def validate(self) -> None:
        ids = [rule.rule_id for rule in self.rules]
        if len(ids) != len(set(ids)):
            raise PolicyError("Rule identifiers must be unique")
        if not self.rules:
            raise PolicyError("Policy must contain at least one rule")
        if not any(rule.rule_id == "deny-by-default" for rule in self.rules):
            raise PolicyError("Policy requires an explicit deny-by-default rule")

    def as_dict(self) -> dict[str, Any]:
        return {
            "policy_id": self.policy_id,
            "version": self.version,
            "fail_closed": self.fail_closed,
            "reset_required": self.reset_required,
            "delegation_require_intersection": self.delegation_require_intersection,
            "mutation_id": self.mutation_id,
            "rules": [rule.as_dict() for rule in self.rules],
        }


def _matches(patterns: list[str], value: str) -> bool:
    return any(fnmatch.fnmatchcase(value, pattern) for pattern in patterns)


def _conditions_match(conditions: dict[str, Any], call: ToolCall) -> tuple[bool, str]:
    provenance = str(call.context.get("provenance", "untrusted"))
    if "provenance_in" in conditions and provenance not in conditions["provenance_in"]:
        return False, "provenance"

    if conditions.get("approval_required") is True and not bool(
        call.context.get("approved", False)
    ):
        return False, "approval"

    for key, allowed in conditions.get("argument_in", {}).items():
        if call.arguments.get(key) not in allowed:
            return False, f"argument_in:{key}"

    for key, maximum in conditions.get("argument_max", {}).items():
        try:
            value = float(call.arguments[key])
        except (KeyError, TypeError, ValueError):
            return False, f"argument_max:{key}"
        if value > float(maximum):
            return False, f"argument_max:{key}"

    if conditions.get("parent_capability_intersection"):
        requested_raw = call.arguments.get("capabilities")
        parent_raw = call.context.get("parent_capabilities")
        if not isinstance(requested_raw, list) or not isinstance(parent_raw, list):
            return False, "parent_capability_intersection:missing"
        if not all(isinstance(item, str) for item in requested_raw + parent_raw):
            return False, "parent_capability_intersection:type"
        requested = set(requested_raw)
        parent = set(parent_raw)
        if not requested.issubset(parent):
            return False, "parent_capability_intersection"

    expires_at_step = conditions.get("expires_at_step")
    if expires_at_step is not None and int(call.context.get("step", 0)) > int(
        expires_at_step
    ):
        return False, "expiry"

    return True, ""


class PolicyEngine:
    def __init__(self, policy: PolicySet) -> None:
        self.policy = policy

    def evaluate(self, call: ToolCall) -> PolicyDecision:
        try:
            ordered = sorted(self.policy.rules, key=lambda item: item.priority, reverse=True)
            condition_failures: list[dict[str, str]] = []
            for rule in ordered:
                if not _matches(rule.subjects, call.subject):
                    continue
                if not _matches(rule.actions, call.action):
                    continue
                if not _matches(rule.resources, call.resource):
                    continue
                conditions_ok, failed = _conditions_match(rule.conditions, call)
                if not conditions_ok:
                    condition_failures.append({"rule_id": rule.rule_id, "failed": failed})
                    continue
                return PolicyDecision(
                    effect=rule.effect,
                    rule_id=rule.rule_id,
                    reason=rule.reason,
                    evidence={"condition_failures": condition_failures},
                )
        except Exception as exc:
            if self.policy.fail_closed:
                return PolicyDecision(
                    effect="deny",
                    rule_id="policy-error-fail-closed",
                    reason=f"Policy evaluation failed closed: {type(exc).__name__}",
                )
            return PolicyDecision(
                effect="allow",
                rule_id="policy-error-fail-open",
                reason=f"Mutated fail-open policy error: {type(exc).__name__}",
            )
        return PolicyDecision(
            effect="deny" if self.policy.fail_closed else "allow",
            rule_id="implicit-fallback",
            reason="No policy rule matched",
        )
