"""Merge candidate enumeration for reviewed ambiguity resolution."""

from __future__ import annotations

from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from . import baseline_anchor_matching as _baseline_anchor_matching
from .absence_constraints import (
    AbsenceChoice as _MergeAbsenceChoice,
    absence_ambiguity_key as _merge_absence_ambiguity_key,
    absence_choices_for_claim as _merge_absence_choices_for_claim,
)
from . import presence_constraints as _presence_constraints
from .presence_mapping import (
    match_uncontrolled_context_lines as _match_uncontrolled_context_lines,
    match_lines_preserving_unowned_context as _match_presence_lines,
    presence_lines_requiring_context_protection as _protected_presence_lines,
)
from .source_alternative_constraints import (
    resolve_effective_merge_constraints as _resolve_effective_constraints,
)
from .baseline_replacement_choices import (
    ReplacementOriginChoice as _BaselineReplacementOriginChoice,
    replacement_origin_choices_for_unit as _replacement_origin_choices_for_unit,
)
from .baseline_replacement_ranges import (
    collect_replacement_source_ranges as _collect_replacement_source_ranges,
    selected_replacement_source_ranges as _selected_replacement_source_ranges,
)
from ..line_matching.match import match_lines
from ..line_matching.match_workspace import MatcherWorkspace
from .candidates import (
    MergeCandidate as _MergeCandidate,
    MergeCandidateSet as _MergeCandidateSet,
    MergeResolution as _MergeResolution,
    MergeResolutionDecision as _MergeResolutionDecision,
)
from .coordinate_strategy import (
    AMBIGUITY_KEY as _COORDINATE_STRATEGY_AMBIGUITY_KEY,
    CoordinateStrategyChoice as _CoordinateStrategyChoice,
    presence_lines_requiring_distinctive_context as _distinctive_context_lines,
)
from .validation import (
    check_structural_validity as _check_merge_structural_validity,
    has_unsafe_mapped_origin_old_side_claims as _has_unsafe_mapped_old_side,
)
from . import presence_placement_choices as _presence_placement_choices
from ...core.line_selection import LineRanges, LineSelection, coerce_line_ranges
from ...core.resource_cleanup import (
    close_resources_on_exit,
    close_resources_preserving_first,
)
from ...exceptions import MergeError as _MergeError
from ...i18n import _

if TYPE_CHECKING:
    from ..line_matching.line_mapping import LineMapping
    from ..ownership.model import BatchOwnership
    from ..ownership.absence_claims import AbsenceClaim


_MergeResolutionValidator = Callable[[_MergeResolution], bool]


@dataclass(frozen=True, slots=True)
class _UnresolvedReplacementOrigin:
    """One replacement origin that still needs an explicit placement."""

    source_start: int
    source_end: int
    deletion_index: int
    ambiguity_key: str
    choices: tuple[_BaselineReplacementOriginChoice, ...]


class _MixedReplacementOrigin(Exception):
    """Signal that origin-tracked content is only partly realized."""


def _coordinate_strategy_candidate_set(
    *,
    strategies_differ: bool,
    max_candidates: int,
) -> _MergeCandidateSet:
    """Offer both merge strategies when their valid results disagree."""
    if not strategies_differ:
        return _MergeCandidateSet.refused()
    if max_candidates < 2:
        raise _MergeError(_("Too many merge candidates to preview safely"))

    explanation = _(
        "recorded baseline coordinates and structural content matching "
        "produce different results"
    )
    choices = (
        (
            _CoordinateStrategyChoice.STRUCTURAL,
            _("use structural content matching"),
        ),
        (
            _CoordinateStrategyChoice.RECORDED_COORDINATES,
            _("use recorded baseline coordinates"),
        ),
    )
    return _MergeCandidateSet.review_required(
        tuple(
            _MergeCandidate(
                ordinal=ordinal,
                count=len(choices),
                decisions=(
                    _MergeResolutionDecision(
                        ambiguity_key=_COORDINATE_STRATEGY_AMBIGUITY_KEY,
                        choice_index=choice_index.value,
                    ),
                ),
                summary=summary,
                source_line_range=None,
                target_after_line=None,
                target_before_line=None,
                explanation=explanation,
                ambiguity_target_line_range=None,
            )
            for ordinal, (choice_index, summary) in enumerate(
                choices,
                start=1,
            )
        )
    )


def _find_unresolved_replacement_origin(
    source_lines: Sequence[bytes],
    ownership: "BatchOwnership",
    working_lines: Sequence[bytes],
    presence_line_set: LineSelection,
    deletion_claims: Sequence["AbsenceClaim"],
    source_to_working_mapping: LineMapping,
    *,
    max_candidates: int,
    spool_dir: str | Path | None,
) -> _UnresolvedReplacementOrigin | None:
    """Return the only unresolved split replacement, if there is one."""
    with MatcherWorkspace(spool_dir=spool_dir) as range_workspace:
        selected_presence = coerce_line_ranges(presence_line_set)
        unresolved = None
        for unit_index, unit in enumerate(ownership.replacement_units):
            if unit.origin is None:
                continue

            claimed_ranges = _collect_replacement_source_ranges(
                range_workspace,
                unit.presence_lines,
            )
            if claimed_ranges is None:
                raise _MergeError(
                    _("Batch was created from a different version of the file")
                )
            with range_workspace.release_resource_on_exit(claimed_ranges):
                source_start: int | None = None
                source_end: int | None = None
                has_mapped_claimed_line = False
                has_missing_claimed_line = False
                for (
                    claimed_start,
                    claimed_end,
                ) in _selected_replacement_source_ranges(
                    claimed_ranges,
                    selected_presence,
                ):
                    if source_start is None:
                        source_start = claimed_start
                    source_end = claimed_end
                    for claimed_line in range(claimed_start, claimed_end + 1):
                        if (
                            claimed_line > len(source_lines)
                            or source_to_working_mapping.get_target_line_from_source_line(
                                claimed_line
                            )
                            is None
                        ):
                            has_missing_claimed_line = True
                        else:
                            has_mapped_claimed_line = True

                if source_start is None or source_end is None:
                    continue
                if has_mapped_claimed_line and has_missing_claimed_line:
                    raise _MixedReplacementOrigin
                if not has_missing_claimed_line:
                    continue
                if len(unit.deletion_indices) != 1:
                    raise _MergeError(
                        _("Batch was created from a different version of the file")
                    )

                deletion_index = unit.deletion_indices[0]
                if type(deletion_index) is not int:
                    raise _MergeError(
                        _("Batch was created from a different version of the file")
                    )
                if deletion_index < 0 or deletion_index >= len(deletion_claims):
                    raise _MergeError(
                        _("Batch was created from a different version of the file")
                    )
                key, choices = _replacement_origin_choices_for_unit(
                    deletion_claims[deletion_index],
                    unit_index,
                    unit,
                    _selected_replacement_source_ranges(
                        claimed_ranges,
                        selected_presence,
                    ),
                    working_lines,
                    max_results=max_candidates + 1,
                )
                if key is None:
                    continue
                candidate = _UnresolvedReplacementOrigin(
                    source_start=source_start,
                    source_end=source_end,
                    deletion_index=deletion_index,
                    ambiguity_key=key,
                    choices=choices,
                )
                if unresolved is not None:
                    raise _MergeError(
                        _("Multiple split replacement placements need review")
                    )
                unresolved = candidate
        return unresolved


def _replacement_origin_candidate_set_from_unresolved(
    unresolved: _UnresolvedReplacementOrigin | None,
    deletion_claims: Sequence[AbsenceClaim],
    *,
    resolution_is_valid: _MergeResolutionValidator,
    max_candidates: int,
) -> _MergeCandidateSet:
    """Build reviewed candidates from one completed origin discovery pass."""
    if unresolved is None:
        return _MergeCandidateSet.refused()

    source_start = unresolved.source_start
    source_end = unresolved.source_end
    deletion_index = unresolved.deletion_index
    key = unresolved.ambiguity_key
    choices = unresolved.choices
    if len(choices) > max_candidates:
        raise _MergeError(_("Too many merge candidates to preview safely"))

    valid_choices: list[_BaselineReplacementOriginChoice] = []
    for choice in choices:
        resolution = _MergeResolution({key: choice.choice_index})
        if resolution_is_valid(resolution):
            valid_choices.append(choice)

    if not valid_choices:
        return _MergeCandidateSet.refused()

    count = len(valid_choices)
    claim = deletion_claims[deletion_index]
    line_count = len(claim.content_lines)
    ambiguity_target_line_range = (
        min(choice.position + 1 for choice in valid_choices),
        max(choice.position + line_count for choice in valid_choices),
    )
    candidates: list[_MergeCandidate] = []
    for ordinal, choice in enumerate(valid_choices, start=1):
        target_start = choice.position + 1
        target_end = choice.position + line_count
        source_range = (
            str(source_start)
            if source_start == source_end
            else f"{source_start}-{source_end}"
        )
        target_range = (
            str(target_start)
            if target_start == target_end
            else f"{target_start}-{target_end}"
        )
        summary = _("replace target lines {target} with source lines {source}").format(
            target=target_range, source=source_range
        )
        candidates.append(
            _MergeCandidate(
                ordinal=ordinal,
                count=count,
                decisions=(
                    _MergeResolutionDecision(
                        ambiguity_key=key,
                        choice_index=choice.choice_index,
                    ),
                ),
                summary=summary,
                source_line_range=(source_start, source_end),
                target_after_line=choice.target_after_line,
                target_before_line=choice.target_before_line,
                explanation=_(
                    "original replacement boundary is not present; "
                    "selected replacement content has multiple compatible placements"
                ),
                ambiguity_target_line_range=ambiguity_target_line_range,
            )
        )
    return _MergeCandidateSet.review_required(tuple(candidates))


def _presence_candidate_set(
    source_lines: Sequence[bytes],
    ownership: "BatchOwnership",
    working_lines: Sequence[bytes],
    presence_line_set: LineRanges,
    deletion_claims: Sequence["AbsenceClaim"],
    controlled_source_lines: LineRanges,
    source_alternative_lines: LineRanges,
    *,
    resolution_is_valid: _MergeResolutionValidator,
    max_candidates: int,
    spool_dir: str | Path | None,
) -> _MergeCandidateSet:
    distinctive_context_lines = _distinctive_context_lines(
        ownership,
        presence_line_set,
        deletion_claims,
        target_lines=working_lines,
        spool_dir=spool_dir,
    )
    presence_mapping_result = _match_presence_lines(
        source_lines,
        working_lines,
        controlled_source_lines,
        ownership=ownership,
        presence_lines=presence_line_set,
        preferred_context_lines=source_alternative_lines,
        spool_dir=spool_dir,
        matcher=match_lines,
    )
    presence_mapping = presence_mapping_result.mapping
    owns_presence_mapping = presence_mapping_result.owned
    if presence_mapping_result.ambiguous:
        if owns_presence_mapping:
            presence_mapping.close()
        presence_mapping = _match_uncontrolled_context_lines(
            source_lines,
            working_lines,
            controlled_source_lines,
            spool_dir=spool_dir,
            matcher=match_lines,
        )
        owns_presence_mapping = True
    try:
        presence_key, presence_choices = (
            _presence_placement_choices.presence_choices_for_missing_claimed_run(
                source_lines,
                working_lines,
                presence_line_set,
                presence_mapping,
                max_results=max_candidates + 1,
                trusted_source_lines={
                    deletion.anchor_line
                    for deletion in deletion_claims
                    if deletion.anchor_line is not None and deletion.content_lines
                },
                distinctive_context_lines=distinctive_context_lines,
                spool_dir=spool_dir,
            )
        )
    except BaseException:
        if owns_presence_mapping:
            close_resources_preserving_first(
                (presence_mapping,),
                suppress_errors=True,
            )
        raise
    if owns_presence_mapping:
        presence_mapping.close()

    if presence_key is not None and len(presence_choices) > max_candidates:
        raise _MergeError(_("Too many merge candidates to preview safely"))
    if presence_key is None or len(presence_choices) <= 1:
        return _MergeCandidateSet.refused()

    valid_choices: list[_presence_placement_choices.PresenceChoice] = []
    for choice in presence_choices:
        resolution = _MergeResolution({presence_key: choice.choice_index})
        if resolution_is_valid(resolution):
            valid_choices.append(choice)

    if len(valid_choices) <= 1:
        return _MergeCandidateSet.refused()

    count = len(valid_choices)
    ambiguity_target_line_range = (
        _presence_placement_choices.presence_ambiguity_target_line_range(
            valid_choices,
            len(working_lines),
        )
    )
    candidates: list[_MergeCandidate] = []
    for ordinal, choice in enumerate(valid_choices, start=1):
        summary = _(
            "insert source lines {start}-{end} after target line {after}, "
            "before target line {before}"
        ).format(
            start=choice.run_start,
            end=choice.run_end,
            after=choice.target_after_line or "start",
            before=choice.target_before_line or "end",
        )
        candidates.append(
            _MergeCandidate(
                ordinal=ordinal,
                count=count,
                decisions=(
                    _MergeResolutionDecision(
                        ambiguity_key=presence_key,
                        choice_index=choice.choice_index,
                    ),
                ),
                summary=summary,
                source_line_range=(choice.run_start, choice.run_end),
                target_after_line=choice.target_after_line,
                target_before_line=choice.target_before_line,
                explanation=_(
                    "surrounding source context has multiple compatible placements"
                ),
                ambiguity_target_line_range=ambiguity_target_line_range,
            )
        )
    return _MergeCandidateSet.review_required(tuple(candidates))


def _absence_candidate_set(
    source_lines: Sequence[bytes],
    ownership: "BatchOwnership",
    working_lines: Sequence[bytes],
    presence_line_set: LineRanges,
    deletion_claims: Sequence["AbsenceClaim"],
    controlled_source_lines: LineRanges,
    source_alternative_lines: LineRanges,
    *,
    resolution_is_valid: _MergeResolutionValidator,
    max_candidates: int,
    spool_dir: str | Path | None,
) -> _MergeCandidateSet:
    if not deletion_claims:
        return _MergeCandidateSet.refused()

    claim_index: int | None = None
    claim: AbsenceClaim | None = None
    for candidate_index, candidate_claim in enumerate(deletion_claims):
        if not candidate_claim.content_lines:
            continue
        if claim is not None:
            raise _MergeError(
                _("Batch was created from a different version of the file")
            )
        claim_index = candidate_index
        claim = candidate_claim
    if claim is None or claim_index is None:
        raise _MergeError(_("Batch was created from a different version of the file"))

    distinctive_context_lines = _distinctive_context_lines(
        ownership,
        presence_line_set,
        deletion_claims,
        target_lines=working_lines,
        spool_dir=spool_dir,
    )

    mapping_result = _match_presence_lines(
        source_lines,
        working_lines,
        controlled_source_lines,
        ownership=ownership,
        presence_lines=presence_line_set,
        preferred_context_lines=source_alternative_lines,
        spool_dir=spool_dir,
        matcher=match_lines,
    )
    owned_mapping = mapping_result.mapping
    if mapping_result.ambiguous:
        if mapping_result.owned:
            owned_mapping.close()
        return _MergeCandidateSet.refused()
    realized_entries = None
    try:
        contextual_placements = _check_merge_structural_validity(
            owned_mapping,
            presence_line_set,
            deletion_claims,
            source_lines,
            working_lines,
            distinctive_presence_context_lines=distinctive_context_lines,
            spool_dir=spool_dir,
        )
        realized_entries = _presence_constraints.apply_presence_constraints(
            source_lines,
            working_lines,
            presence_line_set,
            source_to_working_mapping=owned_mapping,
            distinctive_context_lines=distinctive_context_lines,
            contextual_placements=contextual_placements,
            spool_dir=spool_dir,
        )
    except BaseException:
        if mapping_result.owned:
            close_resources_preserving_first(
                (owned_mapping,),
                suppress_errors=True,
            )
        raise
    if mapping_result.owned:
        try:
            owned_mapping.close()
        except BaseException:
            close_resources_preserving_first(
                (realized_entries,),
                suppress_errors=True,
            )
            raise

    assert realized_entries is not None

    with close_resources_on_exit((realized_entries,)):
        forbidden_sequence = claim.content_lines
        ambiguity_key = _merge_absence_ambiguity_key(
            claim_index,
            claim.anchor_line,
            forbidden_sequence,
        )
        choices = _merge_absence_choices_for_claim(
            realized_entries,
            claim.anchor_line,
            forbidden_sequence,
            max_results=max_candidates + 1,
        )
        if len(choices) > max_candidates:
            raise _MergeError(_("Too many merge candidates to preview safely"))
        if len(choices) <= 1:
            return _MergeCandidateSet.refused()

        valid_choices: list[_MergeAbsenceChoice] = []
        for choice in choices:
            resolution = _MergeResolution({ambiguity_key: choice.choice_index})
            if resolution_is_valid(resolution):
                valid_choices.append(choice)

        if len(valid_choices) <= 1:
            return _MergeCandidateSet.refused()

        count = len(valid_choices)
        ambiguity_target_line_range = (
            min(choice.position + 1 for choice in valid_choices),
            max(choice.position + len(forbidden_sequence) for choice in valid_choices),
        )
        candidates: list[_MergeCandidate] = []
        for ordinal, choice in enumerate(valid_choices, start=1):
            target_start = choice.position + 1
            target_end = choice.position + len(forbidden_sequence)
            summary = (
                _("delete target lines {start}-{end}").format(
                    start=target_start,
                    end=target_end,
                )
                if target_start != target_end
                else _("delete target line {line}").format(line=target_start)
            )
            candidates.append(
                _MergeCandidate(
                    ordinal=ordinal,
                    count=count,
                    decisions=(
                        _MergeResolutionDecision(
                            ambiguity_key=ambiguity_key,
                            choice_index=choice.choice_index,
                        ),
                    ),
                    summary=summary,
                    source_line_range=(
                        (claim.anchor_line, claim.anchor_line)
                        if claim.anchor_line is not None
                        else None
                    ),
                    target_after_line=choice.target_after_line,
                    target_before_line=choice.target_before_line,
                    explanation=_(
                        "deletion anchor has multiple compatible target placements"
                    ),
                    ambiguity_target_line_range=ambiguity_target_line_range,
                )
            )
        return _MergeCandidateSet.review_required(tuple(candidates))


def enumerate_merge_batch_candidates_for_lines(
    source_lines: Sequence[bytes],
    ownership: "BatchOwnership",
    working_lines: Sequence[bytes],
    *,
    resolution_is_valid: _MergeResolutionValidator,
    max_candidates: int,
    coordinate_strategies_differ: bool = False,
    spool_dir: str | Path | None = None,
) -> _MergeCandidateSet:
    """Enumerate merge candidates for acquired normalized line sequences."""
    resolved = ownership.resolve()
    effective_constraints = _resolve_effective_constraints(
        source_lines,
        ownership,
        resolved.presence_line_set,
        resolved.deletion_claims,
        spool_dir=spool_dir,
    )
    presence_line_set = effective_constraints.presence_lines
    deletion_claims = effective_constraints.deletion_claims
    source_alternative_lines = effective_constraints.source_alternative_lines
    unresolved_replacement = None
    has_replacement_origin = any(
        getattr(unit, "origin", None) is not None
        for unit in ownership.replacement_units
    )
    discovery_controlled_source_lines = (
        resolved.presence_line_set if source_alternative_lines else LineRanges.empty()
    )
    if has_replacement_origin:
        try:
            discovery_result = _match_presence_lines(
                source_lines,
                working_lines,
                discovery_controlled_source_lines,
                ownership=ownership,
                presence_lines=presence_line_set,
                preferred_context_lines=source_alternative_lines,
                spool_dir=spool_dir,
                matcher=match_lines,
            )
            discovery_mapping = discovery_result.mapping
            with close_resources_on_exit(
                (discovery_mapping if discovery_result.owned else None,)
            ):
                if discovery_result.ambiguous:
                    raise _MixedReplacementOrigin
                unresolved_replacement = _find_unresolved_replacement_origin(
                    source_lines,
                    ownership,
                    working_lines,
                    presence_line_set,
                    deletion_claims,
                    discovery_mapping,
                    max_candidates=max_candidates,
                    spool_dir=spool_dir,
                )
                with _baseline_anchor_matching.acquire_deletion_anchor_pairs_for_target(
                    source_lines,
                    working_lines,
                    deletion_claims,
                    spool_dir=spool_dir,
                ) as deletion_anchor_pairs:
                    if deletion_anchor_pairs:
                        with match_lines(
                            source_lines,
                            working_lines,
                            anchor_pairs=deletion_anchor_pairs,
                            spool_dir=spool_dir,
                        ) as ordinary_structural_mapping:
                            structural_result = _match_presence_lines(
                                source_lines,
                                working_lines,
                                discovery_controlled_source_lines,
                                ownership=ownership,
                                presence_lines=presence_line_set,
                                preferred_context_lines=source_alternative_lines,
                                ordinary_mapping=ordinary_structural_mapping,
                                spool_dir=spool_dir,
                                matcher=match_lines,
                            )
                            structural_mapping = structural_result.mapping
                            with close_resources_on_exit(
                                (
                                    structural_mapping
                                    if structural_result.owned
                                    else None,
                                )
                            ):
                                unsafe_old_side = (
                                    structural_result.ambiguous
                                    or _has_unsafe_mapped_old_side(
                                        ownership,
                                        presence_line_set,
                                        source_lines,
                                        working_lines,
                                        structural_mapping,
                                        spool_dir=spool_dir,
                                    )
                                )
                    else:
                        unsafe_old_side = _has_unsafe_mapped_old_side(
                            ownership,
                            presence_line_set,
                            source_lines,
                            working_lines,
                            discovery_mapping,
                            spool_dir=spool_dir,
                        )
                    if unsafe_old_side:
                        raise _MixedReplacementOrigin
        except _MixedReplacementOrigin:
            return _MergeCandidateSet.refused()

    coordinate_strategy_candidates = _coordinate_strategy_candidate_set(
        strategies_differ=coordinate_strategies_differ,
        max_candidates=max_candidates,
    )
    if coordinate_strategy_candidates.candidates:
        return coordinate_strategy_candidates

    replacement_candidates = _replacement_origin_candidate_set_from_unresolved(
        unresolved_replacement,
        deletion_claims,
        resolution_is_valid=resolution_is_valid,
        max_candidates=max_candidates,
    )
    if replacement_candidates.candidates:
        return replacement_candidates

    controlled_source_lines = _protected_presence_lines(
        ownership,
        resolved.presence_line_set,
        deletion_claims,
    )
    presence_candidates = _presence_candidate_set(
        source_lines,
        ownership,
        working_lines,
        presence_line_set,
        deletion_claims,
        controlled_source_lines,
        source_alternative_lines,
        resolution_is_valid=resolution_is_valid,
        max_candidates=max_candidates,
        spool_dir=spool_dir,
    )
    if presence_candidates.candidates:
        return presence_candidates

    return _absence_candidate_set(
        source_lines,
        ownership,
        working_lines,
        presence_line_set,
        deletion_claims,
        controlled_source_lines,
        source_alternative_lines,
        resolution_is_valid=resolution_is_valid,
        max_candidates=max_candidates,
        spool_dir=spool_dir,
    )
