"""
Pending skill proposal store.

Manages JSON files under ~/.mugi/evolution/pending_skills/ for skill
optimization proposals awaiting human review.  Each proposal is a single
JSON file named <uuid>.json with atomic write semantics.

Thread-safe: all mutations go through _STORE_LOCK.
"""
from __future__ import annotations

import hashlib
import json
import os
import tempfile
import threading
import uuid
from datetime import datetime, timezone

_EVOLUTION_DIR = os.path.join(os.path.expanduser("~"), ".mugi", "evolution")
PENDING_SKILLS_DIR = os.path.join(_EVOLUTION_DIR, "pending_skills")
_DIR_MODE = 0o700
_FILE_MODE = 0o600

_STORE_LOCK = threading.Lock()

SCHEMA_VERSION = 1
MAX_PROPOSAL_FILE_SIZE = 512_000  # 512 KB
MAX_PROPOSED_CONTENT_LENGTH = 200_000  # chars


def _log(msg: str, level: str = "WARNING") -> None:
    try:
        from tools._logging import _debug_log
        _debug_log(f"[evolution/pending_store] {msg}", level)
    except Exception:
        pass


def _ensure_dir() -> str:
    os.makedirs(PENDING_SKILLS_DIR, mode=_DIR_MODE, exist_ok=True)
    return PENDING_SKILLS_DIR


def _is_valid_uuid(s: str) -> bool:
    try:
        uuid.UUID(s)
        return True
    except (ValueError, AttributeError):
        return False


def _validate_skill_name(name: str) -> tuple[bool, str]:
    """Return (valid, error) for a proposal skill_name."""
    if not name or not isinstance(name, str):
        return False, "skill_name is required"
    if ".." in name or "/" in name or "\\" in name:
        return False, "skill_name contains invalid characters"
    try:
        from tools.skills_tools import _normalize_name
        normalized = _normalize_name(name)
    except ImportError:
        normalized = name.strip().lower()
    if not normalized:
        return False, "skill_name normalizes to empty"
    return True, ""


def sha256_of(content: str) -> str:
    return hashlib.sha256(content.encode("utf-8")).hexdigest()


def _atomic_write_json(path: str, data: dict) -> None:
    """Write JSON atomically: mkstemp in same dir, fsync, os.replace."""
    _ensure_dir()
    parent = os.path.dirname(path)
    fd, tmp = tempfile.mkstemp(dir=parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(data, f, indent=2, default=str)
            f.flush()
            os.fsync(f.fileno())
        os.chmod(tmp, _FILE_MODE)
        os.replace(tmp, path)
    except Exception:
        try:
            os.unlink(tmp)
        except OSError:
            pass
        raise


def _read_proposal(path: str) -> dict | None:
    """Read a single proposal JSON, returning None on corrupt/oversized files."""
    try:
        size = os.path.getsize(path)
        if size > MAX_PROPOSAL_FILE_SIZE:
            _log(f"Skipping oversized proposal: {path} ({size} bytes)")
            return None
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        if not isinstance(data, dict) or "id" not in data:
            _log(f"Skipping malformed proposal: {path}")
            return None
        return data
    except (json.JSONDecodeError, OSError) as exc:
        _log(f"Skipping corrupt proposal {path}: {exc}")
        return None


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def list_pending() -> list[dict]:
    """Return all proposals (any status), newest first. Skips corrupt files."""
    _ensure_dir()
    proposals = []
    for fname in os.listdir(PENDING_SKILLS_DIR):
        if not fname.endswith(".json"):
            continue
        path = os.path.join(PENDING_SKILLS_DIR, fname)
        data = _read_proposal(path)
        if data is not None:
            proposals.append(data)
    proposals.sort(key=lambda p: p.get("created_at", ""), reverse=True)
    return proposals


def list_proposals_by_status(status: str = "pending") -> list[dict]:
    """Return proposals filtered by status."""
    return [p for p in list_pending() if p.get("status") == status]


def get_proposal(proposal_id: str) -> dict | None:
    """Read a single proposal by UUID. Returns None if missing or corrupt."""
    if not _is_valid_uuid(proposal_id):
        return None
    path = os.path.join(PENDING_SKILLS_DIR, f"{proposal_id}.json")
    if not os.path.isfile(path):
        return None
    return _read_proposal(path)


def has_pending_for_skill(skill_name: str) -> bool:
    """Return True if a pending proposal already exists for skill_name."""
    for p in list_proposals_by_status("pending"):
        if p.get("skill_name") == skill_name:
            return True
    return False


def enqueue_proposal(
    skill_name: str,
    proposed_content: str,
    baseline_sha256: str,
    metrics: dict | None = None,
) -> dict:
    """Create a new pending proposal atomically.

    Enforces one-pending-per-skill and content length limits.
    Returns the created proposal dict.
    Raises ValueError on validation failure.
    """
    valid, err = _validate_skill_name(skill_name)
    if not valid:
        raise ValueError(err)
    if len(proposed_content) > MAX_PROPOSED_CONTENT_LENGTH:
        raise ValueError(f"proposed_content too large ({len(proposed_content)} chars)")

    with _STORE_LOCK:
        if has_pending_for_skill(skill_name):
            raise ValueError(f"A pending proposal already exists for skill '{skill_name}'")

        from core.config import get_text_evolution_config
        te_cfg = get_text_evolution_config()
        max_pending = te_cfg.get("max_pending_proposals", 10)
        current_pending = list_proposals_by_status("pending")
        if len(current_pending) >= max_pending:
            raise ValueError(f"Maximum pending proposals ({max_pending}) reached")

        proposal_id = str(uuid.uuid4())
        proposal = {
            "schema_version": SCHEMA_VERSION,
            "id": proposal_id,
            "skill_name": skill_name,
            "status": "pending",
            "created_at": datetime.now(timezone.utc).isoformat(),
            "baseline_sha256": baseline_sha256,
            "proposed_content": proposed_content,
            "metrics": metrics or {},
        }
        path = os.path.join(PENDING_SKILLS_DIR, f"{proposal_id}.json")
        _atomic_write_json(path, proposal)
        _log(f"Enqueued proposal {proposal_id} for skill '{skill_name}'", "INFO")
        return proposal


def accept_proposal(proposal_id: str) -> dict:
    """Mark proposal as accepted and write to the user skill path.

    Returns a result dict with file_updated, git_committed, git_error fields.
    Idempotent: accepting an already-accepted proposal returns stable JSON.
    Raises ValueError on bad id or status conflict.
    """
    if not _is_valid_uuid(proposal_id):
        raise ValueError("Invalid proposal ID")

    with _STORE_LOCK:
        proposal = get_proposal(proposal_id)
        if proposal is None:
            raise ValueError("Proposal not found")

        if proposal["status"] == "accepted":
            return {"already_accepted": True, "file_updated": True}
        if proposal["status"] != "pending":
            raise ValueError(f"Cannot accept proposal in '{proposal['status']}' state")

        skill_name = proposal["skill_name"]
        proposed_content = proposal["proposed_content"]
        baseline_sha256 = proposal["baseline_sha256"]

        # Re-validate baseline hasn't changed
        current_sha = _read_current_skill_sha(skill_name)
        if current_sha and current_sha != baseline_sha256:
            raise ValueError("Baseline changed since proposal was created (conflict)")

        # Write to user skill path
        file_updated = False
        try:
            from tools.skills_tools import _normalize_name, _safe_skill_path, _get_skills_base
            norm_name = _normalize_name(skill_name)
            skill_dir = os.path.join(_get_skills_base(), norm_name)
            os.makedirs(skill_dir, mode=_DIR_MODE, exist_ok=True)
            skill_path = os.path.join(skill_dir, "SKILL.md")
            from utils.path_safety import secure_write_file
            secure_write_file(skill_path, proposed_content, mode=_FILE_MODE)
            file_updated = True
        except Exception as exc:
            raise ValueError(f"Failed to write skill file: {exc}")

        # Attempt git commit
        git_committed = False
        git_error = ""
        try:
            git_committed, git_error = _try_git_commit_skill(norm_name)
        except Exception as exc:
            git_error = str(exc)

        # Update proposal status
        proposal["status"] = "accepted"
        proposal["accepted_at"] = datetime.now(timezone.utc).isoformat()
        path = os.path.join(PENDING_SKILLS_DIR, f"{proposal_id}.json")
        _atomic_write_json(path, proposal)

        return {
            "file_updated": file_updated,
            "git_committed": git_committed,
            "git_error": git_error,
        }


def reject_proposal(proposal_id: str) -> dict:
    """Mark proposal as rejected. Idempotent."""
    if not _is_valid_uuid(proposal_id):
        raise ValueError("Invalid proposal ID")

    with _STORE_LOCK:
        proposal = get_proposal(proposal_id)
        if proposal is None:
            raise ValueError("Proposal not found")

        if proposal["status"] == "rejected":
            return {"already_rejected": True}
        if proposal["status"] != "pending":
            raise ValueError(f"Cannot reject proposal in '{proposal['status']}' state")

        proposal["status"] = "rejected"
        proposal["rejected_at"] = datetime.now(timezone.utc).isoformat()
        path = os.path.join(PENDING_SKILLS_DIR, f"{proposal_id}.json")
        _atomic_write_json(path, proposal)
        return {"rejected": True}


def reject_all_pending() -> int:
    """Reject all pending proposals. Returns count rejected."""
    count = 0
    with _STORE_LOCK:
        for p in list_proposals_by_status("pending"):
            try:
                p["status"] = "rejected"
                p["rejected_at"] = datetime.now(timezone.utc).isoformat()
                path = os.path.join(PENDING_SKILLS_DIR, f"{p['id']}.json")
                _atomic_write_json(path, p)
                count += 1
            except Exception as exc:
                _log(f"Failed to reject {p.get('id')}: {exc}", "ERROR")
    return count


def reject_pending_for_skill(skill_name: str, reason: str = "") -> int:
    """Reject pending proposals for one skill name. Returns count rejected."""
    target = (skill_name or "").strip()
    if not target:
        return 0
    count = 0
    with _STORE_LOCK:
        for p in list_proposals_by_status("pending"):
            if str(p.get("skill_name") or "").strip() != target:
                continue
            try:
                p["status"] = "rejected"
                p["rejected_at"] = datetime.now(timezone.utc).isoformat()
                if reason:
                    p["rejected_reason"] = reason[:500]
                path = os.path.join(PENDING_SKILLS_DIR, f"{p['id']}.json")
                _atomic_write_json(path, p)
                count += 1
            except Exception as exc:
                _log(f"Failed to reject {p.get('id')} for skill '{target}': {exc}", "ERROR")
    return count


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _read_current_skill_sha(skill_name: str) -> str | None:
    """Return SHA-256 of current SKILL.md on disk, or None if missing."""
    try:
        from tools.skills_tools import _normalize_name, _safe_skill_path
        norm = _normalize_name(skill_name)
        path, err = _safe_skill_path(norm, "SKILL.md")
        if not path or err or not os.path.isfile(path):
            return None
        with open(path, "r", encoding="utf-8") as f:
            return sha256_of(f.read())
    except Exception:
        return None


def _try_git_commit_skill(skill_name: str) -> tuple[bool, str]:
    """Attempt to git-add and commit the skill file. Returns (success, error)."""
    import subprocess
    try:
        from persistence import get_workspace_path
        workspace = get_workspace_path()
        if not workspace:
            return False, "no workspace"
        git_dir = os.path.join(workspace, ".git")
        if not os.path.isdir(git_dir):
            return False, "workspace not a git repo"
        skill_rel = os.path.join("skills", skill_name, "SKILL.md")
        subprocess.run(
            ["git", "add", skill_rel],
            cwd=workspace, capture_output=True, timeout=10,
        )
        result = subprocess.run(
            ["git", "commit", "-m", f"Accept evolved skill: {skill_name}"],
            cwd=workspace, capture_output=True, text=True, timeout=15,
        )
        if result.returncode == 0:
            return True, ""
        return False, (result.stderr or result.stdout or "unknown git error").strip()
    except Exception as exc:
        return False, str(exc)


def is_baseline_stale(proposal_id: str) -> bool | None:
    """Return True if current skill SHA differs from proposal baseline. None if unknown."""
    proposal = get_proposal(proposal_id)
    if proposal is None:
        return None
    current = _read_current_skill_sha(proposal["skill_name"])
    if current is None:
        return False  # new skill, no baseline to conflict with
    return current != proposal["baseline_sha256"]
