"""Integration workflow tests — cross-endpoint + DI regression tests.

ALL tests use integration_client (real DI, no service mocks).
Subprocess calls are mocked at the asyncio.create_subprocess_exec level.
"""

import json
from pathlib import Path
from unittest.mock import AsyncMock, patch, call

import pytest
from fastapi.testclient import TestClient

from hangar.dependencies import (
    get_beads_service,
    get_importsync_service,
    get_openspec_service,
    get_gitdiff_service,
    get_wizard_service,
    get_project_path,
    set_project_path,
)
from hangar.main import app

from tests.helpers import load_fixture, make_mock_process


# ── Subprocess mock helpers ───────────────────────────────────────


def _make_subprocess_router(routes: dict[tuple[str, ...], str]):
    """Create a mock that routes subprocess calls based on command args.

    Args:
        routes: mapping of (arg_substring_tuple) -> stdout response.
            The first matching route wins (checked via substring match on args).

    Example::

        router = _make_subprocess_router({
            ("bd", "list"): '[{"id": "hangar-abc", ...}]',
            ("bd", "show"): '[{"id": "hangar-abc", ...}]',
            ("bd", "update"): '',
        })
    """

    async def mock_exec(*args, **kwargs):
        args_str = " ".join(str(a) for a in args)
        for key_parts, stdout in routes.items():
            if all(part in args_str for part in key_parts):
                proc = AsyncMock()
                proc.communicate.return_value = (stdout.encode(), b"")
                proc.returncode = 0
                proc.kill = AsyncMock()
                proc.wait = AsyncMock()
                return proc
        # Default: empty success
        proc = AsyncMock()
        proc.communicate.return_value = (b"", b"")
        proc.returncode = 0
        proc.kill = AsyncMock()
        proc.wait = AsyncMock()
        return proc

    return mock_exec


# ══════════════════════════════════════════════════════════════════
#  WORKFLOW 1 — Task Lifecycle
# ══════════════════════════════════════════════════════════════════


@pytest.mark.integration
class TestTaskLifecycleWorkflow:
    """GET /api/tasks -> GET /{id} -> POST /{id}/claim -> POST /{id}/close."""

    def test_full_task_lifecycle(self, integration_client: TestClient) -> None:
        """Exercise the complete task lifecycle through real DI."""
        bd_list_data = load_fixture("bd_list.json")
        bd_show_data = load_fixture("bd_show.json")

        task_id = "hangar-910"

        router = _make_subprocess_router(
            {
                ("bd", "list", "--all", "--json"): json.dumps(bd_list_data),
                ("bd", "ready", "--json"): json.dumps(bd_list_data[:2]),
                ("bd", "show", task_id, "--json"): json.dumps(bd_show_data),
                ("bd", "update", task_id, "--claim"): "",
                ("bd", "close", task_id): "",
            }
        )

        with patch("asyncio.create_subprocess_exec", side_effect=router):
            # Step 1: List all tasks
            response = integration_client.get("/api/tasks")
            assert response.status_code == 200
            tasks_data = response.json()
            assert len(tasks_data["tasks"]) > 0

            # Step 2: Get a specific task
            response = integration_client.get(f"/api/tasks/{task_id}")
            assert response.status_code == 200
            task_data = response.json()
            assert task_data["id"] == task_id

            # Step 3: Claim the task
            response = integration_client.post(f"/api/tasks/{task_id}/claim")
            assert response.status_code == 200
            assert response.json()["status"] == "claimed"

            # Step 4: Close the task
            response = integration_client.post(
                f"/api/tasks/{task_id}/close",
                json={"reason": "Feature complete"},
            )
            assert response.status_code == 200
            assert response.json()["status"] == "closed"


# ══════════════════════════════════════════════════════════════════
#  WORKFLOW 2 — Spec Browsing
# ══════════════════════════════════════════════════════════════════


@pytest.mark.integration
class TestSpecBrowsingWorkflow:
    """GET /api/specs -> GET /{name} -> GET /{name}/artifacts."""

    def test_full_spec_browsing(
        self, integration_client: TestClient, integration_project: Path
    ) -> None:
        """Browse specs through real DI, verifying filesystem reads work."""
        openspec_list = load_fixture("openspec_list.json")
        openspec_status = load_fixture("openspec_status.json")

        # Create the artifact files that read_artifact will try to read
        change_dir = integration_project / "openspec" / "changes" / "mvp-foundation"
        change_dir.mkdir(parents=True, exist_ok=True)
        (change_dir / "proposal.md").write_text("# Proposal\nContent here\n")
        (change_dir / "design.md").write_text("# Design\nArchitecture here\n")
        specs_dir = change_dir / "specs"
        specs_dir.mkdir(exist_ok=True)
        (specs_dir / "spec.md").write_text("# Spec\nRequirements here\n")
        (change_dir / "tasks.md").write_text("# Tasks\n- [ ] **1.1** Do thing\n")

        router = _make_subprocess_router(
            {
                ("openspec", "list", "--json"): json.dumps(openspec_list),
                ("openspec", "status", "--change", "mvp-foundation"): json.dumps(
                    openspec_status
                ),
            }
        )

        with patch("asyncio.create_subprocess_exec", side_effect=router):
            # Step 1: List all changes
            response = integration_client.get("/api/specs")
            assert response.status_code == 200
            changes = response.json()
            assert len(changes["changes"]) > 0
            change_name = changes["changes"][0]["name"]

            # Step 2: Get change status
            response = integration_client.get(f"/api/specs/{change_name}")
            assert response.status_code == 200
            status = response.json()
            # Model uses alias serialization: changeName (camelCase)
            assert status.get("changeName", status.get("change_name")) == "mvp-foundation"
            assert len(status["artifacts"]) > 0

            # Step 3: Read all artifacts (this reads from REAL filesystem!)
            response = integration_client.get(f"/api/specs/{change_name}/artifacts")
            assert response.status_code == 200
            artifacts = response.json()
            assert len(artifacts) > 0
            # Verify at least one artifact has actual content from disk
            contents = [a["content"] for a in artifacts if a["content"]]
            assert len(contents) > 0, "Expected at least one artifact with content"


# ══════════════════════════════════════════════════════════════════
#  WORKFLOW 3 — Wizard Flow
# ══════════════════════════════════════════════════════════════════


@pytest.mark.integration
class TestWizardFlowWorkflow:
    """POST /wizard/create -> /wizard/generate -> /wizard/approve -> GET state."""

    def test_wizard_flow(
        self, integration_client: TestClient, integration_project: Path
    ) -> None:
        """Exercise wizard create->generate->approve->state through real DI."""
        # Step 1: Create a new change
        response = integration_client.post(
            "/api/wizard/create",
            json={"name": "wizard-test", "description": "Test wizard flow"},
        )
        assert response.status_code == 200
        data = response.json()
        assert data["name"] == "wizard-test"
        assert data["created"] is True

        # Verify directory was created on real filesystem
        change_dir = integration_project / "openspec" / "changes" / "wizard-test"
        assert change_dir.exists()
        assert (change_dir / ".description").read_text() == "Test wizard flow"

        # Step 2: Generate an artifact (mock the claude subprocess)
        agent_output = "# Proposal\n\nGenerated proposal content.\n"
        with patch(
            "asyncio.create_subprocess_exec",
            return_value=make_mock_process(stdout=agent_output),
        ):
            response = integration_client.post(
                "/api/wizard/generate",
                json={
                    "change_name": "wizard-test",
                    "artifact_type": "proposal",
                    "description": "Test wizard flow",
                    "approved_artifacts": {},
                    "agent_type": "claude-code",
                },
            )
        assert response.status_code == 200
        gen_data = response.json()
        assert "Proposal" in gen_data["content"] or "Agent failed" in gen_data["content"]
        assert gen_data["agent_type"] == "claude-code"

        # Step 3: Approve the artifact (writes to real filesystem)
        response = integration_client.post(
            "/api/wizard/approve",
            json={
                "change_name": "wizard-test",
                "artifact_type": "proposal",
                "content": "# Proposal\n\nApproved proposal content.\n",
            },
        )
        assert response.status_code == 200
        assert response.json()["status"] == "approved"
        assert (change_dir / "proposal.md").read_text() == "# Proposal\n\nApproved proposal content.\n"

        # Step 4: Get wizard state (should show proposal as approved)
        response = integration_client.get("/api/wizard/wizard-test/state")
        assert response.status_code == 200
        state = response.json()
        assert state["change_name"] == "wizard-test"
        assert state["description"] == "Test wizard flow"
        assert state["steps"][0]["artifact_type"] == "proposal"
        assert state["steps"][0]["status"] == "approved"
        assert state["current_step"] >= 1  # past proposal step


# ══════════════════════════════════════════════════════════════════
#  WORKFLOW 4 — Sync Flow (regression test for the bug)
# ══════════════════════════════════════════════════════════════════


@pytest.mark.integration
class TestSyncFlowWorkflow:
    """The regression test: verifies _read_tasks_md() finds tasks.md through DI.

    This is the exact bug that caused 404s in the live demo: ImportSyncService
    couldn't find tasks.md because the project_path wasn't properly wired.
    """

    def test_sync_preview_finds_tasks_md(
        self, integration_client: TestClient, integration_project: Path
    ) -> None:
        """GET /api/sync/test-feature/preview reads tasks.md from real filesystem.

        No subprocess mocking needed — preview_import only reads the filesystem.
        """
        response = integration_client.get("/api/sync/test-feature/preview")
        assert response.status_code == 200
        data = response.json()
        assert data["change_name"] == "test-feature"
        assert len(data["tasks"]) == 3  # 1.1, 1.2, 2.1
        assert data["tasks"][0]["number"] == "1.1"
        assert data["tasks"][0]["title"] == "Initialize project structure"
        assert len(data["to_import"]) == 3  # none have beads IDs yet

    def test_sync_import_creates_tasks(
        self, integration_client: TestClient, integration_project: Path
    ) -> None:
        """POST /api/sync/test-feature/import creates beads tasks via bd CLI.

        Verifies the full DI chain: router -> ImportSyncService -> BeadsService.
        """
        router = _make_subprocess_router(
            {
                ("bd", "create"): "✓ Created issue: hangar-t01",
                ("bd", "dep"): "",
            }
        )

        with patch("asyncio.create_subprocess_exec", side_effect=router):
            response = integration_client.post(
                "/api/sync/test-feature/import",
                json={
                    "tasks": ["1.1", "1.2", "2.1"],
                    "dependencies": [("1.1", "1.2"), ("1.2", "2.1")],
                },
            )

        assert response.status_code == 200
        data = response.json()
        assert len(data["created"]) == 3
        assert data["deps_wired"] == 2
        assert data["tasks_md_updated"] is True

        # Verify tasks.md was actually updated on disk
        tasks_md = integration_project / "openspec" / "changes" / "test-feature" / "tasks.md"
        content = tasks_md.read_text()
        assert "(hangar-t01)" in content  # beads ID was stamped back

    def test_sync_completion_reads_from_filesystem(
        self, integration_client: TestClient, integration_project: Path
    ) -> None:
        """GET /api/sync/test-feature/completion uses real filesystem + mock bd.

        Verifies ImportSyncService.get_change_completion() works through DI.
        """
        bd_list_data = [
            {"id": "hangar-abc", "title": "1.1 Init", "status": "closed", "priority": 2, "issue_type": "task"},
        ]

        with patch(
            "asyncio.create_subprocess_exec",
            return_value=make_mock_process(stdout=json.dumps(bd_list_data)),
        ):
            response = integration_client.get("/api/sync/test-feature/completion")

        assert response.status_code == 200
        data = response.json()
        assert data["change_name"] == "test-feature"
        assert data["total"] == 3  # 3 tasks in our sample tasks.md

    def test_sync_preview_404_for_missing_change(
        self, integration_client: TestClient
    ) -> None:
        """GET /api/sync/nonexistent/preview should return 404."""
        response = integration_client.get("/api/sync/nonexistent/preview")
        assert response.status_code == 404
        assert "No tasks.md found" in response.json()["detail"]


# ══════════════════════════════════════════════════════════════════
#  DI WIRING REGRESSION TESTS
# ══════════════════════════════════════════════════════════════════


@pytest.mark.integration
class TestDIWiringRegression:
    """Verify DI singletons are properly wired and reset correctly."""

    def test_set_project_path_propagates_to_all_services(
        self, integration_project: Path
    ) -> None:
        """set_project_path() should propagate to all service singletons."""
        # After integration_project fixture runs, all singletons should point
        # to the tmp_path. Accessing them via DI getters forces creation.
        beads = get_beads_service()
        openspec = get_openspec_service()
        gitdiff = get_gitdiff_service()
        wizard = get_wizard_service()
        importsync = get_importsync_service()

        # All should have the integration_project as their project_path
        assert beads.project_path == integration_project
        assert openspec.project_path == integration_project
        assert gitdiff.project_path == integration_project
        assert wizard.project_path == integration_project
        assert importsync.project_path == integration_project

    def test_importsync_changes_dir_matches_filesystem(
        self, integration_project: Path
    ) -> None:
        """ImportSyncService.changes_dir should point to the real changes dir."""
        importsync = get_importsync_service()
        expected = integration_project / "openspec" / "changes"
        assert importsync.changes_dir == expected
        assert importsync.changes_dir.exists()

    def test_importsync_shares_beads_service_singleton(
        self, integration_project: Path
    ) -> None:
        """ImportSyncService.beads_service should be same instance as DI-provided."""
        beads = get_beads_service()
        importsync = get_importsync_service()
        assert importsync.beads_service is beads

    def test_project_path_reset_clears_all_singletons(
        self, integration_project: Path, tmp_path: Path
    ) -> None:
        """Changing project path should reset all cached singletons."""
        # Force singleton creation
        beads_1 = get_beads_service()
        importsync_1 = get_importsync_service()

        # Change project path to a different tmp dir
        new_path = tmp_path / "different-project"
        new_path.mkdir()
        set_project_path(new_path)

        # New singletons should be different instances
        beads_2 = get_beads_service()
        importsync_2 = get_importsync_service()

        assert beads_2 is not beads_1
        assert importsync_2 is not importsync_1
        assert beads_2.project_path == new_path

        # Cleanup: restore original project path
        set_project_path(integration_project)

    def test_get_project_path_returns_current(
        self, integration_project: Path
    ) -> None:
        """get_project_path() should return the currently configured path."""
        assert get_project_path() == integration_project
