"""The conversion hook in dispatch_codegen_actions_run, and the GitHub deletion it relies on.

With the flag on and a migrate/replace of a Nuxt project that carries a static prototype, the converted pages are
committed WITH the dispatch (stale static copies deleted in the same commit), the agent's prompt says so, and the
result reports what was done. Anything else, and any failure, leaves dispatch exactly as it was."""

from __future__ import annotations

import json
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from uuid import uuid4

import httpx
import pytest
from src.config import get_settings
from src.domain.codegen import actions_pipeline
from src.integrations.github_client import GitHubAppClient

PREVIEW_SHA = "1" * 40
SITE = {
    "index.html": "<title>Home</title><h1>Home</h1><script src=assets/js/app.js></script>",
    "about.html": "<title>About</title><p>about</p>",
    "admin.html": "<title>Admin</title><p>mock</p>",
    "assets/js/app.js": "var a=1",
}
SNAPSHOT_CTX = {
    "source_adoption": "migrate", "deployment_intent": "modify_and_deploy",
    "import_snapshot": {"import_id": "a" * 32, "files": [{"path": "index.html"}]},
}


class FakeGitHub:
    def __init__(self, tree=None, tree_error=None):
        self.refs = {"main": "3" * 40, "preview": PREVIEW_SHA}
        self.commits: list[tuple[str, dict, str]] = []
        self.dispatches = []
        self.tree = tree if tree is not None else set()
        self.tree_error = tree_error
        self.tree_calls: list[tuple[str, str]] = []

    async def get_ref_head_sha(self, repo, branch):
        return self.refs.get(branch)

    async def create_branch_from_sha(self, repo, branch, sha):
        self.refs[branch] = sha
        return True

    async def set_repo_action_secrets(self, repo, secrets):
        return None

    async def create_commit(self, repo, branch, files, message):
        self.commits.append((branch, files, message))
        return f"{len(self.commits):040x}"

    async def trigger_workflow_dispatch(self, repo, workflow_file, ref, inputs=None):
        self.dispatches.append((workflow_file, ref, inputs or {}))
        return True

    async def mint_repo_scoped_token(self, repo):
        return "fake-scoped-token"

    async def list_tree_paths(self, repo, ref):
        self.tree_calls.append((repo, ref))
        if self.tree_error:
            raise self.tree_error
        return set(self.tree)


async def dispatch(*, enabled=True, project_type="nuxt_app", plan_context=None, site=None, github=None,
                   continuation=None, work_branch=False):
    github = github or FakeGitHub()
    settings = get_settings().model_copy(update={
        "codegen_static_conversion_enabled": enabled,
        "codegen_work_branch_enabled": work_branch,
        "codegen_actions_callback_token": "cb-token",
        "codegen_actions_payload_secret": "payload-secret",
        "ai_provider": "claude",
    })
    project = SimpleNamespace(
        repo=SimpleNamespace(repo_full_name="P2HS/p2hs-abc", default_branch="main", preview_branch="preview"),
        type=project_type, project_ref="", workspace_id=uuid4(),
    )

    @asynccontextmanager
    async def db():
        yield SimpleNamespace()

    load = AsyncMock(return_value=SITE if site is None else site)
    with patch.object(actions_pipeline, "get_settings", return_value=settings), \
         patch("src.domain.projects.repository.ProjectRepository",
               lambda d: SimpleNamespace(get_with_repo=AsyncMock(return_value=project))), \
         patch("src.domain.cloud.facades.project_port_context", AsyncMock(return_value={})), \
         patch("src.integrations.github_installation.resolve_installation_id_for_workspace",
               AsyncMock(return_value=1)), \
         patch("src.integrations.github_client.GitHubAppClient", lambda installation_id=None: github), \
         patch("src.workers.prompt_media_sync.prepare_prompt_media_for_actions",
               AsyncMock(return_value=SimpleNamespace(prompt="Make it functional", upload_dir="", files={},
                                                      cloud_refs=[]))), \
         patch("src.core.security.jwt.create_codegen_action_grant_token", return_value="grant"), \
         patch("src.domain.codegen.record_adapter.platform_adapter_files", return_value={}), \
         patch("src.domain.codegen.import_snapshot.load_for_dispatch", AsyncMock(return_value=({}, ""))), \
         patch("src.domain.codegen.import_snapshot.load_text_files", load), \
         patch("src.domain.codegen.work_branch.persist_state", AsyncMock(return_value=True)):
        result = await actions_pipeline.dispatch_codegen_actions_run(
            db_factory=lambda: db(), run_id=uuid4(), project_id=uuid4(), system_prompt="sys",
            user_prompt="Make it functional",
            plan_context=SNAPSHOT_CTX if plan_context is None else plan_context, continuation=continuation,
        )
    return SimpleNamespace(result=result, gh=github, load=load)


def dispatch_commit(out):
    """The commit that carries the payload (the one the workflow runs from)."""
    branch, files, _ = out.gh.commits[-1]
    return branch, files


def prompt_of(out):
    _, files = dispatch_commit(out)
    path = next(p for p in files if p.startswith(actions_pipeline.PAYLOAD_DIR))
    return json.loads(files[path])["payload"]["user_prompt"]


# ---- converted with the dispatch ----------------------------------------------------------------


@pytest.mark.asyncio
async def test_the_converted_pages_are_committed_in_the_dispatch_commit():
    out = await dispatch()
    _, files = dispatch_commit(out)
    assert {"pages/index.vue", "pages/about.html.vue", "pages/about.vue", "legacy/pages/about.html",
            "composables/useLegacyScripts.ts", "public/assets/js/app.js"} <= set(files)
    assert "pages/admin.html.vue" not in files  # a protected route is never converted
    assert any(p.startswith(actions_pipeline.PAYLOAD_DIR) for p in files)  # same commit as the payload
    assert out.result["static_conversion"]["pages"] == [
        {"source": "about.html", "route": "/about.html"}, {"source": "index.html", "route": "/"}]
    assert [s["path"] for s in out.result["static_conversion"]["skipped"]] == ["admin.html"]


@pytest.mark.asyncio
async def test_the_agent_is_told_the_pages_are_already_converted():
    prompt = prompt_of(await dispatch())
    assert prompt.startswith("Make it functional")
    assert "## Pages already converted by the platform" in prompt
    assert "Do NOT re-template" in prompt and "`admin.html`" in prompt


@pytest.mark.asyncio
async def test_stale_static_copies_are_deleted_in_the_same_commit():
    github = FakeGitHub(tree={"public/about.html", "public/index.html", "public/other.html", "pages/about.vue"})
    out = await dispatch(github=github)
    _, files = dispatch_commit(out)
    assert files["public/about.html"] is None and files["public/index.html"] is None
    assert "public/other.html" not in files
    assert github.tree_calls == [("P2HS/p2hs-abc", "preview")]  # the branch being committed to
    assert sorted(out.result["static_conversion"]["deleted"]) == ["public/about.html", "public/index.html"]


@pytest.mark.asyncio
async def test_with_a_work_branch_the_pages_go_on_the_work_branch_not_preview():
    out = await dispatch(work_branch=True)
    branch, files = dispatch_commit(out)
    assert branch.startswith("p2hs/codegen/") and "pages/about.html.vue" in files
    assert out.gh.tree_calls[0][1] == branch  # stale copies are looked up on the branch that is committed to
    assert all(b != "preview" for b, _, _ in out.gh.commits)


@pytest.mark.asyncio
async def test_a_replace_is_converted_like_a_migrate():
    out = await dispatch(plan_context={**SNAPSHOT_CTX, "source_adoption": "replace"})
    assert "pages/index.vue" in dispatch_commit(out)[1]


# ---- when nothing must change ---------------------------------------------------------------------


@pytest.mark.asyncio
@pytest.mark.parametrize("kwargs", [
    {"enabled": False},
    {"project_type": "html"},
    {"project_type": "react_app"},
    {"plan_context": {**SNAPSHOT_CTX, "source_adoption": "merge"}},
    {"plan_context": {**SNAPSHOT_CTX, "source_adoption": None}},
    {"plan_context": {"source_adoption": "migrate"}},  # nothing was imported
])
async def test_it_does_nothing_unless_every_condition_holds(kwargs):
    out = await dispatch(**kwargs)
    _, files = dispatch_commit(out)
    assert not any(p.startswith(("pages/", "legacy/", "composables/", "public/")) for p in files)
    assert out.result["static_conversion"] is None
    assert "Pages already converted" not in prompt_of(out)
    out.load.assert_not_awaited()  # not even the snapshot is read
    assert out.gh.tree_calls == []


@pytest.mark.asyncio
async def test_a_continuation_does_not_convert_again():
    out = await dispatch(continuation={"attempt": 1, "summary": "s"})
    assert out.result["static_conversion"] is None and out.gh.tree_calls == []


@pytest.mark.asyncio
async def test_a_snapshot_with_no_html_pages_changes_nothing():
    out = await dispatch(site={"assets/css/a.css": "a{}", "data.json": "{}"})
    assert out.result["static_conversion"] is None
    assert not any(p.startswith("pages/") for p in dispatch_commit(out)[1])


# ---- it can never fail the run ------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_an_unreadable_snapshot_means_no_conversion_not_a_failed_dispatch():
    with patch("src.domain.codegen.import_snapshot.load_text_files", AsyncMock(side_effect=OSError("disk"))):
        out = await dispatch(site={})
    assert out.gh.dispatches  # the run was still dispatched
    assert out.result["static_conversion"] is None


@pytest.mark.asyncio
async def test_a_failure_to_list_the_branch_still_converts_and_tells_the_agent_to_delete_copies():
    out = await dispatch(github=FakeGitHub(tree_error=httpx.ConnectError("down")))
    _, files = dispatch_commit(out)
    assert "pages/index.vue" in files and not any(v is None for v in files.values())
    assert "delete it: a static file is served before the page" in prompt_of(out)


@pytest.mark.asyncio
async def test_a_bug_in_the_converter_is_contained():
    with patch("src.domain.codegen.static_conversion.plan_conversion", side_effect=RuntimeError("boom")):
        out = await dispatch()
    assert out.gh.dispatches and out.result["static_conversion"] is None
    assert not any(p.startswith("pages/") for p in dispatch_commit(out)[1])


# ---- GitHub: a None value deletes a path ----------------------------------------------------------------


class Response:
    def __init__(self, status=200, body=None):
        self.status_code, self._body = status, body or {}

    def json(self):
        return self._body

    def raise_for_status(self):
        if self.status_code >= 400:
            request = httpx.Request("POST", "https://api.github.test/x")
            raise httpx.HTTPStatusError("x", request=request, response=httpx.Response(self.status_code, request=request))


class RecordingClient:
    """The Git Data API calls create_commit makes, recorded."""

    def __init__(self, ref_status=200):
        self.trees, self.blobs, self.ref_status = [], [], ref_status

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        return False

    async def get(self, url, headers=None, params=None):
        if "/git/ref/heads/" in url:
            return Response(self.ref_status, {"object": {"sha": "p" * 40}})
        if "/git/trees/" in url:
            return Response(200, {"tree": [{"path": "a.txt", "type": "blob"}, {"path": "d", "type": "tree"},
                                           {"path": "d/b.txt", "type": "blob"}]})
        return Response(404)

    async def post(self, url, headers=None, json=None):
        if url.endswith("/git/blobs"):
            self.blobs.append(json)
            return Response(201, {"sha": f"b{len(self.blobs)}"})
        if url.endswith("/git/trees"):
            self.trees.append(json)
            return Response(201, {"sha": "t" * 40})
        if url.endswith("/git/commits"):
            return Response(201, {"sha": "c" * 40})
        return Response(201, {})

    async def patch(self, url, headers=None, json=None):
        return Response(200)


def github_client():
    client = GitHubAppClient.__new__(GitHubAppClient)
    client._headers = AsyncMock(return_value={})
    return client


@pytest.mark.asyncio
async def test_a_none_value_becomes_a_tree_entry_with_a_null_sha_and_no_blob():
    recorder = RecordingClient()
    with patch("src.integrations.github_client.httpx.AsyncClient", lambda: recorder):
        sha = await github_client().create_commit(
            "P2HS/p2hs-abc", "preview", {"pages/index.vue": "<template/>", "public/about.html": None}, "msg")
    assert sha == "c" * 40
    [tree] = recorder.trees
    entries = {e["path"]: e["sha"] for e in tree["tree"]}
    assert entries["public/about.html"] is None and entries["pages/index.vue"] == "b1"
    assert len(recorder.blobs) == 1  # nothing is uploaded for a deletion
    assert tree["base_tree"] == "p" * 40


@pytest.mark.asyncio
async def test_a_deletion_is_ignored_when_the_branch_does_not_exist_yet():
    recorder = RecordingClient(ref_status=404)
    with patch("src.integrations.github_client.httpx.AsyncClient", lambda: recorder):
        await github_client().create_commit("P2HS/p2hs-abc", "new", {"a.txt": "x", "gone.txt": None}, "msg")
    [tree] = recorder.trees
    assert [e["path"] for e in tree["tree"]] == ["a.txt"]  # there is nothing to delete from


@pytest.mark.asyncio
async def test_the_branchs_files_are_listed_as_blobs_only():
    recorder = RecordingClient()
    with patch("src.integrations.github_client.httpx.AsyncClient", lambda: recorder):
        paths = await github_client().list_tree_paths("P2HS/p2hs-abc", "p2hs/codegen/x")
    assert paths == {"a.txt", "d/b.txt"}  # directories are not files
