"""Tests for the SDK, compatibility client, and CLI."""

from __future__ import annotations

from click.testing import CliRunner
import git as gitpython
import json
import pytest
from types import SimpleNamespace

from metabranch.cli import _format_git_command_error, cli
from metabranch.git import ReconciliationCommit
from metabranch.models import (
    BranchState,
    CheckoutResult,
    MetabranchDefinition,
    ReconciliationPlanEntry,
    RepoLock,
    RepoLockStatus,
    StatusResult,
    WorkflowJournal,
)
from metabranch.exceptions import (
    ActiveWorkflowError,
    BranchAlreadyExistsError,
    DirtyWorkingTreeError,
    EditorCancelledError,
    InvalidBranchError,
    InvalidMetabranchError,
    InvalidReconciliationPlanError,
    NoActiveWorkflowError,
    RepoLockError,
    WorkflowConflictError,
)
from metabranch.sdk import Clients, build_clients
from metabranch.sdk.workflows import WorkflowClient, _ResumeCursor
from tests.helpers import commit_file, git


def MetabranchSDK(*args, **kwargs) -> Clients:
    """Compatibility factory for tests while the runtime surface settles."""
    if "config_store" in kwargs:
        kwargs["tracked_config"] = kwargs.pop("config_store")
    return Clients(*args, **kwargs)


SDKClients = Clients


def test_sdk_create_checkout_list_status_and_branch_updates(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()

    assert sdk.is_plain_branch("feature-one") is True
    assert sdk.is_metabranch("feature-one") is False

    sdk.create("meta", "feature-one", "feature-two")
    assert sdk.is_metabranch("meta") is True
    assert git(repo_with_branches, "status", "--short") == ""
    assert git(repo_with_branches, "log", "-1", "--format=%s") == "metabranch: initialize config"
    assert (repo_with_branches / "feature_one.txt").read_text() == "one\n"
    assert (repo_with_branches / "feature_two.txt").read_text() == "two\n"
    assert (
        git(repo_with_branches, "log", "-1", "--format=%B").strip().endswith(
            "Metabranch-Metadata: true"
        )
        is True
    )
    with pytest.raises(BranchAlreadyExistsError):
        sdk.create("meta")

    listed = sdk.list()
    assert [definition.name for definition in listed] == ["meta"]

    status = sdk.status()
    assert isinstance(status, StatusResult)
    assert status.branch_name == "meta"
    assert status.is_metabranch is True
    assert len(status.branch_states) == 2
    assert status.branch_states[0].in_metabranch is True
    assert status.branch_states[0].not_in_metabranch == 0

    sdk.remove_branches("feature-two")
    assert git(repo_with_branches, "log", "-1", "--format=%s") == "metabranch: update config"
    assert sdk.status().statuses[0].startswith("On branch")
    sdk.add_branches("feature-two")
    sdk.update_branches(added=("feature-three",), removed=("feature-three",))
    assert "feature-three" not in sdk.status().statuses
    with pytest.raises(InvalidBranchError):
        sdk.add_branches("missing")
    sdk.update_branches(added=("missing",), removed=("missing",))

    with pytest.raises(InvalidMetabranchError, match="regular Git branch"):
        sdk.checkout("feature-one")
    assert git(repo_with_branches, "branch", "--show-current") == "meta"
    meta_checkout = sdk.checkout("meta")
    assert meta_checkout == CheckoutResult(branch_name="meta", is_metabranch=True)


def test_sdk_recreate_deleted_metabranch_after_stale_registry_entry(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    git(repo_with_branches, "checkout", "master")
    git(repo_with_branches, "branch", "-D", "meta")

    assert sdk.is_metabranch("meta") is False
    sdk.create("meta", "feature-two")
    assert sdk.is_metabranch("meta") is True
    assert git(repo_with_branches, "branch", "--show-current") == "meta"
    assert sdk.status().metabranch is not None
    assert sdk.status().metabranch.branches == ("feature-two",)


def test_sdk_create_without_contained_branches_leaves_base_checkout(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()

    sdk.create("meta")

    assert sdk.is_metabranch("meta") is True
    assert git(repo_with_branches, "branch", "--show-current") == "meta"
    assert (repo_with_branches / "feature_one.txt").exists() is False
    assert (repo_with_branches / "feature_two.txt").exists() is False
    assert git(repo_with_branches, "log", "-1", "--format=%s") == "metabranch: initialize config"


def test_sdk_create_without_checkout_returns_to_original_branch(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()

    sdk.repository.create("meta", "feature-one", checkout=False)

    assert sdk.is_metabranch("meta") is True
    assert git(repo_with_branches, "branch", "--show-current") == "master"
    assert (repo_with_branches / "feature_one.txt").exists() is False
    assert sdk.status().is_metabranch is False


def test_sdk_branch_updates_auto_rebuild_metabranch(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")
    sdk.rebase()

    assert (repo_with_branches / "feature_one.txt").read_text() == "one\n"
    assert (repo_with_branches / "feature_two.txt").exists() is False

    sdk.add_branches("feature-two")
    assert (repo_with_branches / "feature_two.txt").read_text() == "two\n"

    sdk.remove_branches("feature-two")
    assert (repo_with_branches / "feature_two.txt").exists() is False


def test_sdk_branch_updates_require_clean_tree(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    (repo_with_branches / "README.md").write_text("dirty\n")
    with pytest.raises(
        DirtyWorkingTreeError,
        match="changing contained branches rebuilds the metabranch",
    ):
        sdk.add_branches("feature-two")


def test_sdk_requires_metabranch(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()

    status = sdk.status()
    assert status.is_metabranch is False
    assert status.notices == ("Current branch master is not a metabranch.",)
    assert sdk.active_workflow() is None
    with pytest.raises(InvalidMetabranchError):
        sdk.add_branches("feature-one")
    with pytest.raises(InvalidBranchError, match="does not exist"):
        sdk.checkout("missing")
    with pytest.raises(InvalidMetabranchError, match="already exists as a regular Git branch"):
        sdk.create("feature-one")


def test_sdk_rebuild_modes_and_push(monkeypatch, repo_with_branches, bare_remote) -> None:
    monkeypatch.chdir(repo_with_branches)
    git(repo_with_branches, "remote", "add", "origin", str(bare_remote))
    git(repo_with_branches, "push", "-u", "origin", "master")
    git(repo_with_branches, "push", "-u", "origin", "feature-one")
    git(repo_with_branches, "push", "-u", "origin", "feature-two")

    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")
    git(repo_with_branches, "push", "-u", "origin", "meta")
    pushed: list[tuple[str, bool]] = []
    monkeypatch.setattr(
        sdk.git_client,
        "push",
        lambda name, force=False: pushed.append((name, force)),
    )
    sdk.push()
    sdk.push(recursive=True)
    assert pushed == [("meta", False), ("feature-one", False), ("meta", False)]
    sdk.rebase()

    git(repo_with_branches, "checkout", "master")
    commit_file(repo_with_branches, "base.txt", "base\n", "Base update")
    sdk.checkout("meta")
    sdk.rebase("master", recursive=True)

    assert sdk.status().branch_name == "meta"


def test_sdk_rebase_reconstructs_multiple_independent_branches(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one", "feature-two")

    sdk.rebase()

    assert git(repo_with_branches, "branch", "--show-current") == "meta"
    assert (repo_with_branches / "feature_one.txt").read_text() == "one\n"
    assert (repo_with_branches / "feature_two.txt").read_text() == "two\n"
    assert git(repo_with_branches, "rev-list", "--count", "--merges", "meta") == "0"


def test_sdk_rebase_skips_synthetic_integration_merges_on_repeat(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one", "feature-two")

    sdk.rebase()
    sdk.rebase()

    assert git(repo_with_branches, "branch", "--show-current") == "meta"
    assert (repo_with_branches / "feature_one.txt").read_text() == "one\n"
    assert (repo_with_branches / "feature_two.txt").read_text() == "two\n"
    assert git(repo_with_branches, "rev-list", "--count", "--merges", "meta") == "0"


def test_sdk_pull_refreshes_remote_branch(
    monkeypatch, repo_with_branches, bare_remote, tmp_path
) -> None:
    monkeypatch.chdir(repo_with_branches)
    git(repo_with_branches, "remote", "add", "origin", str(bare_remote))
    git(repo_with_branches, "push", "-u", "origin", "master")
    git(repo_with_branches, "push", "-u", "origin", "feature-one")

    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")
    git(repo_with_branches, "push", "-u", "origin", "meta")

    other = tmp_path / "other"
    git(tmp_path, "clone", str(bare_remote), str(other))
    git(other, "config", "user.name", "Metabranch Tester")
    git(other, "config", "user.email", "tester@example.com")
    git(other, "checkout", "feature-one")
    commit_file(other, "feature_one.txt", "remote-change\n", "Remote feature update")
    git(other, "push")

    assert (repo_with_branches / "feature_one.txt").read_text() == "one\n"
    sdk.pull()
    assert (repo_with_branches / "feature_one.txt").read_text() == "one\n"
    sdk.pull("master", recursive=True)
    assert (repo_with_branches / "feature_one.txt").read_text() == "remote-change\n"
    assert sdk.status().branch_name == "meta"


def test_sdk_status_reports_tracking_and_remote_config(
    monkeypatch, repo_with_branches, bare_remote
) -> None:
    monkeypatch.chdir(repo_with_branches)
    git(repo_with_branches, "remote", "add", "origin", str(bare_remote))
    git(repo_with_branches, "push", "-u", "origin", "master")
    git(repo_with_branches, "push", "-u", "origin", "feature-one")

    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")
    git(repo_with_branches, "push", "-u", "origin", "meta")

    status = sdk.status()

    assert status.tracking_branch == "origin/meta"
    assert status.ahead == 0
    assert status.behind == 0
    assert status.remote_config_created_at is not None
    assert status.remote_config_updated_at is not None
    assert status.remote_head_updated_at is not None
    assert status.branch_states[0].in_metabranch is True
    assert status.branch_states[0].not_in_metabranch == 0
    assert status.branch_states[0].tracking_branch == "origin/feature-one"
    assert status.branch_states[0].ahead == 0
    assert status.branch_states[0].behind == 0


def test_sdk_status_reports_branch_commits_missing_from_metabranch(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one", "feature-two")

    git(repo_with_branches, "checkout", "feature-two")
    commit_file(
        repo_with_branches,
        "feature_two.txt",
        "two-local\n",
        "Feature two local update",
    )
    git(repo_with_branches, "checkout", "meta")

    status = sdk.status()
    feature_two = next(
        branch_state
        for branch_state in status.branch_states
        if branch_state.name == "feature-two"
    )

    assert feature_two.in_metabranch is False
    assert feature_two.not_in_metabranch == 1
    assert feature_two.tracking_branch is None
    assert feature_two.ahead is None
    assert feature_two.behind is None


def test_sdk_pull_with_base_rebuilds_only_metabranch(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")
    observed: dict[str, object] = {}

    def fake_rebase(*, base=None, recursive=False, interactive=False, pull=False) -> None:
        observed.update(
            {
                "base": base,
                "recursive": recursive,
                "interactive": interactive,
                "pull": pull,
            }
        )

    monkeypatch.setattr(sdk, "rebase", fake_rebase)

    sdk.pull("master")

    assert observed == {
        "base": "master",
        "recursive": False,
        "interactive": False,
        "pull": False,
    }


def test_sdk_short_operations_respect_repo_lock(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.local_state.acquire_repo_lock(owner="other")

    with pytest.raises(RepoLockError):
        sdk.create("meta", "feature-one")

    sdk.local_state.release_repo_lock("other")
    sdk.create("meta", "feature-one")
    sdk.local_state.acquire_repo_lock(owner="other")
    with pytest.raises(RepoLockError):
        sdk.push()


def test_sdk_short_operations_surface_stale_lock_guidance(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.local_state.ensure_layout()
    sdk.local_state.repo_lock_path.write_text(
        json.dumps(
            RepoLock(
                owner="stale-op",
                operation="push",
                acquired_at="2000-01-01T00:00:00+00:00",
            ).model_dump(mode="json")
        )
    )

    with pytest.raises(RepoLockError, match="Run `metabranch abort` to clear it."):
        sdk.create("meta", "feature-one")


def test_sdk_abort_restores_branch_heads(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    meta_before = git(repo_with_branches, "rev-parse", "meta")
    git(repo_with_branches, "checkout", "master")
    commit_file(repo_with_branches, "base.txt", "base\n", "Base update")
    git(repo_with_branches, "checkout", "meta")

    monkeypatch.setattr(
        sdk.git_client,
        "cherry_pick",
        lambda commit_sha, record_origin=False: (_ for _ in ()).throw(
            RuntimeError("boom")
        ),
    )

    with pytest.raises(RuntimeError, match="boom"):
        sdk.rebase("master")

    active_workflow = sdk.local_state.get_active_workflow()
    assert active_workflow is not None
    assert active_workflow.status == "failed"
    assert sdk.local_state.load_repo_lock() is not None
    sdk.abort()

    assert git(repo_with_branches, "rev-parse", "meta") == meta_before
    assert sdk.local_state.get_active_workflow() is None
    assert sdk.local_state.load_repo_lock() is None


def test_sdk_abort_preflight_failure_cleans_tool_config(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    journal = WorkflowJournal(
        workflow_id="wf-preflight",
        workflow_type="rebase",
        metabranch_name="meta",
        status="failed",
        current_step="preflight",
        checkpoints={
            "return_branch": "meta",
            "ref:feature-one": git(repo_with_branches, "rev-parse", "feature-one"),
            "ref:meta": git(repo_with_branches, "rev-parse", "meta"),
        },
    )
    sdk.local_state.save_workflow(journal)
    sdk.local_state.set_active("meta", workflow_id=journal.workflow_id)
    sdk.local_state.acquire_repo_lock(
        owner=journal.workflow_id,
        workflow_id=journal.workflow_id,
        operation=journal.workflow_type,
        metabranch_name=journal.metabranch_name,
    )

    config_path = repo_with_branches / ".metabranch.yaml"
    config_path.write_text(config_path.read_text() + "\n# dirty\n")
    assert sdk.git_client.path_has_changes(".metabranch.yaml") is True

    sdk.abort()

    assert sdk.local_state.get_active_workflow() is None
    assert sdk.local_state.load_repo_lock() is None
    assert sdk.git_client.path_has_changes(".metabranch.yaml") is False


def test_sdk_abort_preflight_failure_without_dirty_config(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    journal = WorkflowJournal(
        workflow_id="wf-preflight-clean",
        workflow_type="rebase",
        metabranch_name="meta",
        status="failed",
        current_step="preflight",
        checkpoints={
            "return_branch": "meta",
            "ref:feature-one": git(repo_with_branches, "rev-parse", "feature-one"),
            "ref:meta": git(repo_with_branches, "rev-parse", "meta"),
        },
    )
    sdk.local_state.save_workflow(journal)
    sdk.local_state.set_active("meta", workflow_id=journal.workflow_id)
    sdk.local_state.acquire_repo_lock(
        owner=journal.workflow_id,
        workflow_id=journal.workflow_id,
        operation=journal.workflow_type,
        metabranch_name=journal.metabranch_name,
    )

    sdk.abort()

    assert sdk.local_state.get_active_workflow() is None
    assert sdk.local_state.load_repo_lock() is None


def test_sdk_resume_replays_active_workflow(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    original_cherry_pick = sdk.git_client.cherry_pick
    state = {"failed": False}

    def fail_once(commit_sha: str, *, record_origin: bool = False) -> None:
        if state["failed"] is False:
            state["failed"] = True
            raise RuntimeError("boom")
        original_cherry_pick(commit_sha, record_origin=record_origin)

    monkeypatch.setattr(sdk.git_client, "cherry_pick", fail_once)

    with pytest.raises(RuntimeError, match="boom"):
        sdk.rebase()

    sdk.resume()
    assert sdk.local_state.get_active_workflow() is None
    assert sdk.local_state.load_repo_lock() is None
    assert sdk.status().branch_name == "meta"


def test_sdk_allows_compat_custom_attributes() -> None:
    sdk = MetabranchSDK()

    sdk.custom_value = "kept-on-wrapper"

    assert sdk.custom_value == "kept-on-wrapper"


def test_sdk_blocks_mutations_when_workflow_active(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    monkeypatch.setattr(
        sdk.git_client,
        "cherry_pick",
        lambda commit_sha, record_origin=False: (_ for _ in ()).throw(
            gitpython.GitCommandError("cherry-pick", 1, stderr="boom")
        ),
    )

    with pytest.raises(gitpython.GitCommandError):
        sdk.rebase()

    status = sdk.status()
    assert any("Active workflow:" in line for line in status.statuses)
    assert status.repo_lock is not None
    with pytest.raises(ActiveWorkflowError):
        sdk.pull()
    with pytest.raises(ActiveWorkflowError):
        sdk.push()
    with pytest.raises(ActiveWorkflowError):
        sdk.add_branches("feature-two")
    sdk.abort()


def test_sdk_resume_and_abort_require_active_workflow(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()

    with pytest.raises(NoActiveWorkflowError):
        sdk.resume()
    with pytest.raises(NoActiveWorkflowError):
        sdk.abort()


def test_sdk_abort_clears_stale_lock_without_active_workflow(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.local_state.ensure_layout()
    sdk.local_state.repo_lock_path.write_text(
        json.dumps(
            RepoLock(
                owner="wf-1",
                workflow_id="wf-1",
                operation="rebase",
                metabranch_name="meta",
            ).model_dump(mode="json")
        )
    )

    sdk.abort()
    assert sdk.local_state.load_repo_lock() is None


def test_sdk_abort_rejects_live_lock_without_active_workflow(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.local_state.acquire_repo_lock(owner="other")

    with pytest.raises(RepoLockError, match="has no resumable workflow"):
        sdk.abort()


def test_sdk_rebase_reconciles_direct_commits(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    observed: dict[str, str] = {}

    def assign_feature_one(initial_text: str, path) -> str:
        observed["initial"] = initial_text
        return initial_text.replace("? ", "feature-one ", 1)

    sdk = MetabranchSDK(reconciliation_editor=assign_feature_one)
    sdk.create("meta", "feature-one")
    sdk.rebase()
    commit_file(repo_with_branches, "feature_one.txt", "reconciled\n", "Reconcile me")

    sdk.rebase()

    assert "? " in observed["initial"]
    assert git(repo_with_branches, "show", "feature-one:feature_one.txt") == "reconciled"
    assert git(repo_with_branches, "log", "-1", "--format=%s", "feature-one") == "Reconcile me"


def test_sdk_commit_reconciles_directly(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)

    def assign_feature_one(initial_text: str, path) -> str:
        return initial_text.replace("? ", "feature-one ", 1)

    sdk = MetabranchSDK(reconciliation_editor=assign_feature_one)
    sdk.create("meta", "feature-one")

    (repo_with_branches / "feature_one.txt").write_text("committed\n")
    git(repo_with_branches, "add", "feature_one.txt")

    sdk.commit("-m", "Commit and reconcile")

    assert git(repo_with_branches, "log", "-1", "--format=%s", "feature-one") == (
        "Commit and reconcile"
    )
    assert git(repo_with_branches, "branch", "--show-current") == "meta"
    assert git(repo_with_branches, "status", "--short") == ""


def test_sdk_rebase_reconciliation_invalid_plan(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK(reconciliation_editor=lambda initial_text, path: initial_text)
    sdk.create("meta", "feature-one")
    sdk.rebase()
    commit_file(repo_with_branches, "feature_one.txt", "bad-plan\n", "Needs assignment")

    with pytest.raises(
        EditorCancelledError,
        match="cancelled before every commit was assigned",
    ):
        sdk.rebase()

    assert sdk.local_state.get_active_workflow() is None
    assert sdk.local_state.load_repo_lock() is None

    sdk.reconciliation_editor = (
        lambda initial_text, path: initial_text.replace("? ", "feature-one ", 1)
    )
    sdk.rebase()
    assert git(repo_with_branches, "log", "-1", "--format=%s", "feature-one") == (
        "Needs assignment"
    )


def test_sdk_rebase_reconciliation_drop_action(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)

    def drop_commit(initial_text: str, path) -> str:
        return initial_text.replace("? ", "d ", 1)

    sdk = MetabranchSDK(reconciliation_editor=drop_commit)
    sdk.create("meta", "feature-one")
    sdk.rebase()
    commit_file(repo_with_branches, "feature_one.txt", "drop-me\n", "Drop me")

    sdk.rebase()

    assert git(repo_with_branches, "log", "-1", "--format=%s", "feature-one") != "Drop me"
    assert git(repo_with_branches, "show", "meta:feature_one.txt") == "one"


def test_sdk_rebase_ignores_previous_base_commits_when_base_swings_back(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    git(repo_with_branches, "checkout", "-b", "base-two")
    commit_file(repo_with_branches, "base_two.txt", "base-two\n", "Base two")
    git(repo_with_branches, "checkout", "master")

    sdk = MetabranchSDK(
        reconciliation_editor=lambda initial_text, path: (_ for _ in ()).throw(
            AssertionError("reconciliation editor should not open")
        )
    )
    sdk.create("meta", "feature-one")

    sdk.rebase("base-two")
    assert (repo_with_branches / "base_two.txt").read_text() == "base-two\n"

    sdk.rebase("master")
    assert (repo_with_branches / "base_two.txt").exists() is False


def test_sdk_rebase_preflight_only_cancellation_clears_active_workflow(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    def fail_preflight(**kwargs) -> None:
        raise InvalidReconciliationPlanError("bad plan")

    monkeypatch.setattr(sdk, "_run_sync_workflow", fail_preflight)

    with pytest.raises(InvalidReconciliationPlanError, match="bad plan"):
        sdk.rebase()

    assert sdk.local_state.get_active_workflow() is None
    assert sdk.local_state.load_repo_lock() is None


def test_sdk_rebase_post_preflight_reconciliation_error_keeps_workflow_active(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")

    def fail_after_step(**kwargs) -> None:
        journal = kwargs["journal"]
        sdk.local_state.save_workflow(journal.with_updates(current_step="rebase:feature-one"))
        raise InvalidReconciliationPlanError("bad plan")

    monkeypatch.setattr(sdk, "_run_sync_workflow", fail_after_step)

    with pytest.raises(InvalidReconciliationPlanError, match="bad plan"):
        sdk.rebase()

    active_workflow = sdk.local_state.get_active_workflow()
    assert active_workflow is not None
    assert active_workflow.status == "failed"
    assert active_workflow.current_step == "rebase:feature-one"
    assert sdk.local_state.load_repo_lock() is not None


def test_sdk_rebase_reconciliation_saved_plan_reused_on_resume(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)
    editor_calls = {"count": 0}

    def assign_feature_one(initial_text: str, path) -> str:
        editor_calls["count"] += 1
        return initial_text.replace("? ", "feature-one ", 1)

    sdk = MetabranchSDK(reconciliation_editor=assign_feature_one)
    sdk.create("meta", "feature-one")
    sdk.rebase()
    commit_file(repo_with_branches, "feature_one.txt", "resume\n", "Resume reconcile")

    original_cherry_pick = sdk.git_client.cherry_pick
    state = {"failed": False}

    def fail_once(commit_sha: str, *, record_origin: bool = False) -> None:
        if state["failed"] is False:
            state["failed"] = True
            raise RuntimeError("boom")
        original_cherry_pick(commit_sha, record_origin=record_origin)

    monkeypatch.setattr(sdk.git_client, "cherry_pick", fail_once)
    with pytest.raises(RuntimeError, match="boom"):
        sdk.rebase()

    sdk.reconciliation_editor = lambda initial_text, path: (_ for _ in ()).throw(
        EditorCancelledError("editor should not reopen")
    )
    sdk.resume()
    assert editor_calls["count"] == 1
    assert git(repo_with_branches, "log", "-1", "--format=%s", "feature-one") == "Resume reconcile"


def test_sdk_resume_continues_real_reconciliation_cherry_pick_without_reapplying(
    monkeypatch, repo_with_branches
) -> None:
    monkeypatch.chdir(repo_with_branches)

    def assign_feature_one(initial_text: str, path) -> str:
        return initial_text.replace("? ", "feature-one ", 1)

    sdk = MetabranchSDK(reconciliation_editor=assign_feature_one)
    sdk.create("meta", "feature-one")
    sdk.rebase()

    commit_file(repo_with_branches, "feature_one.txt", "meta version\n", "Meta reconcile")
    git(repo_with_branches, "checkout", "feature-one")
    commit_file(repo_with_branches, "feature_one.txt", "branch version\n", "Branch conflict")
    git(repo_with_branches, "checkout", "meta")

    with pytest.raises(gitpython.GitCommandError):
        sdk.rebase()

    active = sdk.local_state.get_active_workflow()
    assert active is not None
    assert active.current_step.startswith("reconcile:")
    assert git(repo_with_branches, "branch", "--show-current") == "feature-one"

    (repo_with_branches / "feature_one.txt").write_text("resolved version\n")
    git(repo_with_branches, "add", "feature_one.txt")
    sdk.reconciliation_editor = lambda initial_text, path: (_ for _ in ()).throw(
        AssertionError("reconciliation editor should not reopen")
    )

    sdk.resume()

    assert sdk.local_state.get_active_workflow() is None
    assert sdk.local_state.load_repo_lock() is None
    assert git(repo_with_branches, "log", "--format=%s", "feature-one").splitlines().count(
        "Meta reconcile"
    ) == 1
    assert git(repo_with_branches, "show", "feature-one:feature_one.txt") == "resolved version"


class ResumeGitClient:
    """Fake git client for resume-path SDK tests."""

    def __init__(self) -> None:
        self.calls: list[tuple[str, str, bool | None]] = []
        self.cleaned_up = False
        self.operation: str | None = None
        self.pending_changes = False
        self.unmerged_paths = False
        self.current_branch = "meta"
        self.continue_error: gitpython.GitCommandError | None = None

    def abort_in_progress_operation(self) -> None:
        self.cleaned_up = True
        self.calls.append(("abort_in_progress_operation", "", None))

    def in_progress_operation(self) -> str | None:
        return self.operation

    def continue_in_progress_operation(self) -> str | None:
        if self.operation is not None:
            self.calls.append(("continue_in_progress_operation", self.operation, None))
        if self.continue_error is not None:
            raise self.continue_error
        return self.operation

    def skip_in_progress_operation(self) -> str | None:
        self.calls.append(("skip_in_progress_operation", self.operation or "", None))
        return self.operation

    def checkout(self, name: str, create: bool = False) -> None:
        assert self.cleaned_up is True
        self.calls.append(("checkout", name, create))
        self.current_branch = name

    def reset_hard(self, target: str) -> None:
        self.calls.append(("reset_hard", target, None))

    def has_unmerged_paths(self) -> bool:
        return self.unmerged_paths

    def has_pending_changes(self) -> bool:
        return self.pending_changes

    def commit_message(self, commit_sha: str) -> str:
        return f"Replay {commit_sha}"

    def commit(self, message: str) -> None:
        self.calls.append(("commit", message, None))

    def current_branch_name(self) -> str:
        return self.current_branch


class ResumeConfigStore:
    """Fake config store for resume-path SDK tests."""

    def __init__(self, definition: MetabranchDefinition | None) -> None:
        self.definition = definition

    def load_from_branch(self, name: str) -> MetabranchDefinition | None:
        return self.definition


class ResumeLocalState:
    """Fake local state for resume-path SDK tests."""

    def __init__(
        self,
        journal,
        definition: MetabranchDefinition | None,
    ) -> None:
        self.journal = journal
        self.definition = definition
        self.saved: list = []
        self.active_workflow_id: str | None = journal.workflow_id if journal else None
        self.repo_lock = None

    def get_active_workflow(self):
        return self.journal

    def get_definition(self, name: str) -> MetabranchDefinition | None:
        return self.definition

    def save_workflow(self, journal) -> None:
        self.saved.append(journal)
        self.journal = journal

    def load_workflow(self, workflow_id: str):
        return self.journal if self.journal.workflow_id == workflow_id else None

    def set_active_workflow(self, workflow_id: str | None) -> None:
        self.active_workflow_id = workflow_id

    def acquire_repo_lock(self, **kwargs):
        self.repo_lock = RepoLock(
            owner=kwargs["owner"],
            workflow_id=kwargs.get("workflow_id"),
            operation=kwargs.get("operation"),
            metabranch_name=kwargs.get("metabranch_name"),
        )
        return self.repo_lock

    def release_repo_lock(self, owner: str | None = None) -> None:
        self.repo_lock = None

    def load_repo_lock(self):
        return self.repo_lock

    def inspect_repo_lock(self):
        if self.repo_lock is None:
            return None
        return RepoLockStatus(lock=self.repo_lock)

    def get_active(self):
        return "meta"


class SyncRunGitClient:
    """Fake git client for direct workflow-step tests."""

    def __init__(self, commits_by_branch=None) -> None:
        self.calls: list[tuple[str, object, object | None]] = []
        self.commits_by_branch = commits_by_branch or {}

    def pull(self, branch_name: str) -> None:
        self.calls.append(("pull", branch_name, None))

    def checkout(self, branch_name: str, create: bool = False) -> None:
        self.calls.append(("checkout", branch_name, create))

    def rebase(self, base_ref: str, interactive: bool = False) -> None:
        self.calls.append(("rebase", base_ref, interactive))

    def list_unique_commits(
        self,
        branch_name: str,
        exclude_refs: tuple[str, ...],
    ) -> list[ReconciliationCommit]:
        self.calls.append(("list_unique_commits", branch_name, exclude_refs))
        return list(self.commits_by_branch.get(branch_name, ()))

    def reset_hard(self, base_ref: str) -> None:
        self.calls.append(("reset_hard", base_ref, None))

    def cherry_pick(self, commit_sha: str, *, record_origin: bool = False) -> None:
        self.calls.append(("cherry_pick", commit_sha, record_origin))


class SyncRunLocalState:
    """Fake local state for direct workflow-step tests."""

    def __init__(self) -> None:
        self.saved: list[WorkflowJournal] = []
        self.active: str | None = None
        self.active_workflow_id: str | None = "wf-1"
        self.released: list[str | None] = []

    def save_workflow(self, journal: WorkflowJournal) -> None:
        self.saved.append(journal)

    def set_active(self, name: str, workflow_id: str | None = None) -> None:
        self.active = name
        self.active_workflow_id = workflow_id

    def set_active_workflow(self, workflow_id: str | None) -> None:
        self.active_workflow_id = workflow_id

    def release_repo_lock(self, owner: str | None = None) -> None:
        self.released.append(owner)


class SyncRunContext:
    """Fake context for direct workflow-step tests."""

    def __init__(self, git_client: SyncRunGitClient) -> None:
        self.git_client = git_client
        self.local_state = SyncRunLocalState()
        self.steps: list[str] = []
        self.progress_messages: list[str] = []
        self.saved_definition: MetabranchDefinition | None = None

    def save_workflow_step(
        self,
        journal: WorkflowJournal,
        step: str,
    ) -> WorkflowJournal:
        self.steps.append(step)
        updated = journal.with_updates(status="running", current_step=step)
        self.local_state.save_workflow(updated)
        return updated

    def save_definition(self, definition: MetabranchDefinition, initialize: bool = False) -> bool:
        self.saved_definition = definition
        return True

    def progress_reporter(self, message: str) -> None:
        self.progress_messages.append(message)


def test_sdk_resume_uses_local_definition_fallback(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        args={"base": ""},
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    git_client = ResumeGitClient()
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=git_client,
        config_store=ResumeConfigStore(None),
        local_state=local_state,
    )
    captured: dict[str, object] = {}

    def fake_run_sync_workflow(**kwargs) -> None:
        captured.update(kwargs)

    monkeypatch.setattr(sdk, "_run_sync_workflow", fake_run_sync_workflow)

    sdk.resume()
    assert captured["definition"] == definition
    assert captured["base_ref"] == "master"
    assert local_state.saved[0].current_step == "resume"
    assert local_state.repo_lock is not None
    assert git_client.calls[0] == ("abort_in_progress_operation", "", None)
    assert git_client.calls[1] == ("checkout", "one", False)


def test_resume_cursor_skips_until_saved_step_then_runs() -> None:
    cursor = _ResumeCursor("replay:one:abc123")

    assert cursor.should_run("pull:one") is False
    assert cursor.should_run("replay:one:abc123") is False
    assert cursor.should_run("replay:two:def456") is True
    assert cursor.should_run("completed") is True
    assert _ResumeCursor(None).should_run("anything") is True


def test_workflow_run_sync_resume_skips_completed_pull_step(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one", "two"))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="pull",
        metabranch_name="meta",
    )
    git_client = SyncRunGitClient()
    context = SyncRunContext(git_client)
    workflow = WorkflowClient(context)
    monkeypatch.setattr(
        workflow,
        "_reconcile_direct_commits",
        lambda journal, **kwargs: journal,
    )

    workflow._run_sync_workflow(
        journal=journal,
        definition=definition,
        base_ref="master",
        pull_branches=True,
        rebase_branches=False,
        interactive=False,
        resume_after_step="pull:one",
    )

    assert "pull:one" not in context.steps
    assert "pull:two" in context.steps
    assert ("pull", "one", None) not in git_client.calls
    assert ("pull", "two", None) in git_client.calls


def test_workflow_run_sync_reports_progress_messages(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
    )
    git_client = SyncRunGitClient(
        commits_by_branch={
            "one": [
                ReconciliationCommit(
                    sha="abc1234",
                    subject="One",
                    message="One",
                )
            ]
        }
    )
    context = SyncRunContext(git_client)
    workflow = WorkflowClient(context)
    monkeypatch.setattr(
        workflow,
        "_reconcile_direct_commits",
        lambda journal, **kwargs: journal,
    )

    workflow._run_sync_workflow(
        journal=journal,
        definition=definition,
        base_ref="master",
        pull_branches=False,
        rebase_branches=False,
        interactive=False,
    )

    assert context.progress_messages == [
        "Rebuilding metabranch contents",
        "Updating metabranch metadata",
    ]


def test_workflow_run_sync_opens_interactive_metabranch_rebase(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
    )
    git_client = SyncRunGitClient(
        commits_by_branch={
            "one": [
                ReconciliationCommit(
                    sha="abc1234",
                    subject="One",
                    message="One",
                )
            ]
        }
    )
    context = SyncRunContext(git_client)
    workflow = WorkflowClient(context)
    monkeypatch.setattr(
        workflow,
        "_reconcile_direct_commits",
        lambda journal, **kwargs: journal,
    )

    workflow._run_sync_workflow(
        journal=journal,
        definition=definition,
        base_ref="master",
        pull_branches=False,
        rebase_branches=False,
        interactive=True,
    )

    assert "interactive:metabranch" in context.steps
    assert ("rebase", "master", True) in git_client.calls
    assert "Opening interactive rebase for metabranch" in context.progress_messages


def test_workflow_run_sync_reports_branch_pull_progress_once(monkeypatch) -> None:
    definition = MetabranchDefinition(
        name="meta",
        base_ref="master",
        branches=("one", "two"),
    )
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="pull",
        metabranch_name="meta",
    )
    context = SyncRunContext(SyncRunGitClient())
    workflow = WorkflowClient(context)
    monkeypatch.setattr(
        workflow,
        "_reconcile_direct_commits",
        lambda journal, **kwargs: journal,
    )

    workflow._run_sync_workflow(
        journal=journal,
        definition=definition,
        base_ref="master",
        pull_branches=True,
        rebase_branches=False,
        interactive=False,
    )

    assert context.progress_messages.count("Pulling contained branches") == 1


def test_workflow_run_sync_reports_branch_rebase_progress_once(monkeypatch) -> None:
    definition = MetabranchDefinition(
        name="meta",
        base_ref="master",
        branches=("one", "two"),
    )
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
    )
    context = SyncRunContext(SyncRunGitClient())
    workflow = WorkflowClient(context)
    monkeypatch.setattr(
        workflow,
        "_reconcile_direct_commits",
        lambda journal, **kwargs: journal,
    )

    workflow._run_sync_workflow(
        journal=journal,
        definition=definition,
        base_ref="master",
        pull_branches=False,
        rebase_branches=True,
        interactive=False,
    )

    assert (
        context.progress_messages.count("Rebasing contained branches onto master") == 1
    )


def test_workflow_run_sync_resume_skips_completed_rebase_step(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one", "two"))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
    )
    git_client = SyncRunGitClient()
    context = SyncRunContext(git_client)
    workflow = WorkflowClient(context)
    monkeypatch.setattr(
        workflow,
        "_reconcile_direct_commits",
        lambda journal, **kwargs: journal,
    )

    workflow._run_sync_workflow(
        journal=journal,
        definition=definition,
        base_ref="master",
        pull_branches=False,
        rebase_branches=True,
        interactive=True,
        resume_after_step="rebase:one",
    )

    assert "rebase:one" not in context.steps
    assert "rebase:two" in context.steps
    assert ("rebase", "master", True) in git_client.calls


def test_workflow_run_sync_resume_skips_completed_interactive_metabranch_step(
    monkeypatch,
) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
    )
    git_client = SyncRunGitClient()
    context = SyncRunContext(git_client)
    workflow = WorkflowClient(context)
    monkeypatch.setattr(
        workflow,
        "_reconcile_direct_commits",
        lambda journal, **kwargs: journal,
    )

    workflow._run_sync_workflow(
        journal=journal,
        definition=definition,
        base_ref="master",
        pull_branches=False,
        rebase_branches=False,
        interactive=True,
        resume_after_step="interactive:metabranch",
    )

    assert "interactive:metabranch" not in context.steps
    assert ("rebase", "master", True) not in git_client.calls


def test_workflow_run_sync_resume_skips_reset_and_completed_replay(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one", "two"))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
    )
    git_client = SyncRunGitClient(
        commits_by_branch={
            "one": [
                ReconciliationCommit(
                    sha="abc123",
                    subject="One",
                    message="One",
                )
            ],
            "two": [
                ReconciliationCommit(
                    sha="def456",
                    subject="Two",
                    message="Two",
                )
            ],
        }
    )
    context = SyncRunContext(git_client)
    workflow = WorkflowClient(context)
    monkeypatch.setattr(
        workflow,
        "_reconcile_direct_commits",
        lambda journal, **kwargs: journal,
    )

    workflow._run_sync_workflow(
        journal=journal,
        definition=definition,
        base_ref="master",
        pull_branches=False,
        rebase_branches=False,
        interactive=False,
        resume_after_step="replay:one:abc123",
    )

    assert ("reset_hard", "master", None) not in git_client.calls
    assert ("cherry_pick", "abc123", True) not in git_client.calls
    assert ("cherry_pick", "def456", True) in git_client.calls


def test_reconcile_direct_commits_resume_skips_completed_plan_entry(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one", "two"))
    commit_one = ReconciliationCommit(sha="abc123", subject="One", message="One")
    commit_two = ReconciliationCommit(sha="def456", subject="Two", message="Two")
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        args={
            "reconciliation_plan": [
                ReconciliationPlanEntry(
                    target_branch="one",
                    commit_sha="abc123",
                    subject="One",
                ).model_dump(mode="json"),
                ReconciliationPlanEntry(
                    target_branch="two",
                    commit_sha="def456",
                    subject="Two",
                ).model_dump(mode="json"),
            ]
        },
    )
    git_client = SyncRunGitClient(commits_by_branch={"meta": [commit_one, commit_two]})
    context = SyncRunContext(git_client)
    workflow = WorkflowClient(context)
    applied: list[str] = []
    monkeypatch.setattr(
        workflow,
        "_apply_reconciliation_entry",
        lambda entry: applied.append(entry.commit_sha),
    )
    monkeypatch.setattr(
        workflow,
        "_is_tool_managed_metadata_commit",
        lambda commit: False,
    )
    monkeypatch.setattr(
        workflow,
        "_is_replayed_branch_commit",
        lambda commit, definition: False,
    )
    monkeypatch.setattr(
        workflow,
        "_is_synthetic_integration_commit",
        lambda commit, definition: False,
    )

    workflow._reconcile_direct_commits(
        journal,
        definition=definition,
        base_ref="master",
        resume_cursor=_ResumeCursor("reconcile:abc123->one"),
    )

    assert "reconcile:abc123->one" not in context.steps
    assert "reconcile:def456->two" in context.steps
    assert applied == ["def456"]


def test_sdk_resume_continues_in_progress_operation_without_restore(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        current_step="replay:one:abc123",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    git_client = ResumeGitClient()
    git_client.operation = "cherry-pick"
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=git_client,
        config_store=ResumeConfigStore(None),
        local_state=local_state,
    )
    captured: dict[str, object] = {}

    def fake_run_sync_workflow(**kwargs) -> None:
        captured.update(kwargs)

    monkeypatch.setattr(sdk, "_run_sync_workflow", fake_run_sync_workflow)

    sdk.resume()

    assert ("continue_in_progress_operation", "cherry-pick", None) in git_client.calls
    assert ("abort_in_progress_operation", "", None) not in git_client.calls
    assert captured["resume_after_step"] == "replay:one:abc123"
    assert local_state.saved[0].current_step == "replay:one:abc123"


def test_sdk_resume_skips_empty_cherry_pick_continue(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        status="blocked",
        current_step="reconcile:abc123->one",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    git_client = ResumeGitClient()
    git_client.operation = "cherry-pick"
    git_client.current_branch = "one"
    git_client.continue_error = gitpython.GitCommandError(
        "cherry-pick",
        1,
        stdout="nothing to commit, working tree clean",
        stderr="The previous cherry-pick is now empty, possibly due to conflict resolution.",
    )
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=git_client,
        config_store=ResumeConfigStore(definition),
        local_state=local_state,
    )
    captured: dict[str, object] = {}

    def fake_run_sync_workflow(**kwargs) -> None:
        captured.update(kwargs)

    monkeypatch.setattr(sdk, "_run_sync_workflow", fake_run_sync_workflow)

    sdk.resume()

    assert ("continue_in_progress_operation", "cherry-pick", None) in git_client.calls
    assert ("skip_in_progress_operation", "cherry-pick", None) in git_client.calls
    assert captured["resume_after_step"] == "reconcile:abc123->one"


def test_sdk_resume_reraises_non_empty_continue_failure(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        status="blocked",
        current_step="reconcile:abc123->one",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    git_client = ResumeGitClient()
    git_client.operation = "cherry-pick"
    git_client.current_branch = "one"
    git_client.continue_error = gitpython.GitCommandError(
        "cherry-pick",
        1,
        stderr="merge conflict",
    )
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=git_client,
        config_store=ResumeConfigStore(definition),
        local_state=local_state,
    )

    with pytest.raises(gitpython.GitCommandError):
        sdk.resume()

    assert ("skip_in_progress_operation", "cherry-pick", None) not in git_client.calls
    assert local_state.saved[-1].status == "blocked"


def test_sdk_resume_after_manual_skip_of_reconciliation_step(monkeypatch) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        status="blocked",
        current_step="reconcile:abc123->one",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    git_client = ResumeGitClient()
    git_client.current_branch = "one"
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=git_client,
        config_store=ResumeConfigStore(definition),
        local_state=local_state,
    )
    captured: dict[str, object] = {}

    def fake_run_sync_workflow(**kwargs) -> None:
        captured.update(kwargs)

    monkeypatch.setattr(sdk, "_run_sync_workflow", fake_run_sync_workflow)

    sdk.resume()

    assert ("abort_in_progress_operation", "", None) not in git_client.calls
    assert ("checkout", "one", False) not in git_client.calls
    assert captured["resume_after_step"] == "reconcile:abc123->one"


def test_sdk_resume_skipped_reconciliation_helper_covers_remaining_paths() -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        status="blocked",
        current_step="reconcile:abc123->one",
    )
    git_client = ResumeGitClient()
    sdk = MetabranchSDK(
        git_client=git_client,
        config_store=ResumeConfigStore(definition),
        local_state=ResumeLocalState(journal, definition),
    )

    git_client.unmerged_paths = True
    with pytest.raises(WorkflowConflictError):
        sdk._resume_skipped_reconciliation_step(journal)

    git_client.unmerged_paths = False
    git_client.pending_changes = True
    assert sdk._resume_skipped_reconciliation_step(journal) is False

    git_client.pending_changes = False
    git_client.current_branch = "meta"
    assert sdk._resume_skipped_reconciliation_step(journal) is False


def test_sdk_resume_finalizes_legacy_reconciliation_resolution_without_restore(
    monkeypatch,
) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        status="blocked",
        current_step="reconcile:abc123->one",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    git_client = ResumeGitClient()
    git_client.pending_changes = True
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=git_client,
        config_store=ResumeConfigStore(definition),
        local_state=local_state,
    )
    captured: dict[str, object] = {}

    def fake_run_sync_workflow(**kwargs) -> None:
        captured.update(kwargs)

    monkeypatch.setattr(sdk, "_run_sync_workflow", fake_run_sync_workflow)

    sdk.resume()

    assert ("commit", "Replay abc123", None) in git_client.calls
    assert ("abort_in_progress_operation", "", None) not in git_client.calls
    assert ("checkout", "one", False) not in git_client.calls
    assert captured["resume_after_step"] == "reconcile:abc123->one"


def test_sdk_resume_rejects_unresolved_legacy_reconciliation_conflict() -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        status="blocked",
        current_step="reconcile:abc123->one",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    git_client = ResumeGitClient()
    git_client.unmerged_paths = True
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=git_client,
        config_store=ResumeConfigStore(definition),
        local_state=local_state,
    )

    with pytest.raises(WorkflowConflictError, match="resolve it, stage the files"):
        sdk.resume()


def test_sdk_resume_requires_definition() -> None:
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        checkpoints={"return_branch": "meta"},
    )
    sdk = MetabranchSDK(
        git_client=ResumeGitClient(),
        config_store=ResumeConfigStore(None),
        local_state=ResumeLocalState(journal, None),
    )

    with pytest.raises(InvalidMetabranchError):
        sdk.resume()


def test_sdk_abort_cleans_up_in_progress_git_state_before_restore() -> None:
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        status="blocked",
        current_step="reconcile:abc123->one",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    git_client = ResumeGitClient()
    local_state = ResumeLocalState(journal, None)
    sdk = MetabranchSDK(
        git_client=git_client,
        local_state=local_state,
    )

    sdk.abort()

    assert git_client.calls[0] == ("abort_in_progress_operation", "", None)
    assert local_state.active_workflow_id is None
    assert local_state.repo_lock is None


@pytest.mark.parametrize(
    ("side_effect", "expected_status"),
    [
        (gitpython.GitCommandError("rebase", 1, stderr="boom"), "blocked"),
        (RuntimeError("boom"), "failed"),
    ],
)
def test_sdk_resume_persists_error_statuses(side_effect, expected_status: str) -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=ResumeGitClient(),
        config_store=ResumeConfigStore(definition),
        local_state=local_state,
    )

    def fake_run_sync_workflow(**kwargs) -> None:
        raise side_effect

    sdk._run_sync_workflow = fake_run_sync_workflow  # type: ignore[method-assign]

    with pytest.raises(type(side_effect)):
        sdk.resume()

    assert local_state.saved[-1].status == expected_status


def test_sdk_resume_persists_reconciliation_errors_without_clearing_workflow() -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=ResumeGitClient(),
        config_store=ResumeConfigStore(definition),
        local_state=local_state,
    )

    def fake_run_sync_workflow(**kwargs) -> None:
        raise EditorCancelledError("cancelled")

    sdk._run_sync_workflow = fake_run_sync_workflow  # type: ignore[method-assign]

    with pytest.raises(EditorCancelledError, match="cancelled"):
        sdk.resume()

    assert local_state.saved[-1].status == "aborted"
    assert local_state.active_workflow_id == "wf-1"
    assert local_state.repo_lock is not None


def test_sdk_resume_preflight_cancellation_clears_workflow() -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        checkpoints={
            "return_branch": "meta",
            "ref:one": "abc123",
            "ref:meta": "def456",
        },
    )
    local_state = ResumeLocalState(journal, definition)
    sdk = MetabranchSDK(
        git_client=ResumeGitClient(),
        config_store=ResumeConfigStore(definition),
        local_state=local_state,
    )

    def fake_run_sync_workflow(**kwargs) -> None:
        local_state.save_workflow(kwargs["journal"].with_updates(current_step="preflight"))
        raise EditorCancelledError("cancelled")

    sdk._run_sync_workflow = fake_run_sync_workflow  # type: ignore[method-assign]

    with pytest.raises(EditorCancelledError, match="cancelled"):
        sdk.resume()

    assert local_state.saved[-1].status == "aborted"
    assert local_state.active_workflow_id is None
    assert local_state.repo_lock is None


class ReconciliationGitClient:
    """Fake git client for reconciliation helper tests."""

    def __init__(self, *, empty_error: bool = False) -> None:
        self.empty_error = empty_error
        self.calls: list[tuple[str, str]] = []
        self.error_message: str | None = None

    def checkout(self, name: str, create: bool = False) -> None:
        self.calls.append(("checkout", name))

    def cherry_pick(self, commit_sha: str, *, record_origin: bool = False) -> None:
        self.calls.append(("cherry_pick", commit_sha))
        if self.error_message is not None:
            raise gitpython.GitCommandError("cherry-pick", 1, stderr=self.error_message)
        if self.empty_error:
            raise gitpython.GitCommandError(
                "cherry-pick",
                1,
                stdout=(
                    "nothing to commit, working tree clean\n"
                    "The previous cherry-pick is now empty, possibly due to conflict resolution."
                ),
                stderr="",
            )

    def skip_in_progress_operation(self) -> str | None:
        self.calls.append(("skip_in_progress_operation", "cherry-pick"))
        return "cherry-pick"

    def recent_commit_subjects(self, branch_name: str, max_count: int = 50):
        return ("Known subject",) if branch_name == "feature-one" else ()

    def is_ancestor(self, commitish: str, ref: str) -> bool:
        return commitish in {"p1", "p2"} and ref == "feature-one"


def test_sdk_reconciliation_helpers_cover_validation_and_noop_paths() -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("feature-one",))
    commit = ReconciliationCommit(
        sha="abc123",
        subject="Known subject",
        message="Known subject",
    )
    journal = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        args={"reconciliation_plan": [{"target_branch": "feature-one", "commit_sha": "wrong", "subject": "Known subject"}]},
    )
    sdk = MetabranchSDK(
        git_client=ReconciliationGitClient(),
        config_store=ResumeConfigStore(definition),
        local_state=ResumeLocalState(journal, definition),
    )

    reconciliation_text = sdk._build_reconciliation_text(
        definition,
        [commit],
        {"abc123": "feature-one"},
    )
    assert reconciliation_text.splitlines()[-1].startswith("feature-one")
    assert "Use `d` to drop" in reconciliation_text
    assert sdk._suggest_reconciliation_targets(definition, [commit]) == {"abc123": "feature-one"}
    assert sdk._is_synthetic_integration_commit(
        ReconciliationCommit(
            sha="merge123",
            subject="Synthetic merge",
            message="Synthetic merge",
            parent_shas=("base", "p1", "p2"),
        ),
        definition,
    ) is True
    with pytest.raises(InvalidReconciliationPlanError, match="Saved reconciliation plan"):
        sdk._load_reconciliation_plan(journal, [commit])
    journal_with_noise = WorkflowJournal(
        workflow_id="wf-2",
        workflow_type="rebase",
        metabranch_name="meta",
        args={
            "reconciliation_plan": [
                "ignore-me",
                {"target_branch": "feature-one", "commit_sha": "abc123", "subject": "Known subject"},
            ]
        },
    )
    assert sdk._load_reconciliation_plan(journal_with_noise, [commit]) == [
        ReconciliationPlanEntry(
            target_branch="feature-one",
            commit_sha="abc123",
            subject="Known subject",
        )
    ]
    broken_journal = WorkflowJournal(
        workflow_id="wf-3",
        workflow_type="rebase",
        metabranch_name="meta",
        args={"reconciliation_plan": [{"target_branch": "feature-one"}]},
    )
    with pytest.raises(InvalidReconciliationPlanError, match="invalid entries"):
        sdk._load_reconciliation_plan(broken_journal, [commit])
    with pytest.raises(InvalidReconciliationPlanError, match="Each reconciliation line"):
        sdk._parse_reconciliation_text("feature-one abc123", definition=definition, commits=[commit])
    with pytest.raises(InvalidReconciliationPlanError, match="Unknown reconciliation target branch"):
        sdk._parse_reconciliation_text("missing abc123 Known subject", definition=definition, commits=[commit])
    with pytest.raises(InvalidReconciliationPlanError, match="Unknown reconciliation commit"):
        sdk._parse_reconciliation_text("feature-one wrong Known subject", definition=definition, commits=[commit])
    with pytest.raises(InvalidReconciliationPlanError, match="subject mismatch"):
        sdk._parse_reconciliation_text("feature-one abc123 Wrong", definition=definition, commits=[commit])
    assert sdk._parse_reconciliation_text(
        "d abc123 Known subject",
        definition=definition,
        commits=[commit],
    ) == [
        ReconciliationPlanEntry(
            action="drop",
            commit_sha="abc123",
            subject="Known subject",
        )
    ]
    with pytest.raises(InvalidReconciliationPlanError, match="assign or drop every metabranch-only commit"):
        sdk._parse_reconciliation_text("# comment\n", definition=definition, commits=[commit])

    empty_git = ReconciliationGitClient(empty_error=True)
    empty_sdk = MetabranchSDK(
        git_client=empty_git,
        config_store=ResumeConfigStore(definition),
        local_state=ResumeLocalState(journal, definition),
    )
    assert empty_sdk._is_empty_cherry_pick_error(
        gitpython.GitCommandError("cherry-pick", 1, stderr="nothing to commit")
    ) is True
    empty_sdk._apply_reconciliation_entry(
        ReconciliationPlanEntry(
            target_branch="feature-one",
            commit_sha="abc123",
            subject="Known subject",
        )
    )
    assert ("skip_in_progress_operation", "cherry-pick") in empty_git.calls
    drop_calls = list(empty_git.calls)
    empty_sdk._apply_reconciliation_entry(
        ReconciliationPlanEntry(
            action="drop",
            commit_sha="abc123",
            subject="Known subject",
        )
    )
    assert empty_git.calls == drop_calls

    clean_git = ReconciliationGitClient()
    clean_sdk = MetabranchSDK(
        git_client=clean_git,
        config_store=ResumeConfigStore(definition),
        local_state=ResumeLocalState(journal, definition),
    )
    clean_sdk._apply_reconciliation_entry(
        ReconciliationPlanEntry(
            target_branch="feature-one",
            commit_sha="abc123",
            subject="Known subject",
        )
    )
    assert ("cherry_pick", "abc123") in clean_git.calls

    conflict_git = ReconciliationGitClient()
    conflict_git.error_message = "merge conflict"
    conflict_sdk = MetabranchSDK(
        git_client=conflict_git,
        config_store=ResumeConfigStore(definition),
        local_state=ResumeLocalState(journal, definition),
    )
    with pytest.raises(gitpython.GitCommandError):
        conflict_sdk._apply_reconciliation_entry(
            ReconciliationPlanEntry(
                target_branch="feature-one",
                commit_sha="abc123",
                subject="Known subject",
            )
        )


@pytest.mark.parametrize(
    ("method_name", "kwargs"),
    [
        ("pull", {}),
        ("pull", {"recursive": True}),
        ("rebase", {}),
        ("rebase", {"recursive": True}),
    ],
)
def test_sdk_sync_rejects_dirty_worktree(
    monkeypatch, repo_with_branches, method_name: str, kwargs: dict[str, object]
) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")
    (repo_with_branches / "dirty.txt").write_text("dirty\n")

    with pytest.raises(DirtyWorkingTreeError):
        getattr(sdk, method_name)(**kwargs)


def test_sdk_sync_rejects_dirty_metabranch_config(monkeypatch, repo_with_branches) -> None:
    monkeypatch.chdir(repo_with_branches)
    sdk = MetabranchSDK()
    sdk.create("meta", "feature-one")
    config_path = repo_with_branches / ".metabranch.yaml"
    config_path.write_text(config_path.read_text() + "\n# dirty\n")

    with pytest.raises(DirtyWorkingTreeError):
        sdk.rebase()


class FakeSDK:
    """Simple fake SDK for client tests."""

    def __init__(self) -> None:
        self.calls: list[tuple[str, tuple, dict]] = []

    def create(self, *args, **kwargs) -> None:
        self.calls.append(("create", args, kwargs))

    def is_metabranch(self, *args, **kwargs) -> bool:
        self.calls.append(("is_metabranch", args, kwargs))
        return True

    def is_plain_branch(self, *args, **kwargs) -> bool:
        self.calls.append(("is_plain_branch", args, kwargs))
        return False

    def checkout(self, *args, **kwargs) -> CheckoutResult:
        self.calls.append(("checkout", args, kwargs))
        return CheckoutResult(branch_name="meta", is_metabranch=True)

    def add_branches(self, *args, **kwargs) -> None:
        self.calls.append(("add_branches", args, kwargs))

    def remove_branches(self, *args, **kwargs) -> None:
        self.calls.append(("remove_branches", args, kwargs))

    def update_branches(self, *args, **kwargs) -> None:
        self.calls.append(("update_branches", args, kwargs))

    def rebase(self, *args, **kwargs) -> None:
        self.calls.append(("rebase", args, kwargs))

    def pull(self, *args, **kwargs) -> None:
        self.calls.append(("pull", args, kwargs))

    def push(self, *args, **kwargs) -> None:
        self.calls.append(("push", args, kwargs))

    def resume(self, *args, **kwargs) -> None:
        self.calls.append(("resume", args, kwargs))

    def abort(self, *args, **kwargs) -> None:
        self.calls.append(("abort", args, kwargs))

    def status(self, *args, **kwargs) -> StatusResult:
        self.calls.append(("status", args, kwargs))
        return StatusResult(
            branch_name="meta",
            is_metabranch=True,
            metabranch=MetabranchDefinition(
                name="meta",
                base_ref="master",
                branches=("feature-one",),
            ),
            branch_states=(
                BranchState(
                    name="feature-one",
                    role="contained",
                    state="available",
                ),
            ),
        )

    def list(self, *args, **kwargs) -> list[MetabranchDefinition]:
        self.calls.append(("list", args, kwargs))
        return [MetabranchDefinition(name="meta", base_ref="master")]

    def active_workflow(self, *args, **kwargs) -> WorkflowJournal | None:
        self.calls.append(("active_workflow", args, kwargs))
        return WorkflowJournal(
            workflow_id="wf-1",
            workflow_type="rebase",
            metabranch_name="meta",
            status="blocked",
            current_step="replay:feature-one:abc123",
        )


def test_sdk_clients_delegate_to_runtime() -> None:
    runtime = FakeSDK()
    runtime.git_client = object()
    runtime.tracked_config = object()
    runtime.local_state = object()
    runtime.reconciliation_editor = object()
    clients = build_clients()
    clients.repository = runtime
    clients.branches = runtime
    clients.workflows = runtime
    clients._context.git_client = runtime.git_client
    clients._context.tracked_config = runtime.tracked_config
    clients._context.local_state = runtime.local_state
    clients._context.reconciliation_editor = runtime.reconciliation_editor

    clients.repository.create("meta", "feature-one")
    assert clients.repository.is_metabranch("meta") is True
    assert clients.repository.is_plain_branch("feature-one") is False
    assert clients.repository.checkout("meta") == CheckoutResult(
        branch_name="meta",
        is_metabranch=True,
    )
    clients.branches.add_branches("feature-one")
    clients.branches.remove_branches("feature-one")
    clients.branches.update_branches(added=("feature-one",), removed=("feature-one",))
    clients.workflows.pull("master", recursive=True)
    clients.workflows.rebase("master", interactive=True, recursive=True, pull=True)
    clients.workflows.push(force=True, recursive=True)
    clients.workflows.resume()
    clients.workflows.abort()
    assert clients.repository.status().branch_name == "meta"
    assert clients.repository.list()[0].name == "meta"
    assert clients.repository.active_workflow() is not None
    assert clients.git_client is runtime.git_client
    assert clients.tracked_config is runtime.tracked_config
    assert clients.local_state is runtime.local_state
    assert clients.reconciliation_editor is runtime.reconciliation_editor

    assert [name for name, _, _ in runtime.calls] == [
        "create",
        "is_metabranch",
        "is_plain_branch",
        "checkout",
        "add_branches",
        "remove_branches",
        "update_branches",
        "pull",
        "rebase",
        "push",
        "resume",
        "abort",
        "status",
        "list",
        "active_workflow",
    ]


def test_build_clients_constructs_split_sdk() -> None:
    clients = build_clients()

    assert clients.repository is not None
    assert clients.branches is not None
    assert clients.workflows is not None
    assert clients.git_client is not None
    assert clients.tracked_config is not None
    assert clients.local_state is not None


def test_cli_attaches_progress_reporter_when_context_exists(monkeypatch) -> None:
    runner = CliRunner()
    fake_client = FakeClient()
    fake_client._context = SimpleNamespace(progress_reporter=None)
    monkeypatch.setattr("metabranch.cli.build_clients", lambda: fake_client)

    result = runner.invoke(cli, ["status"])

    assert result.exit_code == 0
    assert callable(fake_client._context.progress_reporter)


def test_clients_bundle_exposes_context_and_missing_attrs() -> None:
    clients = build_clients()
    editor = object()
    clients._context.local_state = type(
        "EmptyLocalState",
        (),
        {"get_active_workflow": lambda self: None},
    )()

    clients.reconciliation_editor = editor

    assert clients.reconciliation_editor is editor
    clients.ensure_no_active_workflow()
    with pytest.raises(AttributeError):
        _ = clients.missing_attr


class FallbackGitClient:
    """Fake git client for SDK fallback tests."""

    def checkout(self, name: str, create: bool = False) -> None:
        self.checked_out = (name, create)

    def branch_exists(self, name: str) -> bool:
        return True

    def list_branches(self) -> list[str]:
        return []

    def tracking_branch_name(self, name: str) -> str | None:
        return None

    def ahead_behind(
        self, name: str, tracking_ref: str | None = None
    ) -> tuple[int, int] | None:
        return None

    def ref_commit_timestamp(
        self,
        ref: str,
        *,
        path: str | None = None,
        first_addition: bool = False,
    ) -> str | None:
        return None


class FallbackConfigStore:
    """Fake config store for SDK fallback tests."""

    def exists_in_branch(self, name: str) -> bool:
        return False

    def load_from_branch(self, name: str):
        return None

    def exists(self) -> bool:
        return False

    def load(self):
        raise InvalidMetabranchError()

    def save(self, definition) -> None:
        self.saved = definition


class FallbackLocalState:
    """Fake local state for SDK fallback tests."""

    def __init__(self, definition: MetabranchDefinition | None) -> None:
        self.definition = definition
        self.active = None
        self.registered = None

    def list_names(self) -> list[str]:
        return ["meta"]

    def get_definition(self, name: str) -> MetabranchDefinition | None:
        return self.definition

    def register(self, definition: MetabranchDefinition) -> None:
        self.registered = definition

    def unregister(self, name: str) -> None:
        if self.definition is not None and self.definition.name == name:
            self.definition = None
        if self.active == name:
            self.active = None

    def set_active(self, name: str | None) -> None:
        self.active = name

    def get_active(self):
        return self.active

    def get_active_workflow(self):
        return None

    def inspect_repo_lock(self):
        return None


def test_sdk_checkout_uses_local_fallback_definition() -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    local_state = FallbackLocalState(definition)
    sdk = MetabranchSDK(
        git_client=FallbackGitClient(),
        config_store=FallbackConfigStore(),
        local_state=local_state,
    )

    result = sdk.checkout("meta")
    assert result == CheckoutResult(branch_name="meta", is_metabranch=True)
    assert local_state.registered == definition
    assert local_state.active == "meta"


def test_sdk_checkout_handles_missing_local_fallback_definition() -> None:
    local_state = FallbackLocalState(None)
    sdk = MetabranchSDK(
        git_client=FallbackGitClient(),
        config_store=FallbackConfigStore(),
        local_state=local_state,
    )

    result = sdk.checkout("meta")
    assert result == CheckoutResult(branch_name="meta", is_metabranch=True)
    assert local_state.registered is None
    assert local_state.active == "meta"


class MissingFallbackGitClient(FallbackGitClient):
    """Fake git client where the requested branch is missing locally."""

    def branch_exists(self, name: str) -> bool:
        return False


def test_sdk_checkout_known_metabranch_with_missing_git_branch_is_clean_error() -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("one",))
    sdk = MetabranchSDK(
        git_client=MissingFallbackGitClient(),
        config_store=FallbackConfigStore(),
        local_state=FallbackLocalState(definition),
    )

    with pytest.raises(
        InvalidBranchError,
        match="Metabranch 'meta' is known locally but the Git branch is missing.",
    ):
        sdk.checkout("meta")


class MissingContainedBranchConfigStore(FallbackConfigStore):
    """Fake config store for status with a missing contained branch."""

    def __init__(self, definition: MetabranchDefinition) -> None:
        self.definition = definition

    def exists(self) -> bool:
        return True

    def load(self):
        return self.definition


def test_sdk_status_marks_missing_contained_branch() -> None:
    definition = MetabranchDefinition(name="meta", base_ref="master", branches=("gone",))
    local_state = FallbackLocalState(definition)
    local_state.active = "meta"
    sdk = MetabranchSDK(
        git_client=MissingFallbackGitClient(),
        config_store=MissingContainedBranchConfigStore(definition),
        local_state=local_state,
    )

    status = sdk.status()

    assert status.branch_states == (
        BranchState(name="gone", role="contained", state="missing"),
    )


class FakeClient:
    """Simple fake client for CLI tests."""

    plain_branch = False
    listed = [MetabranchDefinition(name="meta", base_ref="master")]
    status_result = StatusResult(
        branch_name="meta",
        is_metabranch=True,
        metabranch=MetabranchDefinition(
            name="meta",
            base_ref="master",
            branches=("feature-one",),
        ),
        branch_states=(
            BranchState(name="feature-one", role="contained", state="available"),
        ),
    )
    workflow = WorkflowJournal(
        workflow_id="wf-1",
        workflow_type="rebase",
        metabranch_name="meta",
        status="blocked",
        current_step="replay:feature-one:abc123",
    )

    def __init__(self) -> None:
        self.repository = self
        self.branches = self
        self.workflows = self

    def create(self, *args, **kwargs) -> None:
        self.created = args
        self.create_kwargs = kwargs

    def is_plain_branch(self, name: str) -> bool:
        return self.plain_branch and name == "plain"

    def checkout(self, name: str) -> None:
        self.checked_out = name

    def add_branches(self, *names: str) -> None:
        self.added = names

    def remove_branches(self, *names: str) -> None:
        self.removed = names

    def update_branches(
        self,
        *,
        added: tuple[str, ...] = (),
        removed: tuple[str, ...] = (),
    ) -> None:
        self.updated = (added, removed)

    def list(self) -> list[MetabranchDefinition]:
        return self.listed

    def status(self) -> StatusResult:
        return self.status_result

    def rebase(
        self,
        base: str | None = None,
        interactive: bool = False,
        recursive: bool = False,
        pull: bool = False,
    ) -> None:
        self.rebased = (base, interactive, recursive, pull)

    def pull(self, base: str | None = None, recursive: bool = False) -> None:
        self.pulled = (base, recursive)

    def push(self, force: bool = False, recursive: bool = False) -> None:
        self.pushed = (force, recursive)

    def commit(self, *args: str) -> None:
        self.committed = args

    def resume(self) -> None:
        self.resumed = True

    def abort(self) -> None:
        self.aborted = True

    def active_workflow(self) -> WorkflowJournal | None:
        return self.workflow


def test_format_git_command_error_handles_empty_and_nonblocked_workflows() -> None:
    fake_client = FakeClient()
    fake_client.workflow = WorkflowJournal(
        workflow_id="wf-2",
        workflow_type="rebase",
        metabranch_name="meta",
        status="running",
        current_step="replay:feature-one:abc123",
    )
    class DuplicateOutputError:
        stdout = "boom"
        stderr = "boom"

    assert _format_git_command_error(DuplicateOutputError(), fake_client) == "boom"

    class NoOutputError:
        stdout = None
        stderr = None

    fake_client.workflow = None
    assert _format_git_command_error(NoOutputError(), fake_client) == "Git command failed."


def test_cli_dispatch(monkeypatch) -> None:
    runner = CliRunner()
    fake_client = FakeClient()
    monkeypatch.setattr("metabranch.cli.build_clients", lambda: fake_client)

    result = runner.invoke(cli, ["checkout", "-b", "meta", "feature-one"])
    assert result.exit_code == 0
    assert fake_client.created == ("meta", "feature-one")
    assert fake_client.create_kwargs == {}

    def reject_plain(name: str) -> None:
        raise InvalidMetabranchError(
            f"Branch '{name}' exists, but it is a regular Git branch, not a metabranch."
        )

    fake_client.checkout = reject_plain
    result = runner.invoke(cli, ["checkout", "plain"])
    assert result.exit_code != 0
    assert "regular Git branch" in result.output

    fake_client.checkout = lambda name: setattr(fake_client, "checked_out", name)
    result = runner.invoke(cli, ["checkout", "meta"])
    assert result.exit_code == 0
    assert "regular Git branch" not in result.output

    result = runner.invoke(cli, ["checkout", "meta", "extra"])
    assert result.exit_code != 0
    assert "Branches cannot be provided" in result.output

    result = runner.invoke(cli, ["branch"])
    assert result.exit_code == 0
    assert "meta" in result.output
    assert "Base" in result.output

    result = runner.invoke(cli, ["branch", "other-meta", "feature-one", "feature-one"])
    assert result.exit_code == 0
    assert fake_client.created == ("other-meta", "feature-one")
    assert fake_client.create_kwargs == {"checkout": False}
    assert "duplicate branches in branch request were ignored" in result.output

    result = runner.invoke(cli, ["add", "feature-one", "feature-one", "feature-two"])
    assert result.exit_code == 0
    assert fake_client.added == ("feature-two",)
    assert "duplicate or already-present branches in add request were ignored" in result.output
    assert "feature-one" in result.output

    result = runner.invoke(
        cli,
        ["add", "feature-one", "feature-one", "feature-one"],
    )
    assert result.exit_code == 0
    assert fake_client.added == ()
    assert result.output.count("feature-one") == 1

    result = runner.invoke(cli, ["remove", "feature-one", "feature-one"])
    assert result.exit_code == 0
    assert fake_client.removed == ("feature-one",)
    assert "duplicate or missing branches in remove request were ignored" in result.output

    fake_client.status_result = StatusResult(
        branch_name="meta",
        is_metabranch=True,
        metabranch=MetabranchDefinition(
            name="meta",
            base_ref="master",
            branches=("feature-one", "feature-two"),
        ),
    )
    result = runner.invoke(
        cli,
        ["add", "feature-one", "feature-one", "feature-two"],
    )
    assert result.exit_code == 0
    assert fake_client.added == ()
    assert "duplicate or already-present branches in add request were ignored" in result.output
    assert "feature-one" in result.output
    assert "feature-two" in result.output

    result = runner.invoke(cli, ["add"])
    assert result.exit_code != 0
    assert "Provide at least one branch to add." in result.output

    result = runner.invoke(cli, ["remove"])
    assert result.exit_code != 0
    assert "Provide at least one branch to remove." in result.output

    fake_client.status_result = StatusResult(
        branch_name="master",
        is_metabranch=False,
        notices=("Current branch master is not a metabranch.",),
    )
    result = runner.invoke(cli, ["add", "feature-one", "feature-one"])
    assert result.exit_code == 0
    assert fake_client.added == ("feature-one",)
    assert "duplicate branches in add request were ignored" in result.output

    fake_client.status_result = FakeClient.status_result
    result = runner.invoke(cli, ["status"])
    assert result.exit_code == 0
    assert "Metabranch Status" in result.output
    assert "feature-one" in result.output

    result = runner.invoke(cli, ["commit", "-m", "hello"])
    assert result.exit_code == 0
    assert fake_client.committed == ("-m", "hello")

    result = runner.invoke(cli, ["rebase", "master", "--interactive"])
    assert result.exit_code == 0
    assert fake_client.rebased == ("master", True, False, False)
    result = runner.invoke(cli, ["rebase", "master", "-r", "--interactive"])
    assert result.exit_code == 0
    assert fake_client.rebased == ("master", True, True, False)
    result = runner.invoke(cli, ["rebase", "--pull"])
    assert result.exit_code == 0
    result = runner.invoke(cli, ["pull", "master", "-r"])
    assert result.exit_code == 0
    result = runner.invoke(cli, ["push"])
    assert result.exit_code == 0

    def fail_rebase(**kwargs) -> None:
        raise gitpython.GitCommandError(
            "cherry-pick",
            1,
            stdout="CONFLICT (modify/delete): feature2.txt deleted in HEAD and modified in abc123.",
            stderr="error: could not apply abc123... feature2update",
        )

    fake_client.rebase = fail_rebase
    result = runner.invoke(cli, ["rebase"])
    assert result.exit_code != 0
    assert "could not apply abc123" in result.output
    assert "CONFLICT (modify/delete)" in result.output
    assert "metabranch resume" in result.output
    assert "metabranch abort" in result.output
    assert "Traceback" not in result.output
    assert fake_client.pushed == (False, False)
    result = runner.invoke(cli, ["push", "-r", "--force"])
    assert result.exit_code == 0
    assert fake_client.pushed == (True, True)
    result = runner.invoke(cli, ["resume"])
    assert result.exit_code == 0
    assert "Resuming Workflow" in result.output
    result = runner.invoke(cli, ["abort"])
    assert result.exit_code == 0
    assert "Aborting Workflow" in result.output


class ErrorClient(FakeClient):
    """Fake client that raises metabranch errors for CLI coverage."""

    def resume(self) -> None:
        raise NoActiveWorkflowError("No active metabranch workflow to resume.")


def test_cli_renders_metabranch_errors(monkeypatch) -> None:
    runner = CliRunner()
    monkeypatch.setattr("metabranch.cli.build_clients", ErrorClient)

    result = runner.invoke(cli, ["resume"])
    assert result.exit_code != 0
    assert "No active metabranch workflow to resume." in result.output


def test_cli_checkout_missing_branch_is_clean_error(monkeypatch) -> None:
    runner = CliRunner()
    fake_client = FakeClient()

    def fail_checkout(name: str) -> None:
        raise InvalidBranchError(f"Branch '{name}' does not exist.")

    fake_client.checkout = fail_checkout
    monkeypatch.setattr("metabranch.cli.build_clients", lambda: fake_client)

    result = runner.invoke(cli, ["checkout", "missing"])

    assert result.exit_code != 0
    assert "Branch 'missing' does not exist." in result.output
    assert "Traceback" not in result.output


def test_cli_create_existing_plain_branch_is_clean_error(monkeypatch) -> None:
    runner = CliRunner()
    fake_client = FakeClient()

    def fail_create(name: str, *names: str) -> None:
        raise InvalidMetabranchError(
            f"Branch '{name}' already exists as a regular Git branch. "
            "Converting an existing branch into a metabranch is forbidden."
        )

    fake_client.create = fail_create
    monkeypatch.setattr("metabranch.cli.build_clients", lambda: fake_client)

    result = runner.invoke(cli, ["checkout", "-b", "meta"])

    assert result.exit_code != 0
    assert "already exists as a regular Git branch" in result.output
    assert "Traceback" not in result.output


class NoWorkflowClient(FakeClient):
    """Fake client with no active workflow summary to render."""

    workflow = None


def test_cli_resume_and_abort_skip_summary_without_workflow(monkeypatch) -> None:
    runner = CliRunner()
    monkeypatch.setattr("metabranch.cli.build_clients", NoWorkflowClient)

    result = runner.invoke(cli, ["resume"])
    assert result.exit_code == 0
    assert "Resuming Workflow" not in result.output

    result = runner.invoke(cli, ["abort"])
    assert result.exit_code == 0
    assert "Aborting Workflow" not in result.output
