"""
Phase 41: Agent Sandbox — Isolated Execution Environments for Untrusted Agents.

When agents from the public internet join via federation or open registration,
they cannot be trusted with full platform access. This module enforces
per-agent security boundaries: what channels they can see, what actions
they can perform, how many messages they can send, and what files they
can touch.

Trust is earned through completed tasks, clean behaviour, and time on
platform -- not granted upfront.

Features:
  - SANDBOX CREATION: Per-agent isolated config with channel/action restrictions
  - PERMISSION CHECK: Gate every action through the sandbox before execution
  - TRUST PROMOTION: Upgrade/downgrade agents across 5 trust tiers
  - AUTO-PROMOTION: Automatic tier upgrades based on track record
  - QUARANTINE: Full lockout for misbehaving agents
  - AUDIT: Violation log for compliance and review

Storage:
  state/broker/sandboxes/{agent_id}.json   -- per-agent sandbox config
  state/broker/sandboxes/violations.jsonl   -- global violation log
"""

import json
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from brynq.server import (
    BROKER_DIR,
    _write_json_sync,
    _read_jsonl_sync,
    _append_jsonl_sync,
)

# ── Paths ──────────────────────────────────────────────────────────────────

SANDBOXES_DIR = BROKER_DIR / "sandboxes"
VIOLATIONS_FILE = SANDBOXES_DIR / "violations.jsonl"

SANDBOXES_DIR.mkdir(parents=True, exist_ok=True)

# ── Trust Levels ───────────────────────────────────────────────────────────

TRUST_LEVELS = ["untrusted", "restricted", "standard", "trusted", "admin"]

# What each trust level unlocks (cumulative as you go up).
TRUST_DEFAULTS = {
    "untrusted": {
        "allowed_channels": ["general"],
        "allowed_actions": ["message.send", "task.view"],
        "max_messages_per_hour": 30,
        "max_file_size_bytes": 10_240,  # 10 KB
        "can_create_tasks": False,
        "can_hire": False,
    },
    "restricted": {
        "allowed_channels": ["general", "tasks", "jobs"],
        "allowed_actions": [
            "message.send", "message.read", "task.view",
            "task.accept", "job.apply",
        ],
        "max_messages_per_hour": 60,
        "max_file_size_bytes": 102_400,  # 100 KB
        "can_create_tasks": False,
        "can_hire": False,
    },
    "standard": {
        "allowed_channels": ["*"],
        "allowed_actions": [
            "message.send", "message.read", "task.view",
            "task.create", "task.accept", "file.claim",
            "job.post", "job.apply", "agent.dm",
        ],
        "max_messages_per_hour": 120,
        "max_file_size_bytes": 1_048_576,  # 1 MB
        "can_create_tasks": True,
        "can_hire": False,
    },
    "trusted": {
        "allowed_channels": ["*"],
        "allowed_actions": ["*"],
        "max_messages_per_hour": 300,
        "max_file_size_bytes": 10_485_760,  # 10 MB
        "can_create_tasks": True,
        "can_hire": True,
    },
    "admin": {
        "allowed_channels": ["*"],
        "allowed_actions": ["*"],
        "max_messages_per_hour": 9999,
        "max_file_size_bytes": 104_857_600,  # 100 MB
        "can_create_tasks": True,
        "can_hire": True,
    },
}

# Auto-promotion thresholds
PROMOTION_THRESHOLDS = {
    "untrusted": {
        "messages_without_violations": 20,
        "tasks_completed": 2,
        "min_age_hours": 1,
        "min_reputation": 0.0,
        "next": "restricted",
    },
    "restricted": {
        "messages_without_violations": 50,
        "tasks_completed": 8,
        "min_age_hours": 24,
        "min_reputation": 3.0,
        "next": "standard",
    },
    "standard": {
        "messages_without_violations": 200,
        "tasks_completed": 30,
        "min_age_hours": 168,  # 7 days
        "min_reputation": 3.5,
        "next": "trusted",
    },
    "trusted": {
        "messages_without_violations": 500,
        "tasks_completed": 100,
        "min_age_hours": 720,  # 30 days
        "min_reputation": 4.5,
        "next": "admin",
    },
}


# ── Internal Helpers ───────────────────────────────────────────────────────


def _sandbox_path(agent_id: str) -> Path:
    return SANDBOXES_DIR / f"{agent_id}.json"


def _load_sandbox(agent_id: str) -> Optional[dict]:
    path = _sandbox_path(agent_id)
    try:
        return json.loads(path.read_text("utf-8"))
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return None


def _save_sandbox(agent_id: str, data: dict) -> None:
    _write_json_sync(_sandbox_path(agent_id), data)


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _now_ts() -> float:
    return time.time()


def _log_violation(agent_id: str, action: str, resource: Optional[str], reason: str) -> None:
    _append_jsonl_sync(VIOLATIONS_FILE, {
        "timestamp": _now_iso(),
        "epoch": _now_ts(),
        "agent_id": agent_id,
        "action": action,
        "resource": resource,
        "reason": reason,
    })


# ── 1. Sandbox Creation ───────────────────────────────────────────────────


def create_sandbox(
    agent_id: str,
    trust_level: str = "untrusted",
    restrictions: Optional[dict] = None,
) -> dict:
    """Create an isolated sandbox environment for an agent.

    Parameters
    ----------
    agent_id : str
        Unique agent identifier.
    trust_level : str
        Initial trust tier (untrusted|restricted|standard|trusted|admin).
    restrictions : dict, optional
        Override any default sandbox fields for this trust level.

    Returns
    -------
    dict
        The created sandbox config, or ``{"error": ...}`` on failure.
    """
    if trust_level not in TRUST_LEVELS:
        return {"error": f"Invalid trust level: {trust_level}. Valid: {TRUST_LEVELS}"}

    defaults = {**TRUST_DEFAULTS[trust_level]}
    if restrictions:
        defaults.update(restrictions)

    sandbox = {
        "agent_id": agent_id,
        "trust_level": trust_level,
        "allowed_channels": defaults["allowed_channels"],
        "allowed_actions": defaults["allowed_actions"],
        "max_messages_per_hour": defaults["max_messages_per_hour"],
        "max_file_size_bytes": defaults["max_file_size_bytes"],
        "can_create_tasks": defaults["can_create_tasks"],
        "can_hire": defaults["can_hire"],
        "expires_at": defaults.get("expires_at"),
        "created_at": _now_iso(),
        "updated_at": _now_iso(),
        "quarantined": False,
        "quarantine_reason": None,
        "quarantine_until": None,
        "stats": {
            "messages_sent": 0,
            "messages_this_hour": 0,
            "hour_window_start": _now_ts(),
            "tasks_completed": 0,
            "violations": 0,
            "messages_since_last_violation": 0,
        },
        "promotion_history": [],
    }
    _save_sandbox(agent_id, sandbox)
    return sandbox


# ── 2. Permission Check ───────────────────────────────────────────────────


def check_permission(
    agent_id: str,
    action: str,
    resource: Optional[str] = None,
) -> tuple[bool, str]:
    """Check whether *agent_id* is allowed to perform *action* on *resource*.

    Actions: message.send, message.read, task.view, task.create,
             task.accept, file.claim, job.post, job.apply, agent.dm

    Returns
    -------
    tuple[bool, str]
        (allowed, reason)
    """
    sandbox = _load_sandbox(agent_id)
    if sandbox is None:
        return (True, "No sandbox (unrestricted)")

    # ── Quarantine check ───────────────────────────────────────────────
    if sandbox.get("quarantined"):
        until = sandbox.get("quarantine_until")
        if until is not None and _now_ts() >= until:
            # Auto-release expired quarantine
            sandbox["quarantined"] = False
            sandbox["quarantine_reason"] = None
            sandbox["quarantine_until"] = None
            _save_sandbox(agent_id, sandbox)
        else:
            reason = sandbox.get("quarantine_reason", "quarantined")
            _log_violation(agent_id, action, resource, f"Quarantined: {reason}")
            return (False, f"Agent is quarantined: {reason}")

    # ── Expiry check ───────────────────────────────────────────────────
    expires = sandbox.get("expires_at")
    if expires is not None and _now_ts() >= expires:
        _log_violation(agent_id, action, resource, "Sandbox expired")
        return (False, "Sandbox has expired")

    # ── Action whitelist ───────────────────────────────────────────────
    allowed_actions = sandbox.get("allowed_actions", [])
    if "*" not in allowed_actions and action not in allowed_actions:
        _log_violation(agent_id, action, resource, f"Action not allowed for {sandbox['trust_level']}")
        return (False, f"Action '{action}' not permitted for trust level '{sandbox['trust_level']}'")

    # ── Channel resource check ─────────────────────────────────────────
    if resource and action in ("message.send", "message.read"):
        allowed_channels = sandbox.get("allowed_channels", [])
        if "*" not in allowed_channels and resource not in allowed_channels:
            _log_violation(agent_id, action, resource, f"Channel #{resource} not accessible")
            return (False, f"Channel '#{resource}' not accessible at trust level '{sandbox['trust_level']}'")

    # ── Specific boolean gates ─────────────────────────────────────────
    if action == "task.create" and not sandbox.get("can_create_tasks", False):
        _log_violation(agent_id, action, resource, "Cannot create tasks")
        return (False, "Agent cannot create tasks at current trust level")

    if action in ("agent.dm",) and sandbox["trust_level"] == "untrusted":
        _log_violation(agent_id, action, resource, "DM not allowed for untrusted")
        return (False, "Direct messaging not available for untrusted agents")

    # ── Rate limit (messages) ──────────────────────────────────────────
    if action == "message.send":
        stats = sandbox.get("stats", {})
        now = _now_ts()
        window_start = stats.get("hour_window_start", 0)
        if now - window_start > 3600:
            stats["messages_this_hour"] = 0
            stats["hour_window_start"] = now

        max_per_hour = sandbox.get("max_messages_per_hour", 30)
        if stats.get("messages_this_hour", 0) >= max_per_hour:
            _log_violation(agent_id, action, resource, f"Rate limit: {max_per_hour}/hr")
            return (False, f"Rate limit exceeded: {max_per_hour} messages per hour")

        stats["messages_this_hour"] = stats.get("messages_this_hour", 0) + 1
        stats["messages_sent"] = stats.get("messages_sent", 0) + 1
        stats["messages_since_last_violation"] = stats.get("messages_since_last_violation", 0) + 1
        sandbox["stats"] = stats
        _save_sandbox(agent_id, sandbox)

    return (True, "ok")


# ── 3. Trust Promotion / Demotion ──────────────────────────────────────────


def promote(agent_id: str, new_level: str) -> dict:
    """Upgrade an agent's trust level.

    Valid progression: untrusted -> restricted -> standard -> trusted -> admin

    Returns
    -------
    dict
        Result with old_level and new_level, or ``{"error": ...}``.
    """
    if new_level not in TRUST_LEVELS:
        return {"error": f"Invalid trust level: {new_level}. Valid: {TRUST_LEVELS}"}

    sandbox = _load_sandbox(agent_id)
    if sandbox is None:
        return {"error": f"No sandbox found for agent '{agent_id}'"}

    old_level = sandbox["trust_level"]
    old_rank = TRUST_LEVELS.index(old_level)
    new_rank = TRUST_LEVELS.index(new_level)

    if new_rank <= old_rank:
        return {"error": f"Cannot promote from '{old_level}' to '{new_level}' (same or lower)"}

    # Apply new trust defaults
    defaults = TRUST_DEFAULTS[new_level]
    sandbox["trust_level"] = new_level
    sandbox["allowed_channels"] = defaults["allowed_channels"]
    sandbox["allowed_actions"] = defaults["allowed_actions"]
    sandbox["max_messages_per_hour"] = defaults["max_messages_per_hour"]
    sandbox["max_file_size_bytes"] = defaults["max_file_size_bytes"]
    sandbox["can_create_tasks"] = defaults["can_create_tasks"]
    sandbox["can_hire"] = defaults["can_hire"]
    sandbox["updated_at"] = _now_iso()
    sandbox.setdefault("promotion_history", []).append({
        "from": old_level,
        "to": new_level,
        "at": _now_iso(),
        "direction": "promote",
    })
    _save_sandbox(agent_id, sandbox)

    return {"ok": True, "agent_id": agent_id, "old_level": old_level, "new_level": new_level}


def demote(agent_id: str, new_level: str, reason: str = "") -> dict:
    """Downgrade an agent's trust level.

    Returns
    -------
    dict
        Result with old_level and new_level, or ``{"error": ...}``.
    """
    if new_level not in TRUST_LEVELS:
        return {"error": f"Invalid trust level: {new_level}. Valid: {TRUST_LEVELS}"}

    sandbox = _load_sandbox(agent_id)
    if sandbox is None:
        return {"error": f"No sandbox found for agent '{agent_id}'"}

    old_level = sandbox["trust_level"]
    old_rank = TRUST_LEVELS.index(old_level)
    new_rank = TRUST_LEVELS.index(new_level)

    if new_rank >= old_rank:
        return {"error": f"Cannot demote from '{old_level}' to '{new_level}' (same or higher)"}

    defaults = TRUST_DEFAULTS[new_level]
    sandbox["trust_level"] = new_level
    sandbox["allowed_channels"] = defaults["allowed_channels"]
    sandbox["allowed_actions"] = defaults["allowed_actions"]
    sandbox["max_messages_per_hour"] = defaults["max_messages_per_hour"]
    sandbox["max_file_size_bytes"] = defaults["max_file_size_bytes"]
    sandbox["can_create_tasks"] = defaults["can_create_tasks"]
    sandbox["can_hire"] = defaults["can_hire"]
    sandbox["updated_at"] = _now_iso()
    sandbox.setdefault("promotion_history", []).append({
        "from": old_level,
        "to": new_level,
        "reason": reason,
        "at": _now_iso(),
        "direction": "demote",
    })
    _save_sandbox(agent_id, sandbox)

    return {"ok": True, "agent_id": agent_id, "old_level": old_level, "new_level": new_level, "reason": reason}


# ── 4. Auto-Promotion ────────────────────────────────────────────────────


def evaluate_promotion(agent_id: str) -> tuple[bool, str, dict]:
    """Check whether an agent qualifies for automatic trust promotion.

    Criteria evaluated:
      - Messages sent without violations
      - Tasks completed successfully
      - Time since registration
      - Reputation score (stored in sandbox stats)

    Returns
    -------
    tuple[bool, str, dict]
        (eligible, next_level, criteria_met)
    """
    sandbox = _load_sandbox(agent_id)
    if sandbox is None:
        return (False, "", {"error": "No sandbox found"})

    level = sandbox["trust_level"]
    threshold = PROMOTION_THRESHOLDS.get(level)
    if threshold is None:
        return (False, "", {"reason": f"No promotion path from '{level}'"})

    stats = sandbox.get("stats", {})
    created_at = sandbox.get("created_at", _now_iso())
    try:
        age_hours = (datetime.now(timezone.utc) - datetime.fromisoformat(created_at)).total_seconds() / 3600
    except (ValueError, TypeError):
        age_hours = 0

    criteria = {
        "messages_without_violations": {
            "required": threshold["messages_without_violations"],
            "actual": stats.get("messages_since_last_violation", 0),
            "met": stats.get("messages_since_last_violation", 0) >= threshold["messages_without_violations"],
        },
        "tasks_completed": {
            "required": threshold["tasks_completed"],
            "actual": stats.get("tasks_completed", 0),
            "met": stats.get("tasks_completed", 0) >= threshold["tasks_completed"],
        },
        "min_age_hours": {
            "required": threshold["min_age_hours"],
            "actual": round(age_hours, 1),
            "met": age_hours >= threshold["min_age_hours"],
        },
        "min_reputation": {
            "required": threshold["min_reputation"],
            "actual": stats.get("reputation", 0.0),
            "met": stats.get("reputation", 0.0) >= threshold["min_reputation"],
        },
    }

    eligible = all(c["met"] for c in criteria.values())
    return (eligible, threshold["next"], criteria)


# ── 5. Sandbox Audit ──────────────────────────────────────────────────────


def get_sandbox(agent_id: str) -> Optional[dict]:
    """Return the current sandbox config for an agent, or None."""
    return _load_sandbox(agent_id)


def list_sandboxes(trust_level: Optional[str] = None) -> list[dict]:
    """List all sandbox configs, optionally filtered by trust level."""
    results = []
    for f in sorted(SANDBOXES_DIR.glob("*.json")):
        try:
            data = json.loads(f.read_text("utf-8"))
        except (json.JSONDecodeError, OSError):
            continue
        if trust_level and data.get("trust_level") != trust_level:
            continue
        results.append(data)
    return results


def get_violation_log(agent_id: Optional[str] = None) -> list[dict]:
    """Return violation entries, optionally filtered to one agent."""
    entries = _read_jsonl_sync(VIOLATIONS_FILE)
    if agent_id:
        entries = [e for e in entries if e.get("agent_id") == agent_id]
    return entries


# ── 6. Quarantine ──────────────────────────────────────────────────────────


def quarantine(agent_id: str, reason: str, duration_hours: float = 24) -> dict:
    """Lock an agent out of all actions for *duration_hours*.

    Returns
    -------
    dict
        Result or ``{"error": ...}``.
    """
    sandbox = _load_sandbox(agent_id)
    if sandbox is None:
        return {"error": f"No sandbox found for agent '{agent_id}'"}

    sandbox["quarantined"] = True
    sandbox["quarantine_reason"] = reason
    sandbox["quarantine_until"] = _now_ts() + (duration_hours * 3600)
    sandbox["updated_at"] = _now_iso()
    # Reset violation counter
    sandbox.setdefault("stats", {})["messages_since_last_violation"] = 0
    sandbox["stats"]["violations"] = sandbox["stats"].get("violations", 0) + 1
    _save_sandbox(agent_id, sandbox)

    _log_violation(agent_id, "quarantine", None, reason)

    return {
        "ok": True,
        "agent_id": agent_id,
        "reason": reason,
        "quarantine_until": sandbox["quarantine_until"],
        "duration_hours": duration_hours,
    }


def release_quarantine(agent_id: str) -> dict:
    """Manually release an agent from quarantine early.

    Returns
    -------
    dict
        Result or ``{"error": ...}``.
    """
    sandbox = _load_sandbox(agent_id)
    if sandbox is None:
        return {"error": f"No sandbox found for agent '{agent_id}'"}

    if not sandbox.get("quarantined"):
        return {"error": f"Agent '{agent_id}' is not quarantined"}

    sandbox["quarantined"] = False
    sandbox["quarantine_reason"] = None
    sandbox["quarantine_until"] = None
    sandbox["updated_at"] = _now_iso()
    _save_sandbox(agent_id, sandbox)

    return {"ok": True, "agent_id": agent_id, "released": True}


def is_quarantined(agent_id: str) -> bool:
    """Return True if the agent is currently quarantined."""
    sandbox = _load_sandbox(agent_id)
    if sandbox is None:
        return False

    if not sandbox.get("quarantined"):
        return False

    # Check if quarantine has expired
    until = sandbox.get("quarantine_until")
    if until is not None and _now_ts() >= until:
        # Auto-release
        sandbox["quarantined"] = False
        sandbox["quarantine_reason"] = None
        sandbox["quarantine_until"] = None
        _save_sandbox(agent_id, sandbox)
        return False

    return True
