"""Tests for ImportSyncService, parse_tasks_md, and infer_dependencies."""

import pytest
from unittest.mock import AsyncMock, patch
from pathlib import Path

from hangar.services.importsync import (
    ParsedTask,
    ParsedTaskGraph,
    SuggestedDep,
    parse_tasks_md,
    infer_dependencies,
    _extract_beads_id,
    ImportSyncService,
)


# ── Sample fixtures ────────────────────────────────────────────────

SAMPLE_TASKS_MD = """\
# Tasks: MVP Foundation

## Phase 1: Project Scaffolding

- [ ] **1.1** Initialize backend (uv, FastAPI)
  - Tests: health endpoint returns 200
- [ ] **1.2** Initialize frontend (SvelteKit)
  - Tests: npm run dev starts
- [ ] **1.3** Wire backend to frontend (CORS)
  - Tests: Frontend can call /api/health

## Phase 2: Beads Integration

- [ ] **2.1** Create Pydantic models for bd JSON
  - Tests: Parse sample fixtures
- [ ] **2.2** Implement BeadsService
  - Tests: Mock subprocess calls
- [ ] **2.3** Create /api/tasks router
  - Tests: FastAPI TestClient
"""

SAMPLE_WITH_BEADS_IDS = """\
# Tasks: Test

## Phase 1: Setup

- [x] (hangar-abc) **1.1** Already imported and done
- [ ] (hangar-def) **1.2** Imported but not done
- [ ] **1.3** Not yet imported
"""

SAMPLE_CHECKED = """\
# Tasks: Test

## Phase 1: Done

- [x] **1.1** Completed task
- [ ] **1.2** Open task
- [X] **1.3** Also completed (uppercase X)
"""

EMPTY_TASKS_MD = """\
# Tasks: Empty Project

No tasks defined yet.
"""

SINGLE_PHASE = """\
# Tasks: Small

## Phase 1: Everything

- [ ] **1.1** Only task A
- [ ] **1.2** Only task B
"""


# ── parse_tasks_md tests ──────────────────────────────────────────


class TestParseTasksMd:
    def test_parses_standard_format(self):
        graph = parse_tasks_md(SAMPLE_TASKS_MD)
        assert len(graph.tasks) == 6
        assert len(graph.phases) == 2
        assert graph.phases[0] == "Project Scaffolding"
        assert graph.phases[1] == "Beads Integration"

    def test_task_fields(self):
        graph = parse_tasks_md(SAMPLE_TASKS_MD)
        t = graph.tasks[0]
        assert t.number == "1.1"
        assert t.title == "Initialize backend (uv, FastAPI)"
        assert t.phase == "Project Scaffolding"
        assert t.phase_index == 0
        assert t.checked is False
        assert t.beads_id is None
        assert t.test_hint == "health endpoint returns 200"
        assert t.line_number > 0

    def test_parses_checked(self):
        graph = parse_tasks_md(SAMPLE_CHECKED)
        assert graph.tasks[0].checked is True
        assert graph.tasks[1].checked is False
        assert graph.tasks[2].checked is True

    def test_parses_beads_ids(self):
        graph = parse_tasks_md(SAMPLE_WITH_BEADS_IDS)
        assert graph.tasks[0].beads_id == "hangar-abc"
        assert graph.tasks[0].checked is True
        assert graph.tasks[1].beads_id == "hangar-def"
        assert graph.tasks[1].checked is False
        assert graph.tasks[2].beads_id is None

    def test_empty_file(self):
        graph = parse_tasks_md(EMPTY_TASKS_MD)
        assert len(graph.tasks) == 0
        assert len(graph.phases) == 0

    def test_missing_lines_ignored(self):
        content = "# Header\n\nRandom text\n\n- Not a task\n"
        graph = parse_tasks_md(content)
        assert len(graph.tasks) == 0

    def test_test_hints_captured(self):
        graph = parse_tasks_md(SAMPLE_TASKS_MD)
        assert graph.tasks[0].test_hint == "health endpoint returns 200"
        assert graph.tasks[3].test_hint == "Parse sample fixtures"

    def test_phase_index_sequential(self):
        graph = parse_tasks_md(SAMPLE_TASKS_MD)
        phase_0_tasks = [t for t in graph.tasks if t.phase_index == 0]
        phase_1_tasks = [t for t in graph.tasks if t.phase_index == 1]
        assert len(phase_0_tasks) == 3
        assert len(phase_1_tasks) == 3

    def test_line_numbers_correct(self):
        graph = parse_tasks_md(SAMPLE_TASKS_MD)
        # Line numbers should be positive and increasing
        line_nums = [t.line_number for t in graph.tasks]
        assert all(n > 0 for n in line_nums)
        assert line_nums == sorted(line_nums)


# ── infer_dependencies tests ──────────────────────────────────────


class TestInferDependencies:
    def test_within_phase_sequential(self):
        graph = parse_tasks_md(SINGLE_PHASE)
        deps = infer_dependencies(graph)
        assert len(deps) == 1
        assert deps[0].from_number == "1.1"
        assert deps[0].to_number == "1.2"
        assert "sequential" in deps[0].reason

    def test_cross_phase_linking(self):
        graph = parse_tasks_md(SAMPLE_TASKS_MD)
        deps = infer_dependencies(graph)
        # Find cross-phase dep: 1.3 → 2.1
        cross = [d for d in deps if "cross-phase" in d.reason]
        assert len(cross) == 1
        assert cross[0].from_number == "1.3"
        assert cross[0].to_number == "2.1"

    def test_within_and_cross_combined(self):
        graph = parse_tasks_md(SAMPLE_TASKS_MD)
        deps = infer_dependencies(graph)
        # Phase 1: 1.1→1.2, 1.2→1.3 (2 within)
        # Phase 2: 2.1→2.2, 2.2→2.3 (2 within)
        # Cross: 1.3→2.1 (1 cross)
        # Total: 5
        assert len(deps) == 5

    def test_empty_graph(self):
        graph = ParsedTaskGraph()
        deps = infer_dependencies(graph)
        assert len(deps) == 0

    def test_single_task(self):
        graph = ParsedTaskGraph(
            tasks=[ParsedTask("1.1", "Solo", "Phase 1", 0, False)],
            phases=["Phase 1"],
        )
        deps = infer_dependencies(graph)
        assert len(deps) == 0

    def test_dep_reasons_descriptive(self):
        graph = parse_tasks_md(SAMPLE_TASKS_MD)
        deps = infer_dependencies(graph)
        for dep in deps:
            assert len(dep.reason) > 0
            assert dep.from_number != dep.to_number


# ── _extract_beads_id tests ───────────────────────────────────────


class TestExtractBeadsId:
    def test_standard_output(self):
        output = "✓ Created issue: hangar-abc\n  Title: test"
        assert _extract_beads_id(output) == "hangar-abc"

    def test_no_match(self):
        assert _extract_beads_id("some random output") is None

    def test_with_warning(self):
        output = "⚠ Warning\n✓ Created issue: hangar-x1y\n  Title: test"
        assert _extract_beads_id(output) == "hangar-x1y"


# ── ImportSyncService tests ───────────────────────────────────────


class TestImportSyncService:
    @pytest.fixture
    def mock_beads(self):
        return AsyncMock(spec_set=["create_task", "add_dependency", "list_tasks"])

    @pytest.fixture
    def tmp_project(self, tmp_path):
        changes_dir = tmp_path / "openspec" / "changes" / "test-change"
        changes_dir.mkdir(parents=True)
        tasks_md = changes_dir / "tasks.md"
        tasks_md.write_text(SAMPLE_TASKS_MD)
        return tmp_path

    @pytest.fixture
    def service(self, tmp_project, mock_beads):
        return ImportSyncService(tmp_project, mock_beads)

    @pytest.mark.asyncio
    async def test_preview_import_fresh(self, service):
        preview = await service.preview_import("test-change")
        assert preview.change_name == "test-change"
        assert len(preview.tasks) == 6
        assert len(preview.to_import) == 6
        assert len(preview.already_imported) == 0
        assert len(preview.suggested_deps) == 5

    @pytest.mark.asyncio
    async def test_preview_import_partial(self, tmp_project, mock_beads):
        # Write a tasks.md with some already imported
        changes_dir = tmp_project / "openspec" / "changes" / "test-change"
        tasks_md = changes_dir / "tasks.md"
        tasks_md.write_text(SAMPLE_WITH_BEADS_IDS)

        service = ImportSyncService(tmp_project, mock_beads)
        preview = await service.preview_import("test-change")
        assert len(preview.already_imported) == 2  # hangar-abc, hangar-def
        assert len(preview.to_import) == 1  # 1.3

    @pytest.mark.asyncio
    async def test_preview_missing_change(self, tmp_project, mock_beads):
        service = ImportSyncService(tmp_project, mock_beads)
        with pytest.raises(FileNotFoundError):
            await service.preview_import("nonexistent")

    @pytest.mark.asyncio
    async def test_execute_import_creates_tasks(self, service, mock_beads):
        # Mock create_task to return output with IDs
        call_count = 0

        async def mock_create_task(title):
            nonlocal call_count
            call_count += 1
            return f"✓ Created issue: hangar-new{call_count}"

        mock_beads.create_task = AsyncMock(side_effect=mock_create_task)
        mock_beads.add_dependency = AsyncMock()

        result = await service.execute_import(
            "test-change",
            task_numbers=["1.1", "1.2"],
            dependencies=[("1.1", "1.2")],
        )
        assert len(result.created) == 2
        assert result.created[0] == ("1.1", "hangar-new1")
        assert result.created[1] == ("1.2", "hangar-new2")
        assert result.deps_wired == 1
        assert result.tasks_md_updated is True

    @pytest.mark.asyncio
    async def test_execute_import_stamps_ids(self, service, mock_beads):
        call_count = 0

        async def mock_create_task(title):
            nonlocal call_count
            call_count += 1
            return f"✓ Created issue: hangar-s{call_count}"

        mock_beads.create_task = AsyncMock(side_effect=mock_create_task)
        mock_beads.add_dependency = AsyncMock()

        await service.execute_import(
            "test-change",
            task_numbers=["1.1"],
            dependencies=[],
        )

        # Read back tasks.md and check for stamped ID
        tasks_md = service._tasks_md_path("test-change")
        content = tasks_md.read_text()
        assert "(hangar-s1) **1.1**" in content

    @pytest.mark.asyncio
    async def test_sync_status_updates_checkboxes(self, tmp_project, mock_beads):
        # Write tasks.md with stamped but unchecked tasks
        changes_dir = tmp_project / "openspec" / "changes" / "test-change"
        tasks_md = changes_dir / "tasks.md"
        tasks_md.write_text(SAMPLE_WITH_BEADS_IDS)

        # Mock beads to return statuses
        from hangar.models.beads import BeadsTask, BeadsTaskList

        mock_tasks = BeadsTaskList(tasks=[
            BeadsTask(id="hangar-abc", title="t1", status="closed"),
            BeadsTask(id="hangar-def", title="t2", status="closed"),
        ])
        mock_beads.list_tasks = AsyncMock(return_value=mock_tasks)

        service = ImportSyncService(tmp_project, mock_beads)
        result = await service.sync_status("test-change")

        # hangar-def was [ ] but now closed → should be synced
        assert len(result.synced) == 1  # hangar-abc was already [x]
        assert result.tasks_md_updated is True

        content = tasks_md.read_text()
        assert "- [x] (hangar-def)" in content

    @pytest.mark.asyncio
    async def test_get_change_completion(self, tmp_project, mock_beads):
        changes_dir = tmp_project / "openspec" / "changes" / "test-change"
        tasks_md = changes_dir / "tasks.md"
        tasks_md.write_text(SAMPLE_WITH_BEADS_IDS)

        from hangar.models.beads import BeadsTask, BeadsTaskList

        mock_tasks = BeadsTaskList(tasks=[
            BeadsTask(id="hangar-abc", title="t1", status="closed"),
            BeadsTask(id="hangar-def", title="t2", status="in_progress"),
        ])
        mock_beads.list_tasks = AsyncMock(return_value=mock_tasks)

        service = ImportSyncService(tmp_project, mock_beads)
        completion = await service.get_change_completion("test-change")

        assert completion.total == 3
        assert completion.closed == 1
        assert completion.in_progress == 1
        assert completion.open == 1  # 1.3 not imported
