"""Helpers for generating patches to fix files."""

import logging
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Optional

from sqlfluff.core.helpers.slice import is_zero_slice
from sqlfluff.core.parser import BaseSegment
from sqlfluff.core.parser.markers import PositionMarker
from sqlfluff.core.templaters import TemplatedFile

linter_logger = logging.getLogger("sqlfluff.linter")


@dataclass
class FixPatch:
    """An edit patch for a source file."""

    templated_slice: slice
    fixed_raw: str
    # The patch category, functions mostly for debugging and explanation
    # than for function. It allows traceability of *why* this patch was
    # generated. It has no significance for processing.
    patch_category: str
    source_slice: slice
    templated_str: str
    source_str: str

    def dedupe_tuple(self) -> tuple[tuple[int, int], str]:
        """Generate a tuple of this fix for deduping."""
        return (
            (self.source_slice.start, self.source_slice.stop),
            self.fixed_raw,
        )


def _iter_source_fix_patches(
    segment: BaseSegment, templated_file: TemplatedFile
) -> Iterator[FixPatch]:
    """Yield any source patches as fixes now.

    NOTE: This yields source fixes for the segment and any of its
    children, so it's important to call it at the right point in
    the recursion to avoid yielding duplicates.
    """
    for source_fix in segment.source_fixes:
        yield FixPatch(
            source_fix.templated_slice,
            source_fix.edit,
            patch_category="source",
            source_slice=source_fix.source_slice,
            templated_str=templated_file.templated_str[source_fix.templated_slice],
            source_str=templated_file.source_str[source_fix.source_slice],
        )


def _iter_templated_patches(
    segment: BaseSegment, templated_file: TemplatedFile
) -> Iterator[FixPatch]:
    """Iterate through the segments generating fix patches.

    The patches are generated in TEMPLATED space. This is important
    so that we defer dealing with any loops until later. At this stage
    everything *should* happen in templated order.

    Occasionally we have an insertion around a placeholder, so we also
    return a hint to deal with that.
    """
    # Does it match? If so we can ignore it.
    assert segment.pos_marker
    templated_raw = templated_file.templated_str[segment.pos_marker.templated_slice]
    matches = segment.raw == templated_raw
    if matches:
        # First yield any source fixes
        yield from _iter_source_fix_patches(segment, templated_file)
        # Then return.
        return

    # If we're here, the segment doesn't match the original.
    linter_logger.debug(
        "# Changed Segment Found: %s at %s: Original: [%r] Fixed: [%r]",
        type(segment).__name__,
        segment.pos_marker.templated_slice,
        templated_raw,
        segment.raw,
    )

    # If it's all literal, then we don't need to recurse.
    if segment.pos_marker.is_literal():
        # First yield any source fixes
        yield from _iter_source_fix_patches(segment, templated_file)
        # Then yield the position in the source file and the patch
        yield FixPatch(
            source_slice=segment.pos_marker.source_slice,
            templated_slice=segment.pos_marker.templated_slice,
            patch_category="literal",
            fixed_raw=segment.raw,
            templated_str=templated_file.templated_str[
                segment.pos_marker.templated_slice
            ],
            source_str=templated_file.source_str[segment.pos_marker.source_slice],
        )
    # Can we go deeper?
    elif not segment.segments:
        # It's not literal, but it's also a raw segment. If we're going
        # to yield a change, we would have done it from the parent, so
        # we just abort from here.
        return  # pragma: no cover TODO?
    else:
        # This segment isn't a literal, but has changed, we need to go deeper.

        # If there's an end of file segment or indent, ignore them just for the
        # purposes of patch iteration.
        # NOTE: This doesn't mutate the underlying `self.segments`.
        segments = segment.segments
        while segments and segments[-1].is_type("end_of_file", "indent"):
            segments = segments[:-1]

        # Iterate through the child segments
        source_idx = segment.pos_marker.source_slice.start
        templated_idx = segment.pos_marker.templated_slice.start
        insert_buff = ""
        first_segment_pos: Optional[PositionMarker] = None
        for seg in segments:
            # First check for insertions.
            # At this stage, everything should have a position.
            assert seg.pos_marker
            # We know it's an insertion if it has length but not in the templated
            # file.
            if seg.raw and seg.pos_marker.is_point():
                # Add it to the insertion buffer if it has length:
                if seg.raw:
                    insert_buff += seg.raw
                    # We want to capture the first position where we have a point.
                    first_segment_pos = first_segment_pos or seg.pos_marker
                    linter_logger.debug(
                        "Appending insertion buffer. %r @idx: %s",
                        insert_buff,
                        templated_idx,
                    )
                continue

            # Special case: a template placeholder (e.g. {{ variable }}) whose
            # Jinja expression rendered to an empty string. These segments occupy
            # zero space in the templated file but have a non-zero source range.
            # Due to the way the lexer processes them, they may appear in the
            # segment list *before* an adjacent literal segment that starts at an
            # earlier templated position.  If we naively apply the start_diff
            # logic below, we would generate a spurious deletion patch for the
            # source characters between `templated_idx` and the placeholder's
            # templated position (e.g. the opening quote of a quoted literal).
            # See: https://github.com/sqlfluff/sqlfluff/issues/6261
            if (
                seg.is_type("placeholder")
                and seg.raw == ""
                and is_zero_slice(seg.pos_marker.templated_slice)
                and not is_zero_slice(seg.pos_marker.source_slice)
                and getattr(seg, "block_type", "") == "templated"
            ):
                # Yield any embedded source fixes (rare, but possible).
                yield from _iter_source_fix_patches(seg, templated_file=templated_file)
                # Do NOT update templated_idx here.  The placeholder occupies no
                # space in the templated output, so advancing templated_idx would
                # make the next sibling appear to have a negative start_diff and
                # its source content would be silently skipped.
                # We do not update source_idx either; the source content covered by
                # this placeholder is implicitly part of the surrounding literal
                # segment's source range and will be left untouched.
                continue

            # If we get here, then we know it's an original. Check for deletions at
            # the point before this segment (vs the TEMPLATED).
            # Deletions in this sense could also mean source consumption.
            start_diff = seg.pos_marker.templated_slice.start - templated_idx

            # Check to see whether there's a discontinuity before the current
            # segment
            if start_diff > 0 or insert_buff:
                # If we have an insert buffer, then it's an edit, otherwise a
                # deletion.

                # For the start of the next segment, we need the position of the
                # first raw, not the pos marker of the whole thing. That accounts
                # better for loops.
                first_segment_pos = first_segment_pos or seg.pos_marker
                templated_slice = slice(
                    templated_idx,
                    max(first_segment_pos.templated_slice.start, templated_idx),
                )
                yield FixPatch(
                    # Whether the source slice is zero depends on the start_diff.
                    # A non-zero start diff implies a deletion, or more likely
                    # a consumed element of the source. We can use the tracking
                    # markers from the last segment to recreate where this element
                    # should be inserted in both source and template.
                    # The slices must never go backwards so the end of the slice must
                    # be greater than or equal to the start.
                    source_slice=(
                        templated_file.templated_slice_to_source_slice(templated_slice)
                        if start_diff > 0 and not insert_buff
                        else slice(
                            source_idx,
                            max(first_segment_pos.source_slice.start, source_idx),
                        )
                    ),
                    templated_slice=templated_slice,
                    patch_category="mid_point",
                    fixed_raw=insert_buff,
                    templated_str="",
                    source_str="",
                )

                # Reset the first position so we can move the pointer forward.
                first_segment_pos = None
                insert_buff = ""

            # Now we deal with any changes *within* the segment itself.
            yield from _iter_templated_patches(seg, templated_file=templated_file)

            # Once we've dealt with any patches from the segment, update
            # our position markers.
            source_idx = seg.pos_marker.source_slice.stop
            templated_idx = seg.pos_marker.templated_slice.stop

        # After the loop, we check whether there's a trailing deletion
        # or insert. Also valid if we still have an insertion buffer here.
        end_diff = segment.pos_marker.templated_slice.stop - templated_idx
        if end_diff or insert_buff:
            source_slice = slice(
                source_idx,
                segment.pos_marker.source_slice.stop,
            )
            templated_slice = slice(
                templated_idx,
                segment.pos_marker.templated_slice.stop,
            )
            # We determine the source_slice directly rather than
            # inferring it so that we can be very specific that
            # we ensure that fixes adjacent to source-only slices
            # (e.g. {% endif %}) are placed appropriately relative
            # to source-only slices.
            yield FixPatch(
                source_slice=source_slice,
                templated_slice=templated_slice,
                patch_category="end_point",
                fixed_raw=insert_buff,
                templated_str=templated_file.templated_str[templated_slice],
                source_str=templated_file.source_str[source_slice],
            )


def _log_hints(patch: FixPatch, templated_file: TemplatedFile) -> None:
    """Log hints for debugging during patch generation."""
    max_log_length = 10
    if patch.templated_slice.start >= max_log_length:
        pre_hint = templated_file.templated_str[
            patch.templated_slice.start - max_log_length : patch.templated_slice.start
        ]
    else:
        pre_hint = templated_file.templated_str[: patch.templated_slice.start]
    if patch.templated_slice.stop + max_log_length < len(templated_file.templated_str):
        post_hint = templated_file.templated_str[
            patch.templated_slice.stop : patch.templated_slice.stop + max_log_length
        ]
    else:
        post_hint = templated_file.templated_str[patch.templated_slice.stop :]
    linter_logger.debug("        Templated Hint: ...%r <> %r...", pre_hint, post_hint)


def generate_source_patches(
    tree: BaseSegment, templated_file: TemplatedFile
) -> list[FixPatch]:
    """Use the fixed tree to generate source patches.

    Importantly here we deduplicate and sort the patches from their position
    in the templated file into their intended order in the source file.

    Any source fixes are generated in `_iter_templated_patches` and included
    alongside any standard fixes. That means we treat them the same here.
    """
    # Iterate patches, filtering and translating as we go:
    linter_logger.debug("### Beginning Patch Iteration.")
    filtered_source_patches = []
    dedupe_buffer: set[tuple[tuple[int, int], str]] = set()
    # We use enumerate so that we get an index for each patch. This is entirely
    # so when debugging logs we can find a given patch again!
    for idx, patch in enumerate(
        _iter_templated_patches(tree, templated_file=templated_file)
    ):
        linter_logger.debug("  %s Yielded patch: %s", idx, patch)
        _log_hints(patch, templated_file)

        # Check for duplicates
        dedupe_tuple = patch.dedupe_tuple()
        if dedupe_tuple in dedupe_buffer:
            linter_logger.info(
                "      - Skipping. Source space Duplicate: %s",
                dedupe_tuple,
            )
            continue

        # We now evaluate patches in the source-space for whether they overlap
        # or disrupt any templated sections unless designed to do so.
        # NOTE: We rely here on the patches being generated in order.

        # Get the affected raw slices.
        local_raw_slices = templated_file.raw_slices_spanning_source_slice(
            patch.source_slice
        )
        local_type_list = [slc.slice_type for slc in local_raw_slices]

        # Deal with the easy cases of 1) New code at end 2) only literals
        if not local_type_list or set(local_type_list) == {"literal"}:
            linter_logger.info(
                "      * Keeping patch on new or literal-only section.",
            )
            filtered_source_patches.append(patch)
            dedupe_buffer.add(dedupe_tuple)
        # Handle the easy case of an explicit source fix
        elif patch.patch_category == "source":
            linter_logger.info(
                "      * Keeping explicit source fix patch.",
            )
            filtered_source_patches.append(patch)
            dedupe_buffer.add(dedupe_tuple)
        # Is it a zero length patch.
        elif (
            patch.source_slice.start == patch.source_slice.stop
            and patch.source_slice.start == local_raw_slices[0].source_idx
        ):
            linter_logger.info(
                "      * Keeping insertion patch on slice boundary.",
            )
            filtered_source_patches.append(patch)
            dedupe_buffer.add(dedupe_tuple)
        else:  # pragma: no cover
            # We've got a situation where the ends of our patch need to be
            # more carefully mapped. This used to happen with greedy template
            # element matching, but should now never happen. In the event that
            # it does, we'll warn but carry on.
            linter_logger.warning(
                "Skipping edit patch on uncertain templated section [%s], "
                "Please report this warning on GitHub along with the query "
                "that produced it.",
                (patch.patch_category, patch.source_slice),
            )
            continue

    # Sort the patches before building up the file.
    return sorted(filtered_source_patches, key=lambda x: x.source_slice.start)


def _patches_conflict(first: FixPatch, second: FixPatch) -> bool:
    """Return whether two source patches cannot be safely applied together."""
    if first.source_slice == second.source_slice:
        return first.fixed_raw != second.fixed_raw

    first_start = int(first.source_slice.start)
    first_stop = int(first.source_slice.stop)
    second_start = int(second.source_slice.start)
    second_stop = int(second.source_slice.stop)

    if first_start == first_stop == second_start == second_stop:
        # NOTE: It's an edge case to hit this and not the initial clause,
        # but possible.
        return first_start == second_start

    return max(first_start, second_start) < min(first_stop, second_stop)


def merge_source_patches(patch_buffers: list[list[FixPatch]]) -> list[FixPatch]:
    """Merge source patches from multiple variants.

    Patches are deduplicated in source space. Conflicting patches are skipped so
    that the non-conflicting subset can still be applied safely.
    """
    merged_patches: list[FixPatch] = []
    dedupe_buffer: set[tuple[tuple[int, int], str]] = set()

    for patch in sorted(
        (patch for patches in patch_buffers for patch in patches),
        key=lambda patch: (patch.source_slice.start, patch.source_slice.stop),
    ):
        dedupe_tuple = patch.dedupe_tuple()
        if dedupe_tuple in dedupe_buffer:
            continue

        if any(_patches_conflict(existing, patch) for existing in merged_patches):
            linter_logger.info(
                "Skipping conflicting cross-variant patch: %s",
                patch,
            )
            continue

        merged_patches.append(patch)
        dedupe_buffer.add(dedupe_tuple)

    return merged_patches
