"""Autonomy policy loader, hashing, and evaluation."""

from __future__ import annotations

import hashlib
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field

from .contracts import PlanStep
from .tools import AutopilotToolDefinition

try:  # Optional dependency in some environments.
    import yaml
except Exception as exc:  # pragma: no cover - handled at runtime
    yaml = None  # type: ignore
    _yaml_import_error = exc
else:  # pragma: no cover - no error path when yaml present
    _yaml_import_error = None


class ScheduleConfig(BaseModel):
    model_config = ConfigDict(extra="ignore")

    cron: str
    timezone: str
    quiet_hours: list[str] = Field(default_factory=list)
    jitter_minutes: int = 0


class KillSwitchConfig(BaseModel):
    model_config = ConfigDict(extra="ignore", populate_by_name=True)

    global_: bool = Field(default=False, alias="global")
    by_tag: dict[str, bool] = Field(default_factory=dict)


class PolicyDefaults(BaseModel):
    model_config = ConfigDict(extra="ignore")

    auto_execute_max_risk_level: int = 0
    approval_required_min_risk_level_default: int = 1
    auto_execute_requires_allow_rule_for_writes: bool = True
    deny_tags: list[str] = Field(default_factory=list)
    max_records_per_run: int = 500
    max_bulk_batch: int = 200
    max_steps_per_run: int = 100
    state_dir: str = "~/.g_gremlin/autopilot"
    receipt_dir: str = "${state_dir}/receipts"
    slack_channel: str | None = None
    approver_roles: list[str] = Field(default_factory=list)
    verify_after_execute: bool = True
    kill_switches: KillSwitchConfig = Field(default_factory=KillSwitchConfig)


class RuleMatch(BaseModel):
    model_config = ConfigDict(extra="ignore")

    tags: list[str] | None = None
    tools: list[str] | None = None
    risk_level_gte: int | None = None


class AllowRule(BaseModel):
    model_config = ConfigDict(extra="ignore")

    name: str
    match: RuleMatch
    auto_execute: bool = False
    max_records: int | None = None


class DenyRule(BaseModel):
    model_config = ConfigDict(extra="ignore")

    name: str
    match: RuleMatch
    reason_code: str | None = None


class ApproverRef(BaseModel):
    model_config = ConfigDict(extra="ignore")

    slack_user: str | None = None
    email: str | None = None


class ApprovalRule(BaseModel):
    model_config = ConfigDict(extra="ignore")

    name: str
    match: RuleMatch
    approvers: list[ApproverRef] = Field(default_factory=list)


class AutonomyPolicy(BaseModel):
    model_config = ConfigDict(extra="ignore")

    version: int = 1
    org_alias: str
    schedules: dict[str, ScheduleConfig] = Field(default_factory=dict)
    defaults: PolicyDefaults = Field(default_factory=PolicyDefaults)
    deny_rules: list[DenyRule] = Field(default_factory=list)
    allow_rules: list[AllowRule] = Field(default_factory=list)
    approval_rules: list[ApprovalRule] = Field(default_factory=list)


@dataclass
class PolicyDecision:
    action: Literal["allow", "deny", "approval_required"]
    decision_path: list[str]
    matched_rule: str | None = None
    budget_remaining: int | None = None


def _ensure_yaml() -> None:
    if yaml is None:  # pragma: no cover - raised only when dependency missing
        raise RuntimeError("PyYAML is required for AutonomyPolicy loading.") from _yaml_import_error


def _canonical_yaml(payload: Any) -> str:
    _ensure_yaml()
    return yaml.safe_dump(
        payload,
        sort_keys=True,
        default_flow_style=False,
        allow_unicode=False,
    )


def load_policy(path: Path) -> AutonomyPolicy:
    _ensure_yaml()
    with open(path, encoding="utf-8") as f:
        data = yaml.safe_load(f) or {}
    return AutonomyPolicy.model_validate(data)


def compute_policy_hash(policy: AutonomyPolicy) -> str:
    payload = policy.model_dump(by_alias=True, exclude_none=True)
    digest = hashlib.sha256(_canonical_yaml(payload).encode("utf-8")).hexdigest()
    return f"sha256:{digest}"


def generate_default_policy(org_alias: str) -> AutonomyPolicy:
    return AutonomyPolicy(
        version=1,
        org_alias=org_alias,
        schedules={
            "nightly": ScheduleConfig(
                cron="0 2 * * *",
                timezone="America/Los_Angeles",
            )
        },
        defaults=PolicyDefaults(
            auto_execute_max_risk_level=0,
            approval_required_min_risk_level_default=1,
            auto_execute_requires_allow_rule_for_writes=True,
            deny_tags=["external_side_effects", "destructive_metadata"],
            max_records_per_run=500,
            max_bulk_batch=200,
            max_steps_per_run=100,
            state_dir="~/.g_gremlin/autopilot",
            receipt_dir="${state_dir}/receipts",
            slack_channel=None,
            approver_roles=["admin", "revops"],
            verify_after_execute=True,
            kill_switches=KillSwitchConfig(
                global_=False,
                by_tag={
                    "bulk_write": False,
                    "metadata_write": False,
                    "external_side_effects": True,
                },
            ),
        ),
        allow_rules=[
            AllowRule(
                name="auto-fix user permissions parity",
                match=RuleMatch(
                    tags=["user_admin"],
                    tools=["sfdc.user.assign_permission_sets"],
                ),
                auto_execute=True,
                max_records=50,
            )
        ],
        approval_rules=[
            ApprovalRule(
                name="writes require approval",
                match=RuleMatch(risk_level_gte=1),
                approvers=[],
            )
        ],
    )


def apply_policy_overlays(policy: AutonomyPolicy, state_dir: Path) -> AutonomyPolicy:
    from .policy_overrides import load_allow_rule_overlays

    overlays = load_allow_rule_overlays(state_dir)
    if not overlays:
        return policy
    return policy.model_copy(update={"allow_rules": policy.allow_rules + overlays})


def _matches_rule(match: RuleMatch, step: PlanStep, tool_def: AutopilotToolDefinition | None) -> bool:
    if match.risk_level_gte is not None and step.risk_level < match.risk_level_gte:
        return False
    if match.tools:
        if step.tool not in match.tools:
            return False
    if match.tags:
        tool_tags = set(tool_def.policy_tags if tool_def else [])
        if not tool_tags.intersection(set(match.tags)):
            return False
    return True


def evaluate_policy(
    policy: AutonomyPolicy,
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> PolicyDecision:
    decision_path: list[str] = []

    env_kill = os.getenv("G_GREMLIN_KILL_SWITCH") == "1"
    global_kill = policy.defaults.kill_switches.global_
    decision_path.append(f"kill_switch:global={'1' if global_kill else '0'}")

    tag_kill = False
    tag_match = None
    tool_tags = set(tool_def.policy_tags if tool_def else [])
    for tag in sorted(policy.defaults.kill_switches.by_tag.keys()):
        active = policy.defaults.kill_switches.by_tag.get(tag, False)
        if tag in tool_tags:
            decision_path.append(f"kill_switch:tag:{tag}={'1' if active else '0'}")
            if active and not tag_kill:
                tag_kill = True
                tag_match = tag
        else:
            decision_path.append(f"kill_switch:tag:{tag}=skip")

    decision_path.append(f"kill_switch:env={'1' if env_kill else '0'}")

    if global_kill:
        decision_path.append("decision:deny:kill_switch:global")
        return PolicyDecision(
            action="deny",
            decision_path=decision_path,
            matched_rule="kill_switch:global",
            budget_remaining=None,
        )
    if tag_kill:
        decision_path.append(f"decision:deny:kill_switch:tag:{tag_match}")
        return PolicyDecision(
            action="deny",
            decision_path=decision_path,
            matched_rule=f"kill_switch:tag:{tag_match}",
            budget_remaining=None,
        )
    if env_kill:
        decision_path.append("decision:deny:kill_switch:env")
        return PolicyDecision(
            action="deny",
            decision_path=decision_path,
            matched_rule="kill_switch:env",
            budget_remaining=None,
        )

    denied_tag = None
    for tag in policy.defaults.deny_tags:
        hit = tag in tool_tags
        decision_path.append(f"deny_tag:{tag}={'1' if hit else '0'}")
        if hit and denied_tag is None:
            denied_tag = tag
    if denied_tag:
        decision_path.append(f"decision:deny:deny_tag:{denied_tag}")
        return PolicyDecision(
            action="deny",
            decision_path=decision_path,
            matched_rule=f"deny_tag:{denied_tag}",
            budget_remaining=None,
        )

    for rule in policy.deny_rules:
        matched = _matches_rule(rule.match, step, tool_def)
        decision_path.append(f"deny_rule:{rule.name}={'match' if matched else 'no_match'}")
        if matched:
            suffix = f":{rule.reason_code}" if rule.reason_code else ""
            decision_path.append(f"decision:deny:{rule.name}{suffix}")
            return PolicyDecision(
                action="deny",
                decision_path=decision_path,
                matched_rule=rule.name,
                budget_remaining=None,
            )

    for rule in policy.allow_rules:
        matched = _matches_rule(rule.match, step, tool_def)
        decision_path.append(
            f"allow_rule:{rule.name}={'match' if matched else 'no_match'}"
            f";auto_execute={'1' if rule.auto_execute else '0'}"
        )
        if matched and rule.auto_execute:
            decision_path.append(f"decision:allow:{rule.name}")
            return PolicyDecision(
                action="allow",
                decision_path=decision_path,
                matched_rule=rule.name,
                budget_remaining=None,
            )

    for rule in policy.approval_rules:
        matched = _matches_rule(rule.match, step, tool_def)
        decision_path.append(f"approval_rule:{rule.name}={'match' if matched else 'no_match'}")
        if matched:
            decision_path.append(f"decision:approval_required:{rule.name}")
            return PolicyDecision(
                action="approval_required",
                decision_path=decision_path,
                matched_rule=rule.name,
                budget_remaining=None,
            )

    if step.risk_level >= policy.defaults.approval_required_min_risk_level_default:
        decision_path.append("decision:approval_required:default_min_risk")
        return PolicyDecision(
            action="approval_required",
            decision_path=decision_path,
            matched_rule="defaults",
            budget_remaining=None,
        )

    if step.risk_level <= policy.defaults.auto_execute_max_risk_level:
        if step.risk_level > 0 and policy.defaults.auto_execute_requires_allow_rule_for_writes:
            decision_path.append("decision:approval_required:default_allowlist_required")
            return PolicyDecision(
                action="approval_required",
                decision_path=decision_path,
                matched_rule="defaults",
                budget_remaining=None,
            )
        decision_path.append("decision:allow:default_auto_execute")
        return PolicyDecision(
            action="allow",
            decision_path=decision_path,
            matched_rule="defaults",
            budget_remaining=None,
        )

    decision_path.append("decision:approval_required:default_fallback")
    return PolicyDecision(
        action="approval_required",
        decision_path=decision_path,
        matched_rule="defaults",
        budget_remaining=None,
    )


__all__ = [
    "AutonomyPolicy",
    "PolicyDefaults",
    "ScheduleConfig",
    "KillSwitchConfig",
    "RuleMatch",
    "DenyRule",
    "AllowRule",
    "ApprovalRule",
    "ApproverRef",
    "PolicyDecision",
    "load_policy",
    "compute_policy_hash",
    "generate_default_policy",
    "apply_policy_overlays",
    "evaluate_policy",
]
