# tests/test_policies.py
import pytest
import yaml
from pydantic import ValidationError

from agentsh_secure_sandbox.policies.schema import (
    PolicyDefinition,
    FileAllowRule,
    FileDenyRule,
    FileSoftDeleteRule,
    NetworkAllowRule,
    NetworkAllowCidrsRule,
    NetworkDenyCidrsRule,
    CommandRedirectRule,
    CommandRedirectTarget,
    EnvRule,
    EnvPolicy,
    SignalRule,
    SignalTarget,
    UnixSocketRule,
    ResourceLimits,
    AuditSettings,
    DnsRedirect,
    ConnectRedirect,
    validate_policy,
    PackageRule,
    PackageMatch,
    LicenseSpdxMatch,
)
from agentsh_secure_sandbox.policies.serialize import serialize_policy, system_policy_yaml
from agentsh_secure_sandbox.policies.merge import merge, merge_prepend
from agentsh_secure_sandbox.policies.presets import (
    agent_default,
    dev_safe,
    ci_strict,
    agent_sandbox,
)


def test_empty_policy():
    p = PolicyDefinition()
    assert p.file is None
    assert p.network is None


def test_file_allow_rule():
    r = FileAllowRule(allow="/workspace/**", ops=["read", "write"])
    assert r.allow == "/workspace/**"


def test_file_deny_rule_list():
    r = FileDenyRule(deny=["**/.env", "**/*.key"])
    assert len(r.deny) == 2


def test_network_allow_with_ports():
    r = NetworkAllowRule(allow="pypi.org", ports=[443])
    assert r.ports == [443]


def test_network_port_validation():
    with pytest.raises(ValidationError):
        NetworkAllowRule(allow="x", ports=[0])
    with pytest.raises(ValidationError):
        NetworkAllowRule(allow="x", ports=[70000])


def test_command_redirect_with_target():
    target = CommandRedirectTarget(cmd="agentsh-fetch", args=["--audit"])
    r = CommandRedirectRule(redirect="curl", to=target)
    assert r.to.cmd == "agentsh-fetch"


def test_command_redirect_with_string():
    r = CommandRedirectRule(redirect="curl", to="safe-curl")
    assert r.to == "safe-curl"


def test_extra_fields_rejected():
    with pytest.raises(ValidationError):
        FileAllowRule(allow="/x", bogus="nope")  # type: ignore[call-arg]


def test_policy_from_dict():
    p = PolicyDefinition(
        file=[{"allow": "/workspace/**"}],
        network=[{"deny": "*"}],
    )
    assert len(p.file) == 1
    assert len(p.network) == 1


def test_validate_policy_dict():
    p = validate_policy({"file": [{"allow": "/tmp"}]})
    assert isinstance(p, PolicyDefinition)


def test_validate_policy_passthrough():
    p = PolicyDefinition()
    assert validate_policy(p) is p


def test_env_rule():
    r = EnvRule(commands=["node"], allow=["PATH", "HOME"])
    assert r.commands == ["node"]


def test_dns_redirect():
    r = DnsRedirect(match="evil.com", resolve_to="127.0.0.1")
    assert r.resolve_to == "127.0.0.1"


def test_connect_redirect():
    r = ConnectRedirect(match="evil.com:443", redirect_to="proxy:8080")
    assert r.redirect_to == "proxy:8080"


# ─── Serialization tests ────────────────────────────────


def test_serialize_empty_policy():
    p = PolicyDefinition()
    result = serialize_policy(p)
    doc = yaml.safe_load(result)
    assert doc["version"] == 1
    assert doc["name"] == "secure-sandbox-policy"
    assert "file_rules" not in doc


def test_serialize_file_allow():
    p = PolicyDefinition(file=[{"allow": "/workspace/**", "ops": ["read", "write"]}])
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["file_rules"][0]["paths"] == ["/workspace/**"]
    assert doc["file_rules"][0]["decision"] == "allow"
    assert doc["file_rules"][0]["operations"] == ["read", "write"]


def test_serialize_file_deny():
    p = PolicyDefinition(file=[{"deny": ["**/.env", "**/*.key"]}])
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["file_rules"][0]["decision"] == "deny"
    assert len(doc["file_rules"][0]["paths"]) == 2


def test_serialize_network_allow_with_ports():
    p = PolicyDefinition(network=[{"allow": "pypi.org", "ports": [443]}])
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["network_rules"][0]["domains"] == ["pypi.org"]
    assert doc["network_rules"][0]["ports"] == [443]


def test_serialize_network_deny():
    p = PolicyDefinition(network=[{"deny": "*"}])
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["network_rules"][0]["decision"] == "deny"


def test_serialize_command_redirect_target():
    p = PolicyDefinition(
        commands=[
            {
                "redirect": "curl",
                "to": {"cmd": "agentsh-fetch", "args": ["--audit"]},
            }
        ]
    )
    doc = yaml.safe_load(serialize_policy(p))
    rule = doc["command_rules"][0]
    assert rule["decision"] == "redirect"
    assert rule["redirect_to"]["command"] == "agentsh-fetch"


def test_serialize_command_redirect_string():
    p = PolicyDefinition(commands=[{"redirect": "curl", "to": "safe-curl"}])
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["command_rules"][0]["redirect_to"] == "safe-curl"


def test_serialize_env_rules():
    p = PolicyDefinition(env=[{"commands": ["node"], "allow": ["PATH"]}])
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["env_rules"][0]["commands"] == ["node"]


def test_serialize_dns_redirects():
    p = PolicyDefinition(dns=[{"match": "evil.com", "resolve_to": "127.0.0.1"}])
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["dns_redirects"][0]["resolve_to"] == "127.0.0.1"


def test_system_policy_yaml():
    doc = yaml.safe_load(system_policy_yaml())
    assert doc["name"] == "_system-protection"
    assert len(doc["file_rules"]) == 3
    assert len(doc["command_rules"]) == 1
    # Verify self-protection
    assert doc["file_rules"][0]["decision"] == "deny"
    assert "/etc/agentsh/**" in doc["file_rules"][0]["paths"]


# ─── Merge tests ────────────────────────────────────────


def test_merge_append():
    base = PolicyDefinition(file=[{"allow": "/workspace/**"}])
    ext = PolicyDefinition(file=[{"allow": "/data/**"}])
    result = merge(base, ext)
    assert len(result.file) == 2
    # Base rule first (first-match-wins: base takes priority)
    assert result.file[0].allow == "/workspace/**"
    assert result.file[1].allow == "/data/**"


def test_merge_prepend():
    base = PolicyDefinition(file=[{"deny": "**/.env"}])
    override = PolicyDefinition(file=[{"allow": "**/.env", "ops": ["read"]}])
    result = merge_prepend(base, override)
    assert len(result.file) == 2
    # Override first (overrides take priority)
    assert result.file[0].allow == "**/.env"


def test_merge_multiple_categories():
    base = PolicyDefinition(
        file=[{"allow": "/a"}],
        network=[{"deny": "*"}],
    )
    ext = PolicyDefinition(
        file=[{"allow": "/b"}],
        commands=[{"allow": "ls"}],
    )
    result = merge(base, ext)
    assert len(result.file) == 2
    assert len(result.network) == 1
    assert len(result.commands) == 1


def test_merge_no_overrides():
    base = PolicyDefinition(file=[{"allow": "/a"}])
    result = merge(base)
    assert len(result.file) == 1


def test_merge_validates_result():
    """Merged result goes through Pydantic validation."""
    base = PolicyDefinition(file=[{"allow": "/a"}])
    ext = PolicyDefinition(file=[{"allow": "/b"}])
    result = merge(base, ext)
    assert isinstance(result, PolicyDefinition)


# ─── Preset tests ───────────────────────────────────────


def test_agent_default_has_workspace_allow():
    p = agent_default()
    allows = [
        r
        for r in p.file
        if hasattr(r, "allow")
        and "/workspace/**" in (r.allow if isinstance(r.allow, list) else [r.allow])
    ]
    assert len(allows) == 1


def test_agent_default_denies_env_files():
    p = agent_default()
    denies = [r for r in p.file if hasattr(r, "deny")]
    paths = []
    for r in denies:
        d = r.deny if isinstance(r.deny, list) else [r.deny]
        paths.extend(d)
    assert "**/.env" in paths


def test_agent_default_network_deny_all():
    p = agent_default()
    last = p.network[-1]
    assert hasattr(last, "deny")
    assert last.deny == "*"


def test_agent_default_with_extensions():
    ext = PolicyDefinition(network=[{"allow": "api.stripe.com", "ports": [443]}])
    p = agent_default(extensions=ext)
    # Base network rules + extension (agentDefault has 14 network rules)
    assert len(p.network) > 3


def test_agent_default_workspace_allows_create():
    """Workspace allow includes comprehensive ops including create."""
    p = agent_default()
    ws_rules = [
        r
        for r in p.file
        if hasattr(r, "allow")
        and "/workspace/**" in (r.allow if isinstance(r.allow, list) else [r.allow])
    ]
    assert len(ws_rules) == 1
    assert "read" in ws_rules[0].ops
    assert "write" in ws_rules[0].ops
    assert "create" in ws_rules[0].ops


def test_dev_safe_allows_npm_registry():
    p = dev_safe()
    allows = [r for r in p.network if hasattr(r, "allow")]
    domains = []
    for r in allows:
        d = r.allow if isinstance(r.allow, list) else [r.allow]
        domains.extend(d)
    assert "registry.npmjs.org" in domains


def test_ci_strict_allows_all_workspace_ops():
    p = ci_strict()
    ws_rules = [
        r
        for r in p.file
        if hasattr(r, "allow")
        and "/workspace/**" in (r.allow if isinstance(r.allow, list) else [r.allow])
    ]
    assert len(ws_rules) == 1
    # ci_strict allows all ops on workspace (no ops filter)
    assert ws_rules[0].ops is None


def test_agent_sandbox_no_network():
    p = agent_sandbox()
    assert len(p.network) == 1
    assert p.network[0].deny == "*"


def test_agent_sandbox_read_only():
    p = agent_sandbox()
    ws_rules = [
        r
        for r in p.file
        if hasattr(r, "allow")
        and "/workspace/**" in (r.allow if isinstance(r.allow, list) else [r.allow])
    ]
    assert len(ws_rules) == 1
    assert ws_rules[0].ops == ["read"]


def test_agent_sandbox_denies_all_other_files():
    p = agent_sandbox()
    deny_rules = [r for r in p.file if hasattr(r, "deny")]
    deny_paths = []
    for r in deny_rules:
        d = r.deny if isinstance(r.deny, list) else [r.deny]
        deny_paths.extend(d)
    assert "/**" in deny_paths


# ─── Package rule tests ─────────────────────────────────


def test_package_rule():
    r = PackageRule(
        match=PackageMatch(finding_type="malware"),
        action="block",
        reason="Known malware",
    )
    assert r.action == "block"


def test_package_match_with_license():
    m = PackageMatch(license_spdx=LicenseSpdxMatch(deny=["GPL-3.0"]))
    assert m.license_spdx.deny == ["GPL-3.0"]


def test_policy_with_package_rules():
    p = PolicyDefinition(
        package_rules=[{
            "match": {"finding_type": "vulnerability", "severity": "critical"},
            "action": "block",
        }]
    )
    assert len(p.package_rules) == 1


def test_serialize_package_rules():
    p = PolicyDefinition(
        package_rules=[{
            "match": {
                "finding_type": "malware",
                "name_patterns": ["evil-*"],
                "ecosystem": "npm",
            },
            "action": "block",
            "reason": "Known malware package",
        }]
    )
    doc = yaml.safe_load(serialize_policy(p))
    rule = doc["package_rules"][0]
    assert rule["match"]["finding_type"] == "malware"
    assert rule["match"]["name_patterns"] == ["evil-*"]
    assert rule["action"] == "block"
    assert rule["reason"] == "Known malware package"


def test_merge_includes_package_rules():
    base = PolicyDefinition(package_rules=[{
        "match": {"finding_type": "malware"},
        "action": "block",
    }])
    ext = PolicyDefinition(package_rules=[{
        "match": {"severity": "critical"},
        "action": "warn",
    }])
    result = merge(base, ext)
    assert len(result.package_rules) == 2


# ─── CIDR network rule tests ──────────────────────────


def test_network_allow_cidrs_rule():
    r = NetworkAllowCidrsRule(allow_cidrs=["127.0.0.1/32", "::1/128"])
    assert len(r.allow_cidrs) == 2


def test_network_deny_cidrs_rule():
    r = NetworkDenyCidrsRule(deny_cidrs=["169.254.169.254/32"])
    assert r.deny_cidrs == ["169.254.169.254/32"]


def test_serialize_allow_cidrs():
    p = PolicyDefinition(network=[{"allow_cidrs": ["127.0.0.1/32"], "ports": [443]}])
    doc = yaml.safe_load(serialize_policy(p))
    rule = doc["network_rules"][0]
    assert rule["cidrs"] == ["127.0.0.1/32"]
    assert rule["decision"] == "allow"
    assert rule["ports"] == [443]


def test_serialize_deny_cidrs():
    p = PolicyDefinition(network=[{"deny_cidrs": ["10.0.0.0/8"]}])
    doc = yaml.safe_load(serialize_policy(p))
    rule = doc["network_rules"][0]
    assert rule["cidrs"] == ["10.0.0.0/8"]
    assert rule["decision"] == "deny"


# ─── EnvPolicy tests ──────────────────────────────────


def test_env_policy():
    ep = EnvPolicy(deny=["*_SECRET*"], block_iteration=True)
    assert ep.block_iteration is True
    assert ep.deny == ["*_SECRET*"]


def test_serialize_env_policy():
    p = PolicyDefinition(env_policy=EnvPolicy(
        deny=["*_SECRET*"],
        block_iteration=True,
        max_bytes=1024,
    ))
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["env_policy"]["deny"] == ["*_SECRET*"]
    assert doc["env_policy"]["block_iteration"] is True
    assert doc["env_policy"]["max_bytes"] == 1024


# ─── Signal rule tests ────────────────────────────────


def test_signal_rule():
    r = SignalRule(
        name="allow-self",
        signals=["@all"],
        target=SignalTarget(type="self"),
        decision="allow",
    )
    assert r.decision == "allow"


def test_signal_rule_with_fallback():
    r = SignalRule(
        name="deny-external",
        signals=["@fatal"],
        target=SignalTarget(type="external"),
        decision="deny",
        fallback="audit",
        message="Blocked",
    )
    assert r.fallback == "audit"


def test_serialize_signal_rules():
    p = PolicyDefinition(signal_rules=[
        {"name": "allow-self", "signals": ["@all"], "target": {"type": "self"}, "decision": "allow"},
        {"name": "deny-ext", "signals": ["@fatal"], "target": {"type": "external"}, "decision": "deny", "fallback": "audit"},
    ])
    doc = yaml.safe_load(serialize_policy(p))
    assert len(doc["signal_rules"]) == 2
    assert doc["signal_rules"][0]["name"] == "allow-self"
    assert doc["signal_rules"][0]["target"]["type"] == "self"
    assert doc["signal_rules"][1]["fallback"] == "audit"


# ─── Unix socket rule tests ───────────────────────────


def test_unix_socket_rule():
    r = UnixSocketRule(
        name="allow-docker",
        paths=["/var/run/docker.sock"],
        operations=["connect"],
        decision="allow",
    )
    assert r.decision == "allow"


def test_serialize_unix_socket_rules():
    p = PolicyDefinition(unix_socket_rules=[
        {"name": "deny-sys", "paths": ["/var/run/**"], "operations": ["connect", "bind"], "decision": "deny"},
    ])
    doc = yaml.safe_load(serialize_policy(p))
    assert len(doc["unix_socket_rules"]) == 1
    assert doc["unix_socket_rules"][0]["paths"] == ["/var/run/**"]
    assert doc["unix_socket_rules"][0]["decision"] == "deny"


# ─── Resource limits tests ────────────────────────────


def test_resource_limits():
    rl = ResourceLimits(max_memory_mb=8192, pids_max=500, command_timeout="15m")
    assert rl.max_memory_mb == 8192


def test_serialize_resource_limits():
    p = PolicyDefinition(resource_limits=ResourceLimits(
        max_memory_mb=4096, cpu_quota_percent=100, session_timeout="12h",
    ))
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["resource_limits"]["max_memory_mb"] == 4096
    assert doc["resource_limits"]["cpu_quota_percent"] == 100
    assert doc["resource_limits"]["session_timeout"] == "12h"


# ─── Audit settings tests ────────────────────────────


def test_audit_settings():
    a = AuditSettings(log_denied=True, include_stderr=True)
    assert a.log_denied is True


def test_serialize_audit_settings():
    p = PolicyDefinition(audit_settings=AuditSettings(
        log_allowed=False, log_denied=True, include_stderr=True,
    ))
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["audit"]["log_allowed"] is False
    assert doc["audit"]["log_denied"] is True
    assert doc["audit"]["include_stderr"] is True


# ─── Soft delete rule tests ───────────────────────────


def test_serialize_soft_delete():
    p = PolicyDefinition(file=[FileSoftDeleteRule(soft_delete="/workspace/**")])
    doc = yaml.safe_load(serialize_policy(p))
    assert doc["file_rules"][0]["decision"] == "soft_delete"
    assert doc["file_rules"][0]["paths"] == ["/workspace/**"]


# ─── Merge object-category tests ───���──────────────────


def test_merge_env_policy_last_write_wins():
    base = PolicyDefinition(env_policy=EnvPolicy(deny=["*_SECRET*"], max_bytes=1024))
    ext = PolicyDefinition(env_policy=EnvPolicy(deny=["*_KEY*"], block_iteration=True))
    result = merge(base, ext)
    # Object merge: override wins for present keys
    assert result.env_policy.deny == ["*_KEY*"]
    assert result.env_policy.block_iteration is True
    # max_bytes from base is preserved (not in override)
    assert result.env_policy.max_bytes == 1024


def test_merge_resource_limits():
    base = PolicyDefinition(resource_limits=ResourceLimits(max_memory_mb=8192, pids_max=500))
    ext = PolicyDefinition(resource_limits=ResourceLimits(max_memory_mb=4096))
    result = merge(base, ext)
    assert result.resource_limits.max_memory_mb == 4096
    assert result.resource_limits.pids_max == 500


def test_merge_signal_rules_as_array():
    base = PolicyDefinition(signal_rules=[
        {"name": "a", "signals": ["@all"], "target": {"type": "self"}, "decision": "allow"},
    ])
    ext = PolicyDefinition(signal_rules=[
        {"name": "b", "signals": ["@fatal"], "target": {"type": "external"}, "decision": "deny"},
    ])
    result = merge(base, ext)
    assert len(result.signal_rules) == 2
    assert result.signal_rules[0].name == "a"
    assert result.signal_rules[1].name == "b"


# ─── Preset new-feature tests ─────────────────────────


def test_agent_default_has_env_policy():
    p = agent_default()
    assert p.env_policy is not None
    assert "*_SECRET*" in p.env_policy.deny
    assert p.env_policy.block_iteration is True


def test_agent_default_has_signal_rules():
    p = agent_default()
    assert p.signal_rules is not None
    assert len(p.signal_rules) == 6
    names = [r.name for r in p.signal_rules]
    assert "allow-self" in names
    assert "deny-system" in names


def test_agent_default_has_unix_socket_rules():
    p = agent_default()
    assert p.unix_socket_rules is not None
    assert len(p.unix_socket_rules) == 2


def test_agent_default_has_resource_limits():
    p = agent_default()
    assert p.resource_limits is not None
    assert p.resource_limits.max_memory_mb == 8192
    assert p.resource_limits.pids_max == 500
    assert p.resource_limits.command_timeout == "15m"


def test_agent_default_has_audit_settings():
    p = agent_default()
    assert p.audit_settings is not None
    assert p.audit_settings.log_denied is True
    assert p.audit_settings.log_allowed is False


def test_agent_default_has_cidr_rules():
    """agent_default() includes CIDR rules for localhost, metadata, and private nets."""
    p = agent_default()
    cidr_allow = [r for r in p.network if isinstance(r, NetworkAllowCidrsRule)]
    cidr_deny = [r for r in p.network if isinstance(r, NetworkDenyCidrsRule)]
    assert len(cidr_allow) == 1  # localhost
    assert len(cidr_deny) == 2  # metadata + private nets


def test_agent_default_has_package_rules():
    p = agent_default()
    assert p.package_rules is not None
    assert len(p.package_rules) == 6
