"""dispatch_codegen_actions_run in work-branch mode: the run's dispatch files and workflow run
live on p2hs/codegen/<run_id>, created once and never force-pushed, and `preview` is untouched.
With the setting off, behaviour is exactly the legacy one (commit on preview)."""

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 pytest
from src.config import get_settings
from src.domain.codegen import actions_pipeline
from src.domain.codegen.work_branch import new_state, work_branch_name

PREVIEW_SHA = "1" * 40
MOVED_SHA = "2" * 40
MAIN_SHA = "3" * 40


class FakeGitHub:
    """Records what would be pushed to GitHub. Refs are branch -> sha."""

    instances: list["FakeGitHub"] = []

    def __init__(self, installation_id=None):
        self.refs = {"main": MAIN_SHA, "preview": PREVIEW_SHA}
        self.created: list[tuple[str, str]] = []
        self.commits: list[tuple[str, dict, str]] = []
        self.dispatches: list[tuple[str, str, dict]] = []
        self.deleted: list[str] = []
        FakeGitHub.instances.append(self)

    async def delete_work_branch(self, repo, branch):
        self.deleted.append(branch)
        self.refs.pop(branch, None)
        return True

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

    async def create_branch_from_sha(self, repo, branch, sha):
        assert branch not in self.refs, "must never re-create an existing branch"
        self.refs[branch] = sha
        self.created.append((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"


SETUP_STAGE_FOR_TEST = {"value": "completed"}


@pytest.fixture
def run_dispatch():
    async def run(*, enabled, plan_context=None, github=None, continuation=None, run_id=None,
                  persist_ok=True):
        FakeGitHub.instances.clear()
        settings = get_settings().model_copy(
            update={
                "codegen_work_branch_enabled": enabled,
                "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",
                setup_stage=SETUP_STAGE_FOR_TEST["value"],
            ),
            type="html", project_ref="", workspace_id=uuid4(),
        )

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

        persisted = []

        async def fake_persist(db_factory, run_id, state):
            persisted.append(state)
            return persist_ok

        run_id = run_id or uuid4()
        fake = github

        def make_github(installation_id=None):
            return fake or FakeGitHub(installation_id)

        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", make_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.work_branch.persist_state", fake_persist):
            result = await actions_pipeline.dispatch_codegen_actions_run(
                db_factory=lambda: db(), run_id=run_id, project_id=uuid4(),
                system_prompt="sys", user_prompt="Make it functional",
                plan_context=plan_context or {}, continuation=continuation,
            )
        gh = fake or FakeGitHub.instances[-1]
        return SimpleNamespace(result=result, gh=gh, persisted=persisted, run_id=run_id)

    return run


def payload_of(outcome, branch):
    files = next(f for b, f, _ in outcome.gh.commits if b == branch)
    path = next(p for p in files if p.startswith(actions_pipeline.PAYLOAD_DIR))
    return json.loads(files[path])["payload"]


@pytest.mark.asyncio
async def test_first_dispatch_creates_the_run_branch_from_preview_and_leaves_preview_alone(run_dispatch):
    out = await run_dispatch(enabled=True)
    branch = work_branch_name(out.run_id)
    assert out.gh.created == [(branch, PREVIEW_SHA)]
    assert [b for b, _, _ in out.gh.commits if b == "preview"] == []  # nothing lands on preview
    assert [b for b, _, _ in out.gh.commits] == ["main", branch]  # workflow ensure + dispatch
    assert out.gh.dispatches[0][1] == branch  # the workflow runs on the work branch
    assert out.gh.refs["preview"] == PREVIEW_SHA
    assert out.result["work_branch"] == branch and out.result["branch"] == "preview"
    [state] = out.persisted
    assert state["branch"] == branch and state["base_sha"] == PREVIEW_SHA
    assert state["base_branch"] == "preview"


@pytest.mark.asyncio
async def test_the_payload_names_the_work_branch_the_base_and_the_promotion_target(run_dispatch):
    out = await run_dispatch(enabled=True)
    payload = payload_of(out, work_branch_name(out.run_id))
    assert payload["work_branch_name"] == work_branch_name(out.run_id)
    assert payload["work_base_sha"] == PREVIEW_SHA
    assert payload["branch"] == "preview"  # where the runner promotes after validation


@pytest.mark.asyncio
async def test_a_retry_reuses_the_branch_and_keeps_its_original_base_even_if_preview_moved(run_dispatch):
    first = await run_dispatch(enabled=True)
    branch = work_branch_name(first.run_id)
    gh = FakeGitHub()
    gh.refs.update({"preview": MOVED_SHA, branch: "9" * 40})  # branch exists, preview advanced
    stored = new_state(first.run_id, "preview", PREVIEW_SHA)
    # retry_run re-queues the SAME run row, so the run id is unchanged and the stored state
    # (persisted in plan.context by the first dispatch) comes back in the plan context.
    second = await run_dispatch(
        enabled=True, plan_context={"work_branch": stored}, github=gh, run_id=first.run_id
    )
    assert gh.created == []  # never re-created, never forced
    assert [b for b, _, _ in gh.commits if b == "preview"] == []
    assert [b for b, _, _ in gh.commits].count(branch) == 1  # appended a new commit
    assert second.persisted[0]["base_sha"] == PREVIEW_SHA  # NOT the moved preview head
    assert gh.dispatches[0][1] == branch

@pytest.mark.asyncio
async def test_an_existing_branch_with_no_trusted_state_fails_closed(run_dispatch):
    gh = FakeGitHub()
    # The run id is generated inside run_dispatch, so make every work branch "exist".
    real_get = gh.get_ref_head_sha

    async def everything_exists(repo, branch):
        return await real_get(repo, branch) or ("9" * 40 if branch.startswith("p2hs/codegen/") else None)

    gh.get_ref_head_sha = everything_exists
    with pytest.raises(RuntimeError, match="saved state is missing"):
        await run_dispatch(enabled=True, plan_context={}, github=gh)
    # not a fall back to committing on preview: nothing was written or dispatched at all
    assert gh.commits == [] and gh.dispatches == [] and gh.deleted == []


@pytest.mark.asyncio
async def test_a_state_that_cannot_be_saved_stops_the_dispatch_and_removes_the_new_branch(run_dispatch):
    gh = FakeGitHub()
    with pytest.raises(RuntimeError, match="Could not save the work-branch state"):
        await run_dispatch(enabled=True, github=gh, persist_ok=False)
    assert gh.commits == [] and gh.dispatches == []  # preview and the workflow untouched
    [branch] = gh.deleted  # the branch this call created is gone, so a retry starts clean
    assert branch.startswith("p2hs/codegen/") and branch not in gh.refs


@pytest.mark.asyncio
async def test_a_reused_branch_is_not_deleted_when_saving_its_state_fails(run_dispatch):
    first = await run_dispatch(enabled=True)
    branch = work_branch_name(first.run_id)
    gh = FakeGitHub()
    gh.refs[branch] = "9" * 40
    stored = new_state(first.run_id, "preview", PREVIEW_SHA)
    with pytest.raises(RuntimeError, match="Could not save"):
        await run_dispatch(
            enabled=True, plan_context={"work_branch": stored}, github=gh,
            run_id=first.run_id, persist_ok=False,
        )
    assert gh.deleted == [] and branch in gh.refs  # earlier work is never removed here


@pytest.mark.asyncio
async def test_with_the_setting_off_nothing_changes(run_dispatch):
    out = await run_dispatch(enabled=False)
    assert out.gh.created == [] and out.persisted == []
    assert [b for b, _, _ in out.gh.commits] == ["main", "preview"]
    assert out.gh.dispatches[0][1] == "preview"
    assert out.result["work_branch"] is None
    payload = payload_of(out, "preview")
    assert payload["work_branch_name"] is None and payload["work_base_sha"] is None
    assert payload["resume"] is None


@pytest.mark.asyncio
async def test_resume_and_correction_reach_the_runner_and_the_prompt(run_dispatch):
    correction = {
        "attempt": 1,
        "entries": [{"name": "contact_submission",
                     "errors": [{"path": "fields.0.public", "code": "unknown_key",
                                 "message": "not recognized"}]}],
    }
    out = await run_dispatch(
        enabled=True,
        plan_context={"resume": {"skip_agent": False, "last_checkpoint": "agent_output"},
                      "correction": correction},
    )
    payload = payload_of(out, work_branch_name(out.run_id))
    assert payload["resume"] == {"skip_agent": False, "last_checkpoint": "agent_output"}
    assert "Correction needed (attempt 1)" in payload["user_prompt"]
    assert "fields.0.public" in payload["user_prompt"]


@pytest.mark.asyncio
async def test_the_in_workflow_deploy_uses_the_promotion_target_not_the_work_branch():
    workflow = actions_pipeline.build_codegen_actions_workflow("html", "ref1")
    assert "deploy_branch: ${{ steps.codegen.outputs.deploy_branch }}" in workflow
    assert "--branch=${{ needs.codegen.outputs.deploy_branch || github.ref_name }}" in workflow
    runner = actions_pipeline.build_codegen_actions_runner()
    assert "deploy_branch=${deployBranch}" in runner


@pytest.mark.asyncio
async def test_default_branch_gets_workflow_while_setup_is_still_running(run_dispatch, monkeypatch):
    monkeypatch.setitem(SETUP_STAGE_FOR_TEST, "value", "local_workspace")
    out = await run_dispatch(enabled=True)
    # Codegen can be submitted before setup reaches its final DB stage. The workflow
    # must still be installed on the default branch so GitHub accepts workflow_dispatch;
    # setup's first push rebases safely onto this platform commit.
    assert [b for b, _, _ in out.gh.commits if b == "main"] == ["main"]
    assert [b for b, _, _ in out.gh.commits] == ["main", out.gh.commits[1][0]]
