"""Mutating metabranch workflow client."""

from __future__ import annotations

import re

import git

from ..exceptions import (
    DirtyWorkingTreeError,
    EditorCancelledError,
    InvalidMetabranchError,
    InvalidReconciliationPlanError,
    NoActiveWorkflowError,
    RepoLockError,
    WorkflowConflictError,
)
from ..git import ReconciliationCommit
from ..models import (
    CONFIG_FILE_NAME,
    METADATA_COMMIT_TRAILER,
    MetabranchDefinition,
    ReconciliationPlanEntry,
    WorkflowJournal,
    validate_model,
)
from .context import _Context


class WorkflowClient:
    """Mutating workflow operations."""

    def __init__(self, context: _Context) -> None:
        self._context = context

    def _report(self, message: str) -> None:
        """Emit a progress message when the context provides a reporter."""
        reporter = getattr(self._context, "progress_reporter", None)
        if callable(reporter):
            reporter(message)

    def pull(self, base: str | None = None, recursive: bool = False) -> None:
        """Pull remotes and optionally rebuild contained branches."""
        if recursive:
            self._sync(
                base=base,
                pull_branches=True,
                rebase_branches=False,
                interactive=False,
                initialize=False,
            )
            return

        self._context.ensure_no_active_workflow()
        definition = self._context.load_definition()
        if self._context.git_client.is_dirty():
            raise DirtyWorkingTreeError(
                "Working tree has local changes; commit, stash, or discard them first."
            )
        if base is not None:
            self.rebase(base=base, recursive=False)
            return
        with self._context.operation_lock("pull", metabranch_name=definition.name):
            self._report(f"Pulling updates for metabranch {definition.name}")
            self._context.git_client.pull(definition.name)

    def rebase(
        self,
        base: str | None = None,
        interactive: bool = False,
        recursive: bool = False,
        pull: bool = False,
        initialize: bool = False,
    ) -> None:
        """Rebuild the metabranch, optionally syncing or rebasing contained branches."""
        self._sync(
            base=base,
            pull_branches=pull,
            rebase_branches=recursive,
            interactive=interactive,
            initialize=initialize,
        )

    def resume(self) -> None:
        """Resume the active workflow journal."""
        journal = self._context.local_state.get_active_workflow()
        if journal is None:
            raise NoActiveWorkflowError("No active metabranch workflow to resume.")

        self._context.local_state.acquire_repo_lock(
            owner=journal.workflow_id,
            workflow_id=journal.workflow_id,
            operation=journal.workflow_type,
            metabranch_name=journal.metabranch_name,
        )
        resume_after_step: str | None = None
        in_progress_operation = self._context.git_client.in_progress_operation()
        if in_progress_operation is not None:
            resume_after_step = journal.current_step
            journal = journal.with_updates(status="running")
        elif self._resume_reconciliation_without_git_state(journal):
            resume_after_step = journal.current_step
            journal = journal.with_updates(status="running")
        elif self._resume_skipped_reconciliation_step(journal):
            resume_after_step = journal.current_step
            journal = journal.with_updates(status="running")
        else:
            self._report("Restoring workflow state")
            self._context.restore_journal_checkpoint(journal)
            journal = journal.with_updates(status="running", current_step="resume")
        self._context.local_state.save_workflow(journal)

        definition = self._context.tracked_config.load_from_branch(
            journal.metabranch_name
        )
        if definition is None:
            definition = self._context.local_state.get_definition(journal.metabranch_name)
        if definition is None:
            raise InvalidMetabranchError()

        args = journal.args
        base = args.get("base")
        base_ref = base if isinstance(base, str) and base else definition.base_ref
        pull_branches = bool(args.get("pull_branches", False))
        rebase_branches = bool(args.get("rebase_branches", False))
        interactive = bool(args.get("interactive", False))
        initialize = bool(args.get("initialize", False))

        try:
            if in_progress_operation is not None:
                self._report("Continuing interrupted Git operation")
                try:
                    self._context.git_client.continue_in_progress_operation()
                except git.GitCommandError as exc:
                    if (
                        in_progress_operation == "cherry-pick"
                        and self._is_empty_cherry_pick_error(exc)
                    ):
                        self._report("Skipping empty cherry-pick")
                        self._context.git_client.skip_in_progress_operation()
                    else:
                        raise
            self._run_sync_workflow(
                journal=journal,
                definition=definition,
                base_ref=base_ref,
                pull_branches=pull_branches,
                rebase_branches=rebase_branches,
                interactive=interactive,
                resume_after_step=resume_after_step,
                initialize=initialize,
            )
        except git.GitCommandError:
            blocked = self._context.current_workflow_snapshot(journal).with_updates(
                status="blocked"
            )
            self._context.local_state.save_workflow(blocked)
            raise
        except (EditorCancelledError, InvalidReconciliationPlanError) as exc:
            failed = self._context.current_workflow_snapshot(journal).with_updates(
                status="aborted"
                if isinstance(exc, EditorCancelledError)
                else "failed"
            )
            self._context.local_state.save_workflow(failed)
            if failed.current_step == "preflight":
                self._finish_preflight_failure(failed)
            raise
        except Exception:
            failed = self._context.current_workflow_snapshot(journal).with_updates(
                status="failed"
            )
            self._context.local_state.save_workflow(failed)
            raise

    def abort(self) -> None:
        """Abort the active workflow or clear a stale lock."""
        journal = self._context.local_state.get_active_workflow()
        if journal is None:
            repo_lock = self._context.local_state.inspect_repo_lock()
            if repo_lock is not None and repo_lock.is_stale:
                self._context.local_state.release_repo_lock()
                return
            if repo_lock is not None:
                raise RepoLockError(
                    "A metabranch repo lock is active but has no resumable workflow. "
                    "Wait for the other operation to finish."
                )
            raise NoActiveWorkflowError("No active metabranch workflow to abort.")
        if journal.current_step == "preflight":
            if self._context.git_client.path_has_changes(CONFIG_FILE_NAME):
                self._context.git_client.discard_path_changes(CONFIG_FILE_NAME)
        else:
            self._context.restore_journal_checkpoint(journal)
        aborted = journal.with_updates(status="aborted", current_step="aborted")
        self._context.local_state.save_workflow(aborted)
        self._context.local_state.set_active_workflow(None)
        self._context.local_state.release_repo_lock(journal.workflow_id)

    def push(self, force: bool = False, recursive: bool = False) -> None:
        """Push the metabranch and optionally its contained branches."""
        self._context.ensure_no_active_workflow()
        definition = self._context.load_definition()
        with self._context.operation_lock("push", metabranch_name=definition.name):
            if recursive:
                for branch_name in definition.branches:
                    self._context.git_client.push(branch_name, force=force)
            self._context.git_client.push(definition.name, force=force)

    def commit(self, *commit_args: str) -> None:
        """Commit changes on the current metabranch, then reconcile immediately."""
        self._context.ensure_no_active_workflow()
        definition = self._context.load_definition()
        with self._context.operation_lock("commit", metabranch_name=definition.name):
            self._report(f"Committing changes on metabranch {definition.name}")
            self._context.git_client.commit_with_args(*commit_args)
        self.rebase()

    def _sync(
        self,
        *,
        base: str | None,
        pull_branches: bool,
        rebase_branches: bool,
        interactive: bool,
        initialize: bool,
    ) -> None:
        self._context.ensure_no_active_workflow()
        definition = self._context.load_definition()
        base_ref = base or definition.base_ref
        if pull_branches:
            self._report(f"Updating metabranch {definition.name} from {base_ref}")
        else:
            self._report(f"Rebasing metabranch {definition.name} onto {base_ref}")
        if self._context.git_client.is_dirty():
            raise DirtyWorkingTreeError(
                "Working tree has local changes; commit, stash, or discard them first."
            )

        journal = self._context.start_workflow(
            workflow_type="pull" if pull_branches else "rebase",
            definition=definition,
            args={
                "base": base,
                "pull_branches": pull_branches,
                "rebase_branches": rebase_branches,
                "interactive": interactive,
                "initialize": initialize,
            },
        )

        try:
            self._run_sync_workflow(
                journal=journal,
                definition=definition,
                base_ref=base_ref,
                pull_branches=pull_branches,
                rebase_branches=rebase_branches,
                interactive=interactive,
                initialize=initialize,
            )
        except git.GitCommandError:
            blocked = self._context.current_workflow_snapshot(journal).with_updates(
                status="blocked"
            )
            self._context.local_state.save_workflow(blocked)
            raise
        except (EditorCancelledError, InvalidReconciliationPlanError) as exc:
            failed = self._context.current_workflow_snapshot(journal).with_updates(
                status="aborted"
                if isinstance(exc, EditorCancelledError)
                else "failed"
            )
            self._context.local_state.save_workflow(failed)
            if failed.current_step == "preflight":
                self._finish_preflight_failure(failed)
            raise
        except Exception:
            failed = self._context.current_workflow_snapshot(journal).with_updates(
                status="failed"
            )
            self._context.local_state.save_workflow(failed)
            raise

    def _run_sync_workflow(
        self,
        *,
        journal: WorkflowJournal,
        definition: MetabranchDefinition,
        base_ref: str,
        pull_branches: bool,
        rebase_branches: bool,
        interactive: bool,
        resume_after_step: str | None = None,
        initialize: bool = False,
    ) -> None:
        cursor = _ResumeCursor(resume_after_step)
        working_journal = journal
        reported_branch_sync = False
        for branch_name in definition.branches:
            if pull_branches:
                step = f"pull:{branch_name}"
                if not cursor.should_run(step):
                    continue
                if not reported_branch_sync:
                    self._report("Pulling contained branches")
                    reported_branch_sync = True
                working_journal = self._context.save_workflow_step(
                    working_journal, step
                )
                self._context.git_client.pull(branch_name)
            elif rebase_branches:
                step = f"rebase:{branch_name}"
                if not cursor.should_run(step):
                    continue
                if not reported_branch_sync:
                    self._report(f"Rebasing contained branches onto {base_ref}")
                    reported_branch_sync = True
                self._context.git_client.checkout(branch_name)
                working_journal = self._context.save_workflow_step(
                    working_journal, step
                )
                self._context.git_client.rebase(base_ref, interactive=interactive)

        working_journal = self._reconcile_direct_commits(
            working_journal,
            definition=definition,
            base_ref=base_ref,
            resume_cursor=cursor,
        )
        self._report("Rebuilding metabranch contents")
        self._context.git_client.checkout(definition.name)
        if cursor.should_run("reset:metabranch"):
            working_journal = self._context.save_workflow_step(
                working_journal, "reset:metabranch"
            )
            self._context.git_client.reset_hard(base_ref)
        for branch_name in definition.branches:
            branch_commits = self._context.git_client.list_unique_commits(
                branch_name,
                exclude_refs=(base_ref,),
            )
            for commit in branch_commits:
                step = f"replay:{branch_name}:{commit.sha}"
                if not cursor.should_run(step):
                    continue
                working_journal = self._context.save_workflow_step(
                    working_journal, step
                )
                self._context.git_client.cherry_pick(
                    commit.sha,
                    record_origin=True,
                )

        updated = MetabranchDefinition(
            name=definition.name,
            base_ref=base_ref,
            branches=definition.branches,
            options=dict(definition.options),
        )
        self._report("Updating metabranch metadata")
        self._context.save_definition(updated, initialize=initialize)
        if interactive and not rebase_branches:
            if cursor.should_run("interactive:metabranch"):
                self._report("Opening interactive rebase for metabranch")
                working_journal = self._context.save_workflow_step(
                    working_journal,
                    "interactive:metabranch",
                )
                self._context.git_client.rebase(base_ref, interactive=True)
        self._context.local_state.set_active(definition.name)
        completed = working_journal.with_updates(
            status="completed",
            current_step="completed",
        )
        self._context.local_state.save_workflow(completed)
        self._context.local_state.set_active_workflow(None)
        self._context.local_state.release_repo_lock(completed.workflow_id)

    def _reconcile_direct_commits(
        self,
        journal: WorkflowJournal,
        *,
        definition: MetabranchDefinition,
        base_ref: str,
        resume_cursor: "_ResumeCursor",
    ) -> WorkflowJournal:
        commits = self._context.git_client.list_unique_commits(
            definition.name,
            exclude_refs=tuple(
                dict.fromkeys((base_ref, definition.base_ref, *definition.branches))
            ),
        )
        commits = [
            commit
            for commit in commits
            if not self._is_tool_managed_metadata_commit(commit)
            and not self._is_replayed_branch_commit(commit, definition)
            and not self._is_synthetic_integration_commit(commit, definition)
        ]
        if not commits:
            return journal

        self._report("Reconciling metabranch-only commits")

        plan = self._load_reconciliation_plan(journal, commits)
        working_journal = journal
        if plan is None:
            suggestions = self._suggest_reconciliation_targets(definition, commits)
            if len(suggestions) == len(commits):
                plan = [
                    ReconciliationPlanEntry(
                        target_branch=suggestions[commit.sha],
                        commit_sha=commit.sha,
                        subject=commit.subject,
                    )
                    for commit in commits
                ]
            else:
                plan = self._open_reconciliation_editor(
                    journal=journal,
                    definition=definition,
                    commits=commits,
                    suggestions=suggestions,
                )
            working_journal = journal.with_updates(
                args={
                    **journal.args,
                    "reconciliation_plan": [
                        entry.model_dump(mode="json") for entry in plan
                    ],
                }
            )
            self._context.local_state.save_workflow(working_journal)

        for entry in plan:
            step = (
                f"reconcile:{entry.commit_sha}->"
                f"{entry.target_branch or entry.action}"
            )
            if not resume_cursor.should_run(step):
                continue
            working_journal = self._context.save_workflow_step(
                working_journal,
                step,
            )
            self._apply_reconciliation_entry(entry)

        return working_journal

    def _load_reconciliation_plan(
        self,
        journal: WorkflowJournal,
        commits: list[ReconciliationCommit],
    ) -> list[ReconciliationPlanEntry] | None:
        raw_plan = journal.args.get("reconciliation_plan")
        if not isinstance(raw_plan, list):
            return None

        plan = []
        for entry in raw_plan:
            if not isinstance(entry, dict):
                continue
            try:
                plan.append(
                    validate_model(
                        entry,
                        ReconciliationPlanEntry,
                        "Invalid reconciliation plan entry",
                    )
                )
            except ValueError as exc:
                raise InvalidReconciliationPlanError(
                    "Saved reconciliation plan contains invalid entries."
                ) from exc
        expected_commits = {commit.sha for commit in commits}
        planned_commits = {entry.commit_sha for entry in plan}
        if expected_commits != planned_commits:
            raise InvalidReconciliationPlanError(
                "Saved reconciliation plan does not match the current metabranch-only commits."
            )
        return plan

    def _open_reconciliation_editor(
        self,
        *,
        journal: WorkflowJournal,
        definition: MetabranchDefinition,
        commits: list[ReconciliationCommit],
        suggestions: dict[str, str],
    ) -> list[ReconciliationPlanEntry]:
        initial_text = self._build_reconciliation_text(
            definition,
            commits,
            suggestions,
        )
        editor_path = self._context.local_state.write_tmp_file(
            f"reconciliation-{journal.workflow_id}.txt",
            initial_text,
        )
        edited_text = self._context.reconciliation_editor(initial_text, editor_path)
        return self._parse_reconciliation_text(
            edited_text,
            definition=definition,
            commits=commits,
        )

    def _build_reconciliation_text(
        self,
        definition: MetabranchDefinition,
        commits: list[ReconciliationCommit],
        suggestions: dict[str, str],
    ) -> str:
        lines = [
            "# Assign each commit to a contained branch.",
            "# Use `d` to drop a commit from the rebuilt metabranch.",
            "# Use one of these branches: " + ", ".join(definition.branches),
            "",
        ]
        for commit in commits:
            target = suggestions.get(commit.sha, "?")
            lines.append(f"{target} {commit.sha} {commit.subject}")
        return "\n".join(lines) + "\n"

    def _suggest_reconciliation_targets(
        self,
        definition: MetabranchDefinition,
        commits: list[ReconciliationCommit],
    ) -> dict[str, str]:
        subjects_by_branch = {
            branch_name: set(self._context.git_client.recent_commit_subjects(branch_name))
            for branch_name in definition.branches
        }
        suggestions: dict[str, str] = {}
        for commit in commits:
            matches = [
                branch_name
                for branch_name, subjects in subjects_by_branch.items()
                if commit.subject in subjects
            ]
            if len(matches) == 1:
                suggestions[commit.sha] = matches[0]
        return suggestions

    def _parse_reconciliation_text(
        self,
        text: str,
        *,
        definition: MetabranchDefinition,
        commits: list[ReconciliationCommit],
    ) -> list[ReconciliationPlanEntry]:
        commit_lookup = {commit.sha: commit for commit in commits}
        planned_entries: list[ReconciliationPlanEntry] = []

        for raw_line in text.splitlines():
            line = raw_line.strip()
            if not line or line.startswith("#"):
                continue
            parts = line.split(maxsplit=2)
            if len(parts) != 3:
                raise InvalidReconciliationPlanError(
                    "Each reconciliation line must be: <branch> <sha> <subject>"
                )
            target_branch, commit_sha, subject = parts
            if target_branch == "?":
                raise EditorCancelledError(
                    "Reconciliation was cancelled before every commit was assigned to a branch."
                )
            if target_branch not in definition.branches and target_branch not in {
                "d",
                "drop",
            }:
                raise InvalidReconciliationPlanError(
                    f"Unknown reconciliation target branch: {target_branch}"
                )
            if commit_sha not in commit_lookup:
                raise InvalidReconciliationPlanError(
                    f"Unknown reconciliation commit: {commit_sha}"
                )
            if commit_lookup[commit_sha].subject != subject:
                raise InvalidReconciliationPlanError(
                    f"Reconciliation subject mismatch for commit {commit_sha}"
                )
            if target_branch in {"d", "drop"}:
                planned_entries.append(
                    ReconciliationPlanEntry(
                        action="drop",
                        commit_sha=commit_sha,
                        subject=subject,
                    )
                )
                continue
            planned_entries.append(
                ReconciliationPlanEntry(
                    target_branch=target_branch,
                    commit_sha=commit_sha,
                    subject=subject,
                )
            )

        expected_commits = {commit.sha for commit in commits}
        planned_commits = {entry.commit_sha for entry in planned_entries}
        if expected_commits != planned_commits:
            raise InvalidReconciliationPlanError(
                "Reconciliation plan must assign or drop every metabranch-only commit exactly once."
            )
        return planned_entries

    def _apply_reconciliation_entry(self, entry: ReconciliationPlanEntry) -> None:
        if entry.action == "drop":
            return
        self._context.git_client.checkout(entry.target_branch)
        try:
            self._context.git_client.cherry_pick(entry.commit_sha)
        except git.GitCommandError as exc:
            if self._is_empty_cherry_pick_error(exc):
                self._context.git_client.skip_in_progress_operation()
                return
            raise

    def _is_empty_cherry_pick_error(self, exc: git.GitCommandError) -> bool:
        message = str(exc).lower()
        return (
            "previous cherry-pick is now empty" in message
            or "nothing to commit" in message
        )

    def _resume_reconciliation_without_git_state(self, journal: WorkflowJournal) -> bool:
        """Finish a legacy no-commit reconciliation step after the user stages a resolution."""
        if journal.status != "blocked" or not journal.current_step.startswith("reconcile:"):
            return False
        if self._context.git_client.has_unmerged_paths():
            raise WorkflowConflictError(
                "Reconciliation conflict is still unresolved; resolve it, stage the files, then run `metabranch resume`."
            )
        if not self._context.git_client.has_pending_changes():
            return False
        commit_sha = journal.current_step.removeprefix("reconcile:").split("->", 1)[0]
        self._report("Finalizing resolved reconciliation commit")
        self._context.git_client.commit(
            self._context.git_client.commit_message(commit_sha)
        )
        return True

    def _resume_skipped_reconciliation_step(self, journal: WorkflowJournal) -> bool:
        """Treat a manually skipped blocked reconciliation cherry-pick as completed."""
        if journal.status != "blocked" or not journal.current_step.startswith("reconcile:"):
            return False
        if self._context.git_client.has_unmerged_paths():
            raise WorkflowConflictError(
                "Reconciliation conflict is still unresolved; resolve it, stage the files, then run `metabranch resume`."
            )
        if self._context.git_client.has_pending_changes():
            return False
        target_branch = journal.current_step.split("->", 1)[1]
        if self._context.git_client.current_branch_name() != target_branch:
            return False
        self._report("Continuing after skipped reconciliation commit")
        return True

    def _is_synthetic_integration_commit(
        self,
        commit: ReconciliationCommit,
        definition: MetabranchDefinition,
    ) -> bool:
        if len(commit.parent_shas) < 2:
            return False

        return all(
            any(
                self._context.git_client.is_ancestor(parent_sha, branch_name)
                for branch_name in definition.branches
            )
            for parent_sha in commit.parent_shas[1:]
        )

    def _is_tool_managed_metadata_commit(self, commit: ReconciliationCommit) -> bool:
        if METADATA_COMMIT_TRAILER not in {
            line.strip() for line in commit.message.splitlines()
        }:
            return False
        return self._context.git_client.commit_paths(commit.sha) == (CONFIG_FILE_NAME,)

    def _is_replayed_branch_commit(
        self,
        commit: ReconciliationCommit,
        definition: MetabranchDefinition,
    ) -> bool:
        match = re.search(
            r"\(cherry picked from commit ([0-9a-f]{7,40})\)",
            commit.message,
        )
        if match is None:
            return False
        source_sha = match.group(1)
        return any(
            branch_name != definition.name
            and self._context.git_client.is_ancestor(source_sha, branch_name)
            for branch_name in self._context.git_client.list_branches()
        )

    def _finish_preflight_failure(self, journal: WorkflowJournal) -> None:
        """Clear active workflow state when a failure happened before any Git mutation."""
        self._context.local_state.set_active_workflow(None)
        self._context.local_state.release_repo_lock(journal.workflow_id)


class _ResumeCursor:
    """Skip steps already completed before a successful Git continue."""

    def __init__(self, resume_after_step: str | None) -> None:
        self.resume_after_step = resume_after_step
        self._matched = resume_after_step is None

    def should_run(self, step: str) -> bool:
        """Return whether the given step should execute in this pass."""
        if self.resume_after_step is None:
            return True
        if not self._matched:
            if step == self.resume_after_step:
                self._matched = True
            return False
        return True
