namespace ClaudeDo.Ui.ViewModels.Modals;

/// Which role a row plays on one side of the split view.
public enum AlignedSide { Ctx, Del, Add, Filler, Gap }

/// A character range inside a row's text, used for intra-line (word) highlighting.
public readonly record struct TextSpan(int Start, int Length);

/// One rendered row of the side-by-side view. Left is the old state, right the new.
public sealed record SplitRow(
    AlignedSide LeftKind,  int? OldNo, string LeftText,  IReadOnlyList<TextSpan> LeftSpans,
    AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);

/// One rendered row of the single-pane view.
public sealed record UnifiedRow(
    AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);

/// Render-ready diff. Row index <c>i</c> is document line <c>i + 1</c> in the matching text —
/// that mapping is what the line-number margin and both background renderers rely on.
public sealed record AlignedDiff(
    IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
    IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText)
{
    public static readonly AlignedDiff Empty = new(
        Array.Empty<SplitRow>(), "", "", Array.Empty<UnifiedRow>(), "");
}

/// Turns a parsed unified-diff line stream into aligned rows. Pure — no Avalonia types,
/// so all of the interesting behaviour is unit-testable.
public static class DiffAlignment
{
    /// Marker text for a skipped region between two hunks.
    public const string GapText = "⋯";

    /// Word diff is O(n·m) in tokens and is noise on lines that were rewritten wholesale,
    /// so it is skipped on very long lines, on token-heavy lines, and on dissimilar pairs.
    public const int MaxWordDiffChars = 2000;
    public const int MaxWordDiffTokens = 400;
    public const double MinWordDiffSimilarity = 0.5;

    internal static readonly IReadOnlyList<TextSpan> NoSpans = Array.Empty<TextSpan>();

    public static AlignedDiff Build(IReadOnlyList<DiffLineViewModel>? lines)
    {
        if (lines is null || lines.Count == 0) return AlignedDiff.Empty;

        var split = new List<SplitRow>();
        var unified = new List<UnifiedRow>();
        int? prevOld = null, prevNew = null;

        var i = 0;
        while (i < lines.Count)
        {
            var line = lines[i];

            // File-header rows only appear in the legacy flattened stream; alignment is per file.
            if (line.Kind == DiffLineKind.File) { i++; continue; }

            if (IsGap(prevOld, prevNew, line))
            {
                split.Add(new SplitRow(AlignedSide.Gap, null, GapText, NoSpans,
                                       AlignedSide.Gap, null, GapText, NoSpans));
                unified.Add(new UnifiedRow(AlignedSide.Gap, null, null, GapText, NoSpans));
            }

            if (line.Kind == DiffLineKind.Ctx)
            {
                split.Add(new SplitRow(AlignedSide.Ctx, line.OldNo, line.Text, NoSpans,
                                       AlignedSide.Ctx, line.NewNo, line.Text, NoSpans));
                unified.Add(new UnifiedRow(AlignedSide.Ctx, line.OldNo, line.NewNo, line.Text, NoSpans));
                prevOld = line.OldNo;
                prevNew = line.NewNo;
                i++;
                continue;
            }

            // A change block: the parser always emits the deletions before the additions.
            var dels = new List<DiffLineViewModel>();
            while (i < lines.Count && lines[i].Kind == DiffLineKind.Del) dels.Add(lines[i++]);
            var adds = new List<DiffLineViewModel>();
            while (i < lines.Count && lines[i].Kind == DiffLineKind.Add) adds.Add(lines[i++]);

            EmitChangeBlock(dels, adds, split, unified);

            if (dels.Count > 0) prevOld = dels[^1].OldNo;
            if (adds.Count > 0) prevNew = adds[^1].NewNo;
        }

        return new AlignedDiff(
            split,
            string.Join('\n', split.Select(r => r.LeftText)),
            string.Join('\n', split.Select(r => r.RightText)),
            unified,
            string.Join('\n', unified.Select(r => r.Text)));
    }

    /// The parser drops "@@" headers, so a skipped region is visible only as a jump in the
    /// line numbers. Nothing precedes the first row, so it can never open with a gap.
    private static bool IsGap(int? prevOld, int? prevNew, DiffLineViewModel next)
    {
        if (prevOld is { } po && next.OldNo is { } no && no > po + 1) return true;
        if (prevNew is { } pn && next.NewNo is { } nn && nn > pn + 1) return true;
        return false;
    }

    private static void EmitChangeBlock(
        List<DiffLineViewModel> dels, List<DiffLineViewModel> adds,
        List<SplitRow> split, List<UnifiedRow> unified)
    {
        var paired = Math.Min(dels.Count, adds.Count);

        // Word diff is only meaningful for rows that stand 1:1 opposite each other.
        var spans = new (IReadOnlyList<TextSpan> Left, IReadOnlyList<TextSpan> Right)[paired];
        for (var k = 0; k < paired; k++)
            spans[k] = WordDiff(dels[k].Text, adds[k].Text);

        for (var k = 0; k < paired; k++)
            split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, spans[k].Left,
                                   AlignedSide.Add, adds[k].NewNo, adds[k].Text, spans[k].Right));
        for (var k = paired; k < dels.Count; k++)
            split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, NoSpans,
                                   AlignedSide.Filler, null, "", NoSpans));
        for (var k = paired; k < adds.Count; k++)
            split.Add(new SplitRow(AlignedSide.Filler, null, "", NoSpans,
                                   AlignedSide.Add, adds[k].NewNo, adds[k].Text, NoSpans));

        for (var k = 0; k < dels.Count; k++)
            unified.Add(new UnifiedRow(AlignedSide.Del, dels[k].OldNo, null, dels[k].Text,
                                       k < paired ? spans[k].Left : NoSpans));
        for (var k = 0; k < adds.Count; k++)
            unified.Add(new UnifiedRow(AlignedSide.Add, null, adds[k].NewNo, adds[k].Text,
                                       k < paired ? spans[k].Right : NoSpans));
    }

    /// Changed character ranges on each side of a 1:1 line pair, or no spans when the pair
    /// is too long, too token-heavy or too dissimilar for per-word highlighting to help.
    internal static (IReadOnlyList<TextSpan> Left, IReadOnlyList<TextSpan> Right) WordDiff(
        string left, string right)
    {
        if (left.Length == 0 || right.Length == 0 || string.Equals(left, right, StringComparison.Ordinal))
            return (NoSpans, NoSpans);
        if (left.Length > MaxWordDiffChars || right.Length > MaxWordDiffChars)
            return (NoSpans, NoSpans);

        var a = Tokenize(left);
        var b = Tokenize(right);
        if (a.Count > MaxWordDiffTokens || b.Count > MaxWordDiffTokens)
            return (NoSpans, NoSpans);

        var (keepA, keepB, common) = LongestCommonSubsequence(a, b);
        var similarity = common / (double)Math.Max(a.Count, b.Count);
        if (similarity < MinWordDiffSimilarity) return (NoSpans, NoSpans);

        return (SpansForUnmatched(a, keepA), SpansForUnmatched(b, keepB));
    }

    /// Splits a line into runs of word characters, runs of whitespace, and single
    /// punctuation characters — the granularity that makes an identifier rename read as
    /// one changed token rather than a string of changed characters.
    private static List<Token> Tokenize(string s)
    {
        var tokens = new List<Token>();
        var i = 0;
        while (i < s.Length)
        {
            var start = i;
            if (IsWordChar(s[i]))
                while (i < s.Length && IsWordChar(s[i])) i++;
            else if (char.IsWhiteSpace(s[i]))
                while (i < s.Length && char.IsWhiteSpace(s[i])) i++;
            else
                i++;
            tokens.Add(new Token(start, s[start..i]));
        }
        return tokens;
    }

    private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c == '_';

    private static (bool[] KeepA, bool[] KeepB, int Common) LongestCommonSubsequence(
        List<Token> a, List<Token> b)
    {
        var n = a.Count;
        var m = b.Count;
        var dp = new int[n + 1, m + 1];
        for (var i = n - 1; i >= 0; i--)
            for (var j = m - 1; j >= 0; j--)
                dp[i, j] = string.Equals(a[i].Text, b[j].Text, StringComparison.Ordinal)
                    ? dp[i + 1, j + 1] + 1
                    : Math.Max(dp[i + 1, j], dp[i, j + 1]);

        var keepA = new bool[n];
        var keepB = new bool[m];
        int x = 0, y = 0;
        while (x < n && y < m)
        {
            if (string.Equals(a[x].Text, b[y].Text, StringComparison.Ordinal))
            {
                keepA[x] = keepB[y] = true;
                x++; y++;
            }
            else if (dp[x + 1, y] >= dp[x, y + 1]) x++;
            else y++;
        }
        return (keepA, keepB, dp[0, 0]);
    }

    /// Merges runs of consecutive unmatched tokens so "zz" is one span, not two.
    private static IReadOnlyList<TextSpan> SpansForUnmatched(List<Token> tokens, bool[] keep)
    {
        var spans = new List<TextSpan>();
        var i = 0;
        while (i < tokens.Count)
        {
            if (keep[i]) { i++; continue; }
            var start = tokens[i].Start;
            var end = start + tokens[i].Text.Length;
            i++;
            while (i < tokens.Count && !keep[i])
            {
                end = tokens[i].Start + tokens[i].Text.Length;
                i++;
            }
            spans.Add(new TextSpan(start, end - start));
        }
        return spans.Count == 0 ? NoSpans : spans;
    }

    /// A tokenized slice of a line, carrying its offset so spans map back to characters.
    private readonly record struct Token(int Start, string Text);
}
