"""Line-selection support for include commands."""

from __future__ import annotations

from collections.abc import Sequence
from contextlib import ExitStack
from dataclasses import dataclass, replace
from enum import Enum
import sys
import uuid

from ...batch.ownership.model import BatchOwnership
from ...batch.ownership.hunk_translation import (
    translate_hunk_selection_to_batch_ownership,
)
from ...batch.ownership import insertion_references as _insertion_references
from ...batch.merge.merge import merge_batch_from_line_sequences_as_buffer
from ...batch.state.lifecycle import create_batch, delete_batch
from ...batch.ownership.metadata_loading import acquire_ownership_for_metadata_dict
from ...batch.state.query import read_batch_metadata
from ...batch.selection import line_selection_not_valid_message
from ...batch.text_file_storage import add_file_to_batch
from ...batch.state.batch_names import batch_exists
from ...core.buffer import LineBuffer, buffer_matches
from ...batch.source.snapshots import create_batch_source_commit
from ...batch.source.line_coordinates import translate_display_source_coordinates
from ...batch.realized_file_content import build_realized_buffer_from_lines
from ...core.models import LineEntry, LineLevelChange
from ...data.selected_change.file_hunk_cache import cache_unstaged_file_as_single_hunk
from ...data.file_modes import detect_file_mode
from ...data.file_tracking import auto_add_untracked_files
from ...data.line_state import require_line_changes_from_state
from ...utils.repository_buffers import (
    read_git_object_buffer_or_none,
    load_working_tree_file_as_buffer,
)
from ...data.selected_change.loading import require_selected_hunk
from ...data.selected_change.paths import get_selected_change_file_path
from ...data.selected_change.store import (
    SelectedChangeKind,
    SelectedChangeStateSnapshot,
    read_selected_change_kind,
    snapshot_selected_change_state,
)
from ...exceptions import MergeError, exit_with_error
from ...i18n import _
from ...staging.index_update import update_index_with_blob_buffer
from ...utils.file_io import write_file_bytes
from ...utils.git_index import git_write_tree
from ...utils.paths import get_session_batch_sources_file_path
from . import replacement_selection


class TransientIncludeFailureReason(Enum):
    """Why transient batch staging could not safely realize a line selection."""

    NO_SELECTED_LINES = "no_selected_lines"
    EMPTY_OWNERSHIP = "empty_ownership"
    PREPARATION_FAILED = "preparation_failed"
    MISSING_BATCH_METADATA = "missing_batch_metadata"
    MISSING_BATCH_SOURCE = "missing_batch_source"
    INDEX_MERGE_FAILED = "index_merge_failed"
    WORKING_TREE_MERGE_FAILED = "working_tree_merge_failed"
    WORKING_TREE_WOULD_CHANGE = "working_tree_would_change"


@dataclass(frozen=True)
class TransientIncludeResult:
    """Result of staging a live line selection through transient batch ownership."""

    buffer: LineBuffer | None
    failure_reason: TransientIncludeFailureReason | None = None
    failure_detail: str | None = None

    @classmethod
    def success(cls, buffer: LineBuffer) -> TransientIncludeResult:
        return cls(buffer=buffer)

    @classmethod
    def failure(
        cls,
        reason: TransientIncludeFailureReason,
        *,
        detail: str | None = None,
    ) -> TransientIncludeResult:
        return cls(buffer=None, failure_reason=reason, failure_detail=detail)


@dataclass(frozen=True)
class IncludeLineSelectionContext:
    """Resolved selected-line view for a live include action."""

    line_changes: LineLevelChange
    preserve_selected_state: bool = False
    saved_selected_state: SelectedChangeStateSnapshot | None = None
    reset_processed_include_ids: bool = False


def _snapshot_session_batch_sources_file() -> tuple[bool, bytes | None]:
    path = get_session_batch_sources_file_path()
    if not path.exists():
        return False, None
    return True, path.read_bytes()


def _restore_session_batch_sources_file(existed: bool, content: bytes | None) -> None:
    path = get_session_batch_sources_file_path()
    if existed:
        assert content is not None
        write_file_bytes(path, content)
        return
    try:
        path.unlink()
    except FileNotFoundError:
        pass


def selected_file_view_targets(target_file: str) -> bool:
    """Return whether the selected file view targets a path."""
    return (
        read_selected_change_kind() == SelectedChangeKind.FILE
        and get_selected_change_file_path() == target_file
    )


def selected_file_view_is_fresh_for(target_file: str) -> bool:
    """Return whether the selected file view can be reused for a path.

    A matching view represents the line IDs the user actually saw.  If its
    snapshots are stale, fail closed instead of rebuilding a different view
    and applying those old IDs to it.
    """
    if not selected_file_view_targets(target_file):
        return False
    require_selected_hunk()
    return True


def load_include_line_selection_context(
    file: str | None,
    selected_state_stack: ExitStack,
) -> IncludeLineSelectionContext:
    """Resolve the selected line view for include --line."""
    if file is None:
        require_selected_hunk()
        return IncludeLineSelectionContext(
            line_changes=annotate_line_changes_with_working_tree_source(
                require_line_changes_from_state()
            )
        )

    if file == "":
        target_file = get_selected_change_file_path()
        if target_file is None:
            exit_with_error(
                _("No selected hunk. Run 'show' first or specify file path.")
            )
    else:
        target_file = file

    auto_add_untracked_files([target_file])
    selected_file_view_targets_file = selected_file_view_targets(target_file)
    reuse_selected_file_view = selected_file_view_is_fresh_for(target_file)
    preserve_selected_state = False
    saved_selected_state = None

    if reuse_selected_file_view:
        line_changes = require_line_changes_from_state()
    else:
        if file != "" and not selected_file_view_targets_file:
            preserve_selected_state = True
            saved_selected_state = selected_state_stack.enter_context(
                snapshot_selected_change_state()
            )

        cached_line_changes = cache_unstaged_file_as_single_hunk(target_file)
        if cached_line_changes is None:
            exit_with_error(_("No changes in file '{file}'.").format(file=target_file))
        line_changes = cached_line_changes

    return IncludeLineSelectionContext(
        line_changes=annotate_line_changes_with_working_tree_source(line_changes),
        preserve_selected_state=preserve_selected_state,
        saved_selected_state=saved_selected_state,
        reset_processed_include_ids=not reuse_selected_file_view,
    )


def line_sequence_ends_with_lf(lines: Sequence[bytes]) -> bool:
    """Return whether a byte-line sequence has a trailing newline."""
    line_count = len(lines)
    return line_count > 0 and lines[line_count - 1].endswith(b"\n")


def annotate_line_changes_with_working_tree_source(
    line_changes: LineLevelChange,
) -> LineLevelChange:
    """Attach working-tree source line positions to line changes."""
    new_lines: list[LineEntry] = []
    for line, source_line in translate_display_source_coordinates(
        line_changes.lines,
        lambda line_number: line_number,
    ):
        new_lines.append(replace(line, source_line=source_line))

    return replace(line_changes, lines=new_lines)


def build_transient_index_buffer(
    *,
    source_lines: Sequence[bytes],
    ownership: BatchOwnership,
    current_index_lines: Sequence[bytes],
    hunk_base_lines: Sequence[bytes],
) -> LineBuffer:
    """Merge structurally, then use unchanged reviewed index coordinates."""
    try:
        return merge_batch_from_line_sequences_as_buffer(
            source_lines,
            ownership,
            current_index_lines,
        )
    except MergeError:
        # The ownership references use the reviewed index snapshot's
        # coordinates. They are a safe fallback only while the live index
        # still matches that exact baseline.
        if not buffer_matches(current_index_lines, hunk_base_lines):
            raise
        return build_realized_buffer_from_lines(
            current_index_lines,
            source_lines,
            ownership,
            preferred_line_ending_lines=current_index_lines,
        )


def try_build_index_content_via_transient_batch(
    *,
    line_changes: LineLevelChange,
    selected_display_ids: set[int],
    current_index_lines: Sequence[bytes],
    hunk_base_lines: Sequence[bytes],
    hunk_source_lines: Sequence[bytes],
) -> TransientIncludeResult:
    """Try staging live lines through transient batch ownership."""
    selected_lines = [
        line for line in line_changes.lines if line.id in selected_display_ids
    ]
    if not selected_lines:
        return TransientIncludeResult.failure(
            TransientIncludeFailureReason.NO_SELECTED_LINES
        )

    if not current_index_lines and all(line.kind == "+" for line in line_changes.lines):
        if any(line.source_line is None for line in selected_lines):
            return TransientIncludeResult.failure(
                TransientIncludeFailureReason.PREPARATION_FAILED,
                detail="missing source line for new-file selection",
            )
        with load_working_tree_file_as_buffer(line_changes.path) as working_lines:
            if not buffer_matches(working_lines, hunk_source_lines):
                return TransientIncludeResult.failure(
                    TransientIncludeFailureReason.WORKING_TREE_WOULD_CHANGE,
                    detail="working tree changed after new-file snapshot",
                )
        selected_source_lines: list[bytes] = []
        for line in selected_lines:
            assert line.source_line is not None
            selected_source_lines.append(hunk_source_lines[line.source_line - 1])
        return TransientIncludeResult.success(
            LineBuffer.from_chunks(selected_source_lines)
        )

    batch_name = f"include-line-{uuid.uuid4().hex}"
    session_sources_existed, session_sources_content = (
        _snapshot_session_batch_sources_file()
    )
    created_batch = False
    target_index_buffer: LineBuffer | None = None

    try:
        create_batch(
            batch_name,
            "Transient include-line selection",
            baseline_commit=git_write_tree(),
        )
        created_batch = True

        _insertion_references.record_baseline_references_for_additions(
            line_changes,
            baseline_lines=hunk_base_lines,
            source_lines=hunk_source_lines,
        )
        ownership = translate_hunk_selection_to_batch_ownership(
            line_changes.lines,
            selected_display_ids,
            baseline_lines=hunk_base_lines,
            replacement_line_runs=replacement_selection.derive_replacement_line_runs(
                hunk_base_lines=hunk_base_lines,
                hunk_source_lines=hunk_source_lines,
            ),
        )
        if ownership.is_empty():
            return TransientIncludeResult.failure(
                TransientIncludeFailureReason.EMPTY_OWNERSHIP
            )

        with load_working_tree_file_as_buffer(line_changes.path) as working_lines:
            batch_source_commit = create_batch_source_commit(
                line_changes.path,
                file_buffer_override=working_lines,
            )
            try:
                add_file_to_batch(
                    batch_name,
                    line_changes.path,
                    ownership,
                    detect_file_mode(line_changes.path),
                    batch_source_commit=batch_source_commit,
                )
            except MergeError as error:
                return TransientIncludeResult.failure(
                    TransientIncludeFailureReason.PREPARATION_FAILED,
                    detail=str(error),
                )

            metadata = read_batch_metadata(batch_name)
            file_metadata = metadata.get("files", {}).get(line_changes.path)
            if file_metadata is None:
                return TransientIncludeResult.failure(
                    TransientIncludeFailureReason.MISSING_BATCH_METADATA
                )

            stored_batch_source_commit = file_metadata.get("batch_source_commit")
            if not stored_batch_source_commit:
                return TransientIncludeResult.failure(
                    TransientIncludeFailureReason.MISSING_BATCH_METADATA
                )

            source_buffer = read_git_object_buffer_or_none(
                f"{stored_batch_source_commit}:{line_changes.path}"
            )
            if source_buffer is None:
                return TransientIncludeResult.failure(
                    TransientIncludeFailureReason.MISSING_BATCH_SOURCE
                )

            with (
                acquire_ownership_for_metadata_dict(file_metadata) as ownership,
                source_buffer as source_lines,
            ):
                try:
                    target_index_buffer = build_transient_index_buffer(
                        source_lines=source_lines,
                        ownership=ownership,
                        current_index_lines=current_index_lines,
                        hunk_base_lines=hunk_base_lines,
                    )
                except MergeError as error:
                    return TransientIncludeResult.failure(
                        TransientIncludeFailureReason.INDEX_MERGE_FAILED,
                        detail=str(error),
                    )

                try:
                    target_working_buffer = merge_batch_from_line_sequences_as_buffer(
                        source_lines,
                        ownership,
                        working_lines,
                    )
                except MergeError as error:
                    target_index_buffer.close()
                    target_index_buffer = None
                    return TransientIncludeResult.failure(
                        TransientIncludeFailureReason.WORKING_TREE_MERGE_FAILED,
                        detail=str(error),
                    )

                with target_working_buffer:
                    if not buffer_matches(working_lines, target_working_buffer):
                        target_index_buffer.close()
                        target_index_buffer = None
                        return TransientIncludeResult.failure(
                            TransientIncludeFailureReason.WORKING_TREE_WOULD_CHANGE
                        )

        assert target_index_buffer is not None
        return TransientIncludeResult.success(target_index_buffer)
    finally:
        if sys.exc_info()[0] is not None and target_index_buffer is not None:
            target_index_buffer.close()
        if created_batch and batch_exists(batch_name):
            delete_batch(batch_name)
        _restore_session_batch_sources_file(
            session_sources_existed,
            session_sources_content,
        )


def stage_live_line_target_buffer(file_path: str, target_buffer: LineBuffer) -> None:
    """Stage the result of live line-level include."""
    update_index_with_blob_buffer(file_path, target_buffer)


def transient_include_failure_message(
    *,
    reason: TransientIncludeFailureReason,
    line_id_specification: str,
    file_path: str,
) -> str:
    """Return a user-facing message for transient include failures."""
    if reason in (
        TransientIncludeFailureReason.NO_SELECTED_LINES,
        TransientIncludeFailureReason.EMPTY_OWNERSHIP,
    ):
        return line_selection_not_valid_message(
            line_id_specification=line_id_specification,
            file_path=file_path,
        )

    if reason in (
        TransientIncludeFailureReason.WORKING_TREE_MERGE_FAILED,
        TransientIncludeFailureReason.WORKING_TREE_WOULD_CHANGE,
    ):
        return _(
            "Cannot safely include selection {lines} from {file} because applying "
            "that selection would also change the working tree.\n"
            "Run 'git-stage-batch show --file {file}' and choose line IDs from "
            "the current file view."
        ).format(lines=line_id_specification, file=file_path)

    if reason == TransientIncludeFailureReason.INDEX_MERGE_FAILED:
        return _(
            "Cannot safely include selection {lines} from {file} because the "
            "selection no longer fits the current staged content.\n"
            "Run 'git-stage-batch show --file {file}' and choose line IDs from "
            "the current file view."
        ).format(lines=line_id_specification, file=file_path)

    return _(
        "Cannot safely include selection {lines} from {file}.\n"
        "Run 'git-stage-batch show --file {file}' and choose line IDs from "
        "the current file view."
    ).format(lines=line_id_specification, file=file_path)
