
"""Boris state management - data models and persistence."""
import json
import os
import tempfile
import time
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import Optional

import config


def archive_current_run(project_dir: str) -> Optional[str]:
    """Archive the current state.json and plan.md into .boris/runs/{timestamp}/.

    Called before starting a new Boris run so previous runs are preserved.
    Returns the archive directory path, or None if there was nothing to archive.
    """
    state_path = os.path.join(project_dir, config.STATE_DIR, config.STATE_FILE)
    if not os.path.exists(state_path):
        return None

    # Read the existing state to get a meaningful timestamp
    try:
        with open(state_path, "r", encoding="utf-8") as f:
            data = json.load(f)
        started = data.get("started_at", "")
        # Use started_at timestamp for the folder name (sanitize for filesystem)
        if started:
            # Convert ISO format to a filesystem-safe name
            run_id = started.replace(":", "-").replace("T", "_")[:19]
        else:
            run_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    except (json.JSONDecodeError, OSError):
        run_id = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")

    runs_dir = os.path.join(project_dir, config.STATE_DIR, "runs")
    archive_dir = os.path.join(runs_dir, run_id)
    os.makedirs(archive_dir, exist_ok=True)

    # Move state.json
    archive_state = os.path.join(archive_dir, config.STATE_FILE)
    try:
        import shutil
        shutil.copy2(state_path, archive_state)
    except OSError:
        pass

    # Move plan.md
    plan_path = os.path.join(project_dir, config.STATE_DIR, "plan.md")
    if os.path.exists(plan_path):
        archive_plan = os.path.join(archive_dir, "plan.md")
        try:
            shutil.copy2(plan_path, archive_plan)
        except OSError:
            pass

    # Move plan_diagram.md
    diagram_path = os.path.join(project_dir, config.STATE_DIR, "plan_diagram.md")
    if os.path.exists(diagram_path):
        archive_diagram = os.path.join(archive_dir, "plan_diagram.md")
        try:
            shutil.copy2(diagram_path, archive_diagram)
        except OSError:
            pass

    # Move progress.txt if it exists
    progress_path = os.path.join(project_dir, config.STATE_DIR, "progress.txt")
    if os.path.exists(progress_path):
        archive_progress = os.path.join(archive_dir, "progress.txt")
        try:
            shutil.copy2(progress_path, archive_progress)
        except OSError:
            pass

    return archive_dir


def list_runs(project_dir: str) -> list[dict]:
    """List all archived Boris runs in .boris/runs/.

    Returns a list of dicts with keys: run_id, started_at, task, milestones, status.
    Sorted by most recent first.
    """
    runs_dir = os.path.join(project_dir, config.STATE_DIR, "runs")
    if not os.path.isdir(runs_dir):
        return []

    runs = []
    for entry in os.listdir(runs_dir):
        run_dir = os.path.join(runs_dir, entry)
        state_file = os.path.join(run_dir, config.STATE_FILE)
        if not os.path.isfile(state_file):
            continue
        try:
            with open(state_file, "r", encoding="utf-8") as f:
                data = json.load(f)
            plan = data.get("plan", {})
            milestones = plan.get("milestones", [])
            completed = sum(1 for m in milestones if m.get("status") == "completed")
            total = len(milestones)
            runs.append({
                "run_id": entry,
                "started_at": data.get("started_at", ""),
                "task": plan.get("task", "")[:100],
                "milestones": f"{completed}/{total}",
                "path": run_dir,
            })
        except (json.JSONDecodeError, OSError):
            continue

    runs.sort(key=lambda r: r["started_at"], reverse=True)
    return runs


def ensure_boris_dir(project_dir: str) -> str:
    """Ensure the .boris/ directory structure exists in the project directory.

    Creates:
      {project_dir}/.boris/
      {project_dir}/.boris/plans/

    Returns the path to the .boris/ directory.
    This should be called early in Boris startup to guarantee state/plan
    files have a home in the project directory.
    """
    boris_dir = os.path.join(project_dir, config.STATE_DIR)
    os.makedirs(boris_dir, exist_ok=True)
    os.makedirs(os.path.join(boris_dir, "plans"), exist_ok=True)
    return boris_dir


@dataclass
class Milestone:
    id: str
    title: str
    description: str
    depends_on: list[str]
    acceptance_criteria: list[str]
    files_to_create: list[str]
    files_to_modify: list[str]
    test_commands: list[str] = field(default_factory=list)
    status: str = "pending"  # pending | in_progress | completed | skipped
    retry_count: int = 0
    completed_at: Optional[str] = None


@dataclass
class UIMilestone:
    id: str
    title: str
    description: str
    test_tool: str
    test_commands: list[str]
    acceptance_criteria: list[str]
    issues_found: list[str] = field(default_factory=list)
    issues_fixed: list[str] = field(default_factory=list)
    status: str = "pending"
    retry_count: int = 0
    completed_at: Optional[str] = None


@dataclass
class E2EMilestone:
    id: str
    title: str
    description: str
    test_tool: str
    test_commands: list[str]
    acceptance_criteria: list[str]
    playwright_script_path: Optional[str] = None   # Pre-generated Playwright script
    proof_image_path: Optional[str] = None         # Expected screenshot proof file
    scenario: Optional[str] = None                 # User interaction scenario description
    status: str = "pending"
    retry_count: int = 0
    completed_at: Optional[str] = None


@dataclass
class E2EPlan:
    project_type: str
    test_tool: str
    milestones: list[E2EMilestone]


@dataclass
class UIPlan:
    project_type: str
    test_tool: str
    milestones: list[UIMilestone]


@dataclass
class Plan:
    task: str
    milestones: list[Milestone]
    created_at: str = field(default_factory=lambda: datetime.now().isoformat())


@dataclass
class State:
    plan: Plan
    current_milestone_index: int
    project_dir: str
    git_remote: Optional[str]
    no_git: bool
    started_at: str = field(default_factory=lambda: datetime.now().isoformat())
    updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
    phase: str = "structural"  # structural | e2e_testing | ui_testing
    e2e_plan: Optional[E2EPlan] = None
    ui_plan: Optional[UIPlan] = None
    turbo: bool = False
    session_id: Optional[str] = None
    cumulative_stats: dict = field(default_factory=dict)
    attached_files: list[str] = field(default_factory=list)


def save(state: State) -> None:
    """Save state to {project_dir}/.boris/state.json and plan.md."""
    state_dir = os.path.join(state.project_dir, config.STATE_DIR)
    os.makedirs(state_dir, exist_ok=True)
    state_path = os.path.join(state_dir, config.STATE_FILE)
    state.updated_at = datetime.now().isoformat()
    # Atomic write: write to temp file first, then os.replace() to prevent
    # corrupted state on crash/Ctrl+C (P0 fix).
    fd, tmp_path = tempfile.mkstemp(dir=state_dir, suffix=".tmp", prefix="state_")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(asdict(state), f, indent=2, ensure_ascii=False)
        # Retry os.replace in case OneDrive/antivirus holds a lock
        for attempt in range(5):
            try:
                os.replace(tmp_path, state_path)
                break
            except PermissionError:
                if attempt < 4:
                    time.sleep(0.3)
                else:
                    raise
    except BaseException:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    # Also export a human-readable markdown plan
    _save_plan_md(state)


def _save_plan_md(state: State) -> None:
    """Export the plan as a readable markdown file in the project directory."""
    plan = state.plan
    if not plan or not plan.milestones:
        return

    status_icon = {
        "completed": "[x]",
        "in_progress": "[-]",
        "skipped": "[~]",
        "pending": "[ ]",
    }

    lines = []
    lines.append(f"# Boris Plan")
    lines.append("")
    lines.append(f"**Task:** {plan.task}")
    lines.append(f"**Created:** {plan.created_at}")
    lines.append(f"**Updated:** {state.updated_at}")
    lines.append(f"**Mode:** {'Turbo (parallel)' if state.turbo else 'Sequential'}")
    lines.append("")

    # Summary counts
    counts = {}
    for m in plan.milestones:
        counts[m.status] = counts.get(m.status, 0) + 1
    total = len(plan.milestones)
    done = counts.get("completed", 0)
    lines.append(f"**Progress:** {done}/{total} milestones completed")
    if counts.get("skipped", 0):
        lines.append(f"**Skipped:** {counts['skipped']}")
    lines.append("")
    lines.append("---")
    lines.append("")

    # Milestone list
    lines.append("## Milestones")
    lines.append("")
    for m in plan.milestones:
        icon = status_icon.get(m.status, "[ ]")
        lines.append(f"### {icon} {m.id}: {m.title}")
        lines.append("")
        if m.depends_on:
            lines.append(f"**Depends on:** {', '.join(m.depends_on)}")
        lines.append(f"**Status:** {m.status}")
        if m.completed_at:
            lines.append(f"**Completed:** {m.completed_at}")
        lines.append("")
        lines.append(m.description)
        lines.append("")

        if m.acceptance_criteria:
            lines.append("**Acceptance Criteria:**")
            for ac in m.acceptance_criteria:
                lines.append(f"- {ac}")
            lines.append("")

        if m.files_to_create:
            lines.append(f"**Files to create:** {len(m.files_to_create)}")
            for f_path in m.files_to_create:
                lines.append(f"- `{f_path}`")
            lines.append("")

        if m.files_to_modify:
            lines.append(f"**Files to modify:** {len(m.files_to_modify)}")
            for f_path in m.files_to_modify:
                lines.append(f"- `{f_path}`")
            lines.append("")

        lines.append("---")
        lines.append("")

    md_path = os.path.join(state.project_dir, config.STATE_DIR, "plan.md")
    with open(md_path, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))

    # Also update the Mermaid diagram at the fixed well-known path
    _save_mermaid_diagram(state.plan, state.project_dir)


def _save_mermaid_diagram(plan, project_dir: str) -> str:
    """Save a Mermaid flowchart diagram of the plan to .boris/plan_diagram.md.

    This file lives at a fixed path so agents can read it directly without
    globbing or searching. It shows milestones, dependencies, and statuses.
    """
    boris_dir = os.path.join(project_dir, config.STATE_DIR)
    os.makedirs(boris_dir, exist_ok=True)
    filepath = os.path.join(boris_dir, "plan_diagram.md")

    status_label = {
        "completed": "done",
        "in_progress": "active",
        "skipped": "skipped",
        "pending": "pending",
    }

    lines = []
    lines.append("# Boris Plan — Dependency Diagram")
    lines.append("")
    lines.append(f"**Task:** {plan.task}")
    lines.append(f"**Generated:** {datetime.now().isoformat()}")
    lines.append("")
    lines.append("```mermaid")
    lines.append("flowchart TD")

    for m in plan.milestones:
        label = f"{m.id}: {m.title}"
        sl = status_label.get(m.status, "pending")
        lines.append(f'    {m.id}["{label}<br/>({sl})"]')

    lines.append("")

    for m in plan.milestones:
        for dep in m.depends_on:
            lines.append(f"    {dep} --> {m.id}")

    lines.append("")

    completed = [m.id for m in plan.milestones if m.status == "completed"]
    in_progress = [m.id for m in plan.milestones if m.status == "in_progress"]
    skipped = [m.id for m in plan.milestones if m.status == "skipped"]
    pending = [m.id for m in plan.milestones if m.status == "pending"]

    if completed:
        lines.append("    classDef done fill:#2d6a2d,stroke:#1a4a1a,color:#fff")
        lines.append(f"    class {','.join(completed)} done")
    if in_progress:
        lines.append("    classDef active fill:#b8860b,stroke:#8b6508,color:#fff")
        lines.append(f"    class {','.join(in_progress)} active")
    if skipped:
        lines.append("    classDef skip fill:#666,stroke:#444,color:#fff")
        lines.append(f"    class {','.join(skipped)} skip")
    if pending:
        lines.append("    classDef pend fill:#4a6fa5,stroke:#365880,color:#fff")
        lines.append(f"    class {','.join(pending)} pend")

    lines.append("```")
    lines.append("")

    lines.append("## Milestone Summary")
    lines.append("")
    lines.append("| ID | Title | Status | Depends On | Files |")
    lines.append("|---|---|---|---|---|")
    for m in plan.milestones:
        deps = ", ".join(m.depends_on) if m.depends_on else "—"
        files = ", ".join((m.files_to_create or []) + (m.files_to_modify or [])) or "—"
        lines.append(f"| {m.id} | {m.title} | {m.status} | {deps} | {files} |")
    lines.append("")

    with open(filepath, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))

    return filepath


def load(project_dir: str) -> Optional[State]:
    """Load state from {project_dir}/.boris/state.json. Returns None if not found."""
    state_path = os.path.join(project_dir, config.STATE_DIR, config.STATE_FILE)
    if not os.path.exists(state_path):
        return None
    try:
        with open(state_path, "r", encoding="utf-8") as f:
            data = json.load(f)
        # Reconstruct dataclass instances from dicts
        milestones = [Milestone(**m) for m in data["plan"]["milestones"]]
        plan = Plan(
            task=data["plan"]["task"],
            milestones=milestones,
            created_at=data["plan"]["created_at"],
        )
        # Reconstruct E2E plan if present
        e2e_plan = None
        e2e_data = data.get("e2e_plan")
        if e2e_data:
            e2e_milestones = [E2EMilestone(**m) for m in e2e_data.get("milestones", [])]
            e2e_plan = E2EPlan(
                project_type=e2e_data.get("project_type", "unknown"),
                test_tool=e2e_data.get("test_tool", "playwright"),
                milestones=e2e_milestones,
            )

        return State(
            plan=plan,
            current_milestone_index=data["current_milestone_index"],
            project_dir=data["project_dir"],
            git_remote=data.get("git_remote"),
            no_git=data.get("no_git", False),
            started_at=data.get("started_at", ""),
            updated_at=data.get("updated_at", ""),
            phase=data.get("phase", "structural"),
            e2e_plan=e2e_plan,
            turbo=data.get("turbo", False),
            session_id=data.get("session_id", None),
            cumulative_stats=data.get("cumulative_stats", {}),
            attached_files=data.get("attached_files", []),
        )
    except (json.JSONDecodeError, KeyError, TypeError):
        return None
