"""Service for bridging OpenSpec tasks.md with Beads task management."""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from pathlib import Path

from hangar.services.beads import BeadsService
from hangar.validation import validate_change_name


# ── Pure data classes ──────────────────────────────────────────────


@dataclass
class ParsedTask:
    """A single task parsed from tasks.md."""

    number: str  # "1.1", "2.3"
    title: str  # "Initialize backend..."
    phase: str  # "Phase 1: Project Scaffolding"
    phase_index: int  # 0-based
    checked: bool  # from [x] vs [ ]
    beads_id: str | None = None  # from (hangar-abc) prefix
    test_hint: str | None = None  # from "- Tests: ..." sub-bullet
    line_number: int = 0  # for write-back


@dataclass
class ParsedTaskGraph:
    """All tasks parsed from a single tasks.md file."""

    tasks: list[ParsedTask] = field(default_factory=list)
    phases: list[str] = field(default_factory=list)


@dataclass
class SuggestedDep:
    """A suggested dependency edge between two tasks."""

    from_number: str  # "1.1" (blocker)
    to_number: str  # "2.1" (blocked)
    reason: str  # "cross-phase: Phase 1 → Phase 2"


@dataclass
class ImportPreview:
    """Preview of what an import would do (no mutations)."""

    change_name: str
    tasks: list[ParsedTask]
    suggested_deps: list[SuggestedDep]
    already_imported: list[str]  # task numbers with beads IDs
    to_import: list[str]  # task numbers without beads IDs


@dataclass
class ImportResult:
    """Result of executing an import."""

    created: list[tuple[str, str]]  # (task_number, beads_id)
    deps_wired: int
    tasks_md_updated: bool


@dataclass
class SyncResult:
    """Result of syncing beads status to tasks.md."""

    synced: list[tuple[str, str]]  # (beads_id, new_status)
    tasks_md_updated: bool


@dataclass
class ChangeCompletion:
    """Beads-sourced completion stats for a change."""

    change_name: str
    total: int
    closed: int
    in_progress: int
    open: int


# ── Regex patterns ─────────────────────────────────────────────────

# Matches both "## Phase 1: Title" and "## 1. Title"
PHASE_RE = re.compile(r"^##\s+(?:Phase\s+)?\d+[\.:]\s*(.+)$")

# Matches both "- [x] **1.1** Title" and "- [x] 1.1 Title"
# Optional (beads-id) prefix, optional bold markers around number
TASK_RE = re.compile(
    r"^- \[([ xX])\]\s+"  # checkbox
    r"(?:\(([^)]+)\)\s+)?"  # optional (beads-id)
    r"\*{0,2}(\d+\.\d+)\*{0,2}\s+"  # X.Y with optional bold
    r"(.+)$"  # title
)

# Indented sub-bullet for test hints
TEST_HINT_RE = re.compile(r"^\s+-\s+Tests?:\s*(.+)$")


# ── Pure functions ─────────────────────────────────────────────────


def parse_tasks_md(content: str) -> ParsedTaskGraph:
    """Parse a tasks.md file into a structured task graph.

    Pure function — no I/O, fully testable.
    """
    graph = ParsedTaskGraph()
    current_phase = ""
    phase_index = -1
    last_task: ParsedTask | None = None

    for line_num, line in enumerate(content.splitlines(), start=1):
        # Check for phase header
        phase_match = PHASE_RE.match(line)
        if phase_match:
            current_phase = phase_match.group(1).strip()
            phase_index += 1
            graph.phases.append(current_phase)
            last_task = None
            continue

        # Check for task line
        task_match = TASK_RE.match(line)
        if task_match:
            checked_char, beads_id, number, title = task_match.groups()
            task = ParsedTask(
                number=number,
                title=title.strip(),
                phase=current_phase,
                phase_index=phase_index if phase_index >= 0 else 0,
                checked=checked_char.lower() == "x",
                beads_id=beads_id,
                line_number=line_num,
            )
            graph.tasks.append(task)
            last_task = task
            continue

        # Check for test hint (must follow a task)
        if last_task is not None:
            hint_match = TEST_HINT_RE.match(line)
            if hint_match:
                last_task.test_hint = hint_match.group(1).strip()
                continue

        # Any other line resets the test-hint association
        if line.strip() and not line.startswith("  "):
            last_task = None

    return graph


def infer_dependencies(graph: ParsedTaskGraph) -> list[SuggestedDep]:
    """Suggest dependency edges from the parsed task graph.

    Rules:
    1. Within-phase sequential: X.Y blocks X.(Y+1)
    2. Cross-phase: last task of phase N blocks first task of phase N+1

    Pure function — no I/O, fully testable.
    """
    deps: list[SuggestedDep] = []

    if not graph.tasks:
        return deps

    # Group tasks by phase index
    phases: dict[int, list[ParsedTask]] = {}
    for task in graph.tasks:
        phases.setdefault(task.phase_index, []).append(task)

    # Sort each phase's tasks by number
    for phase_tasks in phases.values():
        phase_tasks.sort(key=lambda t: [int(x) for x in t.number.split(".")])

    phase_indices = sorted(phases.keys())

    for pi in phase_indices:
        phase_tasks = phases[pi]

        # Rule 1: Within-phase sequential
        for i in range(len(phase_tasks) - 1):
            deps.append(
                SuggestedDep(
                    from_number=phase_tasks[i].number,
                    to_number=phase_tasks[i + 1].number,
                    reason=f"sequential within {phase_tasks[i].phase}",
                )
            )

        # Rule 2: Cross-phase — last of this phase blocks first of next
        next_pi_idx = phase_indices.index(pi) + 1
        if next_pi_idx < len(phase_indices):
            next_pi = phase_indices[next_pi_idx]
            next_tasks = phases[next_pi]
            if phase_tasks and next_tasks:
                deps.append(
                    SuggestedDep(
                        from_number=phase_tasks[-1].number,
                        to_number=next_tasks[0].number,
                        reason=f"cross-phase: {phase_tasks[-1].phase} → {next_tasks[0].phase}",
                    )
                )

    return deps


# ── Service (has I/O) ──────────────────────────────────────────────


class ImportSyncService:
    """Orchestrates import/sync between OpenSpec tasks.md and Beads."""

    def __init__(self, project_path: Path, beads_service: BeadsService) -> None:
        self.project_path = project_path
        self.beads_service = beads_service
        self.changes_dir = project_path / "openspec" / "changes"

    def _tasks_md_path(self, change_name: str) -> Path:
        validate_change_name(change_name)
        # Check active directory first, then archive
        active = self.changes_dir / change_name / "tasks.md"
        if active.exists():
            return active
        archive = self.changes_dir / "archive" / change_name / "tasks.md"
        if archive.exists():
            return archive
        return active  # default (will raise FileNotFoundError naturally)

    def _read_tasks_md(self, change_name: str) -> str:
        path = self._tasks_md_path(change_name)
        if not path.exists():
            raise FileNotFoundError(f"No tasks.md found for change '{change_name}'")
        return path.read_text()

    async def preview_import(self, change_name: str) -> ImportPreview:
        """Parse tasks.md, infer deps, check existing imports. No mutations."""
        content = self._read_tasks_md(change_name)
        graph = parse_tasks_md(content)
        suggested_deps = infer_dependencies(graph)

        already_imported = [t.number for t in graph.tasks if t.beads_id]
        to_import = [t.number for t in graph.tasks if not t.beads_id]

        return ImportPreview(
            change_name=change_name,
            tasks=graph.tasks,
            suggested_deps=suggested_deps,
            already_imported=already_imported,
            to_import=to_import,
        )

    async def execute_import(
        self,
        change_name: str,
        task_numbers: list[str],
        dependencies: list[tuple[str, str]],
    ) -> ImportResult:
        """Create beads tasks, wire deps, stamp IDs back into tasks.md."""
        content = self._read_tasks_md(change_name)
        graph = parse_tasks_md(content)

        # Map task numbers to parsed tasks
        task_map = {t.number: t for t in graph.tasks}

        # Create tasks in beads
        created: list[tuple[str, str]] = []
        number_to_beads_id: dict[str, str] = {}

        # Include already-imported IDs for dep wiring
        for t in graph.tasks:
            if t.beads_id:
                number_to_beads_id[t.number] = t.beads_id

        for num in task_numbers:
            task = task_map.get(num)
            if not task or task.beads_id:
                continue  # skip unknown or already imported

            title = f"{task.number} {task.title}"
            output = await self.beads_service.create_task(title)

            # Extract beads ID from output: "✓ Created issue: hangar-xxx"
            beads_id = _extract_beads_id(output)
            if beads_id:
                number_to_beads_id[num] = beads_id
                created.append((num, beads_id))

        # Wire dependencies (only if both sides have beads IDs)
        deps_wired = 0
        for from_num, to_num in dependencies:
            from_id = number_to_beads_id.get(from_num)
            to_id = number_to_beads_id.get(to_num)
            if from_id and to_id:
                try:
                    await self.beads_service.add_dependency(from_id, to_id)
                    deps_wired += 1
                except RuntimeError:
                    pass  # dep may already exist

        # Stamp beads IDs back into tasks.md
        lines = content.splitlines()
        for num, beads_id in created:
            task = task_map[num]
            line_idx = task.line_number - 1  # 0-based
            if line_idx < len(lines):
                old_line = lines[line_idx]
                # Insert (beads_id) before **X.Y**
                new_line = old_line.replace(
                    f"**{num}**", f"({beads_id}) **{num}**"
                )
                lines[line_idx] = new_line

        # Write updated tasks.md
        tasks_md_updated = len(created) > 0
        if tasks_md_updated:
            path = self._tasks_md_path(change_name)
            path.write_text("\n".join(lines) + "\n")

        return ImportResult(
            created=created,
            deps_wired=deps_wired,
            tasks_md_updated=tasks_md_updated,
        )

    async def sync_status(self, change_name: str) -> SyncResult:
        """Read beads status for stamped tasks, update tasks.md checkboxes."""
        content = self._read_tasks_md(change_name)
        graph = parse_tasks_md(content)

        # Collect stamped tasks
        stamped = [t for t in graph.tasks if t.beads_id]
        if not stamped:
            return SyncResult(synced=[], tasks_md_updated=False)

        # Fetch all beads tasks to get current status
        all_tasks = await self.beads_service.list_tasks()
        beads_status_map = {t.id: t.status for t in all_tasks.tasks}

        # Update checkboxes
        lines = content.splitlines()
        synced: list[tuple[str, str]] = []
        changed = False

        for task in stamped:
            status = beads_status_map.get(task.beads_id)
            if not status:
                continue

            should_be_checked = status == "closed"
            if should_be_checked != task.checked:
                line_idx = task.line_number - 1
                if line_idx < len(lines):
                    old_line = lines[line_idx]
                    if should_be_checked:
                        new_line = old_line.replace("- [ ]", "- [x]", 1)
                    else:
                        new_line = old_line.replace("- [x]", "- [ ]", 1)
                        new_line = new_line.replace("- [X]", "- [ ]", 1)
                    lines[line_idx] = new_line
                    changed = True
                    synced.append((task.beads_id, status))

        if changed:
            path = self._tasks_md_path(change_name)
            path.write_text("\n".join(lines) + "\n")

        return SyncResult(synced=synced, tasks_md_updated=changed)

    async def get_change_completion(self, change_name: str) -> ChangeCompletion:
        """Get beads-sourced completion stats for a change."""
        content = self._read_tasks_md(change_name)
        graph = parse_tasks_md(content)

        stamped = [t for t in graph.tasks if t.beads_id]
        if not stamped:
            # No beads IDs — fall back to checkbox status from tasks.md
            checked = sum(1 for t in graph.tasks if t.checked)
            return ChangeCompletion(
                change_name=change_name,
                total=len(graph.tasks),
                closed=checked,
                in_progress=0,
                open=len(graph.tasks) - checked,
            )

        all_tasks = await self.beads_service.list_tasks()
        beads_status_map = {t.id: t.status for t in all_tasks.tasks}

        closed = 0
        in_progress = 0
        open_count = 0

        for task in stamped:
            status = beads_status_map.get(task.beads_id)
            if status is None:
                # Beads ID not found in DB — fall back to checkbox status
                if task.checked:
                    closed += 1
                else:
                    open_count += 1
            elif status == "closed":
                closed += 1
            elif status == "in_progress":
                in_progress += 1
            else:
                open_count += 1

        # Tasks not yet imported count as open
        not_imported = len(graph.tasks) - len(stamped)
        open_count += not_imported

        return ChangeCompletion(
            change_name=change_name,
            total=len(graph.tasks),
            closed=closed,
            in_progress=in_progress,
            open=open_count,
        )


def _extract_beads_id(output: str) -> str | None:
    """Extract beads ID from bd create output.

    Example: '✓ Created issue: hangar-abc' → 'hangar-abc'
    """
    match = re.search(r"Created issue:\s*(\S+)", output)
    return match.group(1) if match else None
