"""File-scoped include support."""

from __future__ import annotations

from collections.abc import Sequence
from contextlib import AbstractContextManager, contextmanager, nullcontext
import sys
from typing import Iterator

from ...core.diff_parser import (
    UnifiedDiffItem,
    acquire_unified_diff,
    patch_is_file_deletion,
)
from ...core.hashing import (
    compute_binary_file_hash,
    compute_file_mode_change_hash,
    compute_gitlink_change_hash,
    compute_rename_change_hash,
    compute_stable_hunk_hash_from_lines,
    compute_text_file_deletion_hash,
)
from ...core.models import (
    BinaryFileChange,
    FileModeChange,
    GitlinkChange,
    RenameChange,
    TextFileDeletionChange,
)
from ...data.file_tracking import auto_add_untracked_files
from ...data.live_diff import stream_live_git_diff
from ...data.progress import record_hunk_included
from ...data.selected_change.paths import get_selected_change_file_path
from ...data.undo.checkpoints import undo_checkpoint
from ...exceptions import exit_with_error
from ...i18n import _, ngettext
from ...utils.git_command import run_git_command
from ...utils.git_index import (
    git_add_paths,
    git_apply_to_index,
    git_read_tree,
    git_write_tree,
)
from ..selection import selected_change_staging as _selected_change_staging
from ..selection.action_completion import finish_selected_change_action
from .target_path import checkpoint_paths_for_live_file


@contextmanager
def _rollback_index_on_error() -> Iterator[None]:
    """Restore the pre-operation index if whole-file staging fails."""
    index_tree = git_write_tree()
    try:
        yield
    except BaseException:
        git_read_tree(index_tree)
        raise


def include_file_changes(
    file: str,
    *,
    quiet: bool = False,
    advance: bool = True,
    auto_advance: bool | None = None,
    _prepared_changes: Sequence[UnifiedDiffItem] | None = None,
) -> int:
    """Include one file; prepared inputs require a caller-owned transaction."""
    if file == "":
        target_file = get_selected_change_file_path()
        if target_file is None:
            diff_result = run_git_command(
                [
                    "-c",
                    "diff.ignoreSubmodules=none",
                    "diff",
                    "--ignore-submodules=none",
                    "--quiet",
                ],
                check=False,
                requires_index_lock=False,
            )
            if diff_result.returncode == 0:
                print(_("No changes to stage."), file=sys.stderr)
            else:
                print(
                    _("No selected hunk. Run 'show' first or specify file path."),
                    file=sys.stderr,
                )
            return 0
    else:
        target_file = file

    checkpoint: AbstractContextManager[object]
    patch_context: AbstractContextManager[Iterator[UnifiedDiffItem]]
    rollback_context: AbstractContextManager[None]
    if _prepared_changes is None:
        auto_add_untracked_files([target_file])
        checkpoint_paths = checkpoint_paths_for_live_file(target_file)
        checkpoint = undo_checkpoint(
            f"include --file {file}".rstrip(),
            worktree_paths=checkpoint_paths,
        )
        patch_context = acquire_unified_diff(
            stream_live_git_diff(
                full_index=True,
                ignore_submodules="none",
                submodule_format="short",
            )
        )
        rollback_context = _rollback_index_on_error()
    else:
        checkpoint = nullcontext()
        patch_context = nullcontext(iter(_prepared_changes))
        rollback_context = nullcontext()

    with checkpoint:
        hunks_staged = 0
        submodule_pointers_staged = 0
        renames_staged = 0
        included_hashes: list[str] = []
        staged_rename_pairs: set[tuple[str, str]] = set()
        with rollback_context, patch_context as patches:
            for patch in patches:
                if isinstance(patch, FileModeChange):
                    if target_file not in (patch.path(), patch.index_path):
                        continue
                    _selected_change_staging.stage_file_mode_change(patch)
                    included_hashes.append(compute_file_mode_change_hash(patch))
                    hunks_staged += 1
                    continue

                if isinstance(patch, RenameChange):
                    if target_file not in (patch.old_path, patch.new_path):
                        continue

                    _selected_change_staging.stage_rename_change(patch)
                    result = git_add_paths([patch.new_path], check=False)
                    if result.returncode != 0:
                        exit_with_error(
                            _("Failed to stage renamed file: {error}").format(
                                error=result.stderr,
                            )
                        )
                    included_hashes.append(compute_rename_change_hash(patch))
                    hunks_staged += 1
                    renames_staged += 1
                    staged_rename_pairs.add((patch.old_path, patch.new_path))
                    continue

                if isinstance(patch, TextFileDeletionChange):
                    if patch.path() != target_file:
                        continue

                    _selected_change_staging.stage_text_deletion_change(patch)
                    included_hashes.append(compute_text_file_deletion_hash(patch))
                    hunks_staged += 1
                    continue

                if isinstance(patch, GitlinkChange):
                    if patch.path() == target_file:
                        result = _selected_change_staging.stage_gitlink_change(patch)
                        if result.returncode != 0:
                            exit_with_error(
                                _("Failed to stage submodule pointer: {error}").format(
                                    error=result.stderr,
                                )
                            )
                        included_hashes.append(compute_gitlink_change_hash(patch))
                        hunks_staged += 1
                        submodule_pointers_staged += 1
                    continue

                if isinstance(patch, BinaryFileChange):
                    file_path = patch.path()
                    if file_path != target_file:
                        continue

                    result = git_add_paths([file_path], check=False)
                    if result.returncode != 0:
                        exit_with_error(
                            _("Failed to stage binary file: {error}").format(
                                error=result.stderr,
                            )
                        )

                    included_hashes.append(compute_binary_file_hash(patch))
                    hunks_staged += 1
                    continue

                patch_paths = {
                    path
                    for path in (patch.old_path, patch.new_path)
                    if path != "/dev/null"
                }
                if target_file not in patch_paths:
                    continue

                if (patch.old_path, patch.new_path) in staged_rename_pairs:
                    continue

                patch_hash = compute_stable_hunk_hash_from_lines(patch.lines)

                if patch.old_path != patch.new_path:
                    result = git_add_paths(sorted(patch_paths), check=False)
                    if result.returncode != 0:
                        exit_with_error(
                            _("Failed to stage file: {error}").format(
                                error=result.stderr,
                            )
                        )

                    included_hashes.append(patch_hash)
                    hunks_staged += 1
                    continue

                if patch_is_file_deletion(patch.lines):
                    _selected_change_staging.stage_text_file_deletion(target_file)
                    apply_result = None
                else:
                    apply_result = git_apply_to_index(patch.lines, check=False)
                if apply_result is None or apply_result.returncode == 0:
                    included_hashes.append(patch_hash)
                    hunks_staged += 1
                else:
                    exit_with_error(
                        _("Failed to apply hunk: {error}").format(
                            error=apply_result.stderr,
                        )
                    )

        for patch_hash in included_hashes:
            record_hunk_included(patch_hash)

    if hunks_staged == 0:
        if not quiet:
            print(
                _("No hunks staged from {file}").format(file=target_file),
                file=sys.stderr,
            )
        return 0

    if quiet and advance:
        finish_selected_change_action(quiet=True, auto_advance=auto_advance)
    if quiet:
        return hunks_staged

    if renames_staged == hunks_staged:
        msg = ngettext(
            "✓ Staged {count} rename from {file}",
            "✓ Staged {count} renames from {file}",
            hunks_staged,
        ).format(count=hunks_staged, file=target_file)
    elif submodule_pointers_staged == hunks_staged:
        msg = ngettext(
            "✓ Staged {count} submodule pointer from {file}",
            "✓ Staged {count} submodule pointers from {file}",
            hunks_staged,
        ).format(count=hunks_staged, file=target_file)
    else:
        msg = ngettext(
            "✓ Staged {count} hunk from {file}",
            "✓ Staged {count} hunks from {file}",
            hunks_staged,
        ).format(count=hunks_staged, file=target_file)
    print(msg, file=sys.stderr)

    if advance:
        finish_selected_change_action(quiet=False, auto_advance=auto_advance)
    return hunks_staged
