/* git/diff.rs
 *
 * Copyright 2026 Christian Kruse
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

use std::path::Path;

use similar::{ChangeTag, TextDiff};

use super::types::*;
use super::{git_err, open_repo, strip_trailing_newline};

const MAX_DIFF_LINES: usize = 20_000;
const MAX_LINE_BYTES: usize = 5_000;

pub fn load_file_diff(
    repo_path: &Path,
    file_path: &str,
    mode: DiffMode,
    wt_status: Option<FileStatus>,
) -> Result<FileDiff, String> {
    let repo = open_repo(repo_path)?;
    let mut opts = git2::DiffOptions::new();
    opts.pathspec(file_path);

    let diff = if wt_status == Some(FileStatus::Conflicted) {
        let head_tree = repo.head().ok().and_then(|r| r.peel_to_tree().ok());
        repo.diff_tree_to_workdir(head_tree.as_ref(), Some(&mut opts))
    } else {
        match mode {
            DiffMode::Unstaged => {
                opts.include_untracked(true);
                opts.show_untracked_content(true);
                repo.diff_index_to_workdir(None, Some(&mut opts))
            }

            DiffMode::Staged => {
                let head_tree = repo.head().ok().and_then(|r| r.peel_to_tree().ok());
                repo.diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))
            }
        }
    }
    .map_err(git_err("Failed to generate diff"))?;

    if diff.deltas().len() == 0 {
        if wt_status == Some(FileStatus::New) && mode == DiffMode::Unstaged {
            return build_new_file_diff(repo_path, file_path);
        }

        return Ok(FileDiff {
            path: file_path.to_string(),
            hunks: Vec::new(),
            insertions: 0,
            deletions: 0,
            is_binary: false,
            is_too_large: false,
        });
    }

    let is_binary = diff.deltas().next().map(|d| d.flags().is_binary()).unwrap_or(false);

    if is_binary {
        return Ok(FileDiff {
            path: file_path.to_string(),
            hunks: Vec::new(),
            insertions: 0,
            deletions: 0,
            is_binary: true,
            is_too_large: false,
        });
    }

    let patch = git2::Patch::from_diff(&diff, 0)
        .map_err(git_err("Failed to create patch"))?
        .ok_or_else(|| "No patch for this file".to_string())?;

    match parse_hunks_from_patch(&patch)? {
        ParseOutcome::Parsed {
            hunks,
            insertions,
            deletions,
        } => Ok(FileDiff {
            path: file_path.to_string(),
            hunks,
            insertions,
            deletions,
            is_binary: false,
            is_too_large: false,
        }),

        ParseOutcome::TooLarge { insertions, deletions } => Ok(FileDiff {
            path: file_path.to_string(),
            hunks: Vec::new(),
            insertions,
            deletions,
            is_binary: false,
            is_too_large: true,
        }),
    }
}

pub(super) fn file_diffs_from_diff(diff: &git2::Diff) -> Result<Vec<FileDiff>, String> {
    let mut file_diffs = Vec::new();

    for (delta_idx, delta) in diff.deltas().enumerate() {
        let path = delta
            .new_file()
            .path()
            .unwrap_or_else(|| std::path::Path::new("?"))
            .to_string_lossy()
            .to_string();

        if delta.flags().is_binary() {
            file_diffs.push(FileDiff {
                path,
                hunks: Vec::new(),
                insertions: 0,
                deletions: 0,
                is_binary: true,
                is_too_large: false,
            });
            continue;
        }

        match git2::Patch::from_diff(diff, delta_idx) {
            Ok(Some(patch)) => match parse_hunks_from_patch(&patch)? {
                ParseOutcome::Parsed {
                    hunks,
                    insertions,
                    deletions,
                } => {
                    file_diffs.push(FileDiff {
                        path,
                        hunks,
                        insertions,
                        deletions,
                        is_binary: false,
                        is_too_large: false,
                    });
                }

                ParseOutcome::TooLarge { insertions, deletions } => {
                    file_diffs.push(FileDiff {
                        path,
                        hunks: Vec::new(),
                        insertions,
                        deletions,
                        is_binary: false,
                        is_too_large: true,
                    });
                }
            },

            _ => {
                file_diffs.push(FileDiff {
                    path,
                    hunks: Vec::new(),
                    insertions: 0,
                    deletions: 0,
                    is_binary: false,
                    is_too_large: false,
                });
            }
        }
    }

    Ok(file_diffs)
}

pub(super) enum ParseOutcome {
    Parsed {
        hunks: Vec<DiffHunk>,
        insertions: usize,
        deletions: usize,
    },
    TooLarge {
        insertions: usize,
        deletions: usize,
    },
}

pub(super) fn parse_hunks_from_patch(patch: &git2::Patch) -> Result<ParseOutcome, String> {
    let mut hunks = Vec::new();
    let mut insertions = 0usize;
    let mut deletions = 0usize;
    let mut total_lines = 0usize;
    let mut too_large = false;

    for hunk_idx in 0..patch.num_hunks() {
        let (hunk, num_lines) = patch.hunk(hunk_idx).map_err(git_err("Failed to read hunk"))?;

        let header = String::from_utf8_lossy(hunk.header()).trim().to_string();
        let mut lines = Vec::new();

        for line_idx in 0..num_lines {
            let line = patch
                .line_in_hunk(hunk_idx, line_idx)
                .map_err(git_err("Failed to read line"))?;

            let kind = match line.origin() {
                '+' => {
                    insertions += 1;
                    DiffLineKind::Addition
                }

                '-' => {
                    deletions += 1;
                    DiffLineKind::Deletion
                }

                _ => DiffLineKind::Context,
            };

            let raw = line.content();
            total_lines += 1;

            if raw.len() > MAX_LINE_BYTES {
                too_large = true;
            }

            // Stop materializing line data once we've crossed the threshold; we still keep counting
            // insertions/deletions so the summary stays accurate
            if too_large || total_lines > MAX_DIFF_LINES {
                too_large = true;
                continue;
            }

            let content = String::from_utf8_lossy(raw).to_string();

            lines.push(DiffLine {
                kind,
                old_lineno: line.old_lineno(),
                new_lineno: line.new_lineno(),
                content,
                word_ranges: None,
            });
        }

        if too_large {
            continue;
        }

        annotate_word_ranges(&mut lines);

        hunks.push(DiffHunk {
            header,
            old_start: hunk.old_start(),
            old_lines: hunk.old_lines(),
            new_start: hunk.new_start(),
            new_lines: hunk.new_lines(),
            lines,
        });
    }

    if too_large {
        Ok(ParseOutcome::TooLarge { insertions, deletions })
    } else {
        Ok(ParseOutcome::Parsed {
            hunks,
            insertions,
            deletions,
        })
    }
}

fn annotate_word_ranges(lines: &mut [DiffLine]) {
    let mut i = 0;

    while i < lines.len() {
        if lines[i].kind != DiffLineKind::Deletion {
            i += 1;
            continue;
        }

        let del_start = i;

        while i < lines.len() && lines[i].kind == DiffLineKind::Deletion {
            i += 1;
        }

        let del_end = i;
        let add_start = i;

        while i < lines.len() && lines[i].kind == DiffLineKind::Addition {
            i += 1;
        }

        let add_end = i;

        // TODO: handle N:M reformat cases via a similarity-matrix matching
        // (e.g. greedy/Hungarian over `TextDiff::ratio`).
        let del_count = del_end - del_start;
        let add_count = add_end - add_start;

        if del_count == 0 || del_count != add_count {
            continue;
        }

        for k in 0..del_count {
            let del_idx = del_start + k;
            let add_idx = add_start + k;
            let del_content = strip_trailing_newline(&lines[del_idx].content);
            let add_content = strip_trailing_newline(&lines[add_idx].content);

            if let Some((del_ranges, add_ranges)) = compute_word_ranges(del_content, add_content) {
                lines[del_idx].word_ranges = Some(del_ranges);
                lines[add_idx].word_ranges = Some(add_ranges);
            }
        }
    }
}

const MAX_COVERAGE: f32 = 0.5;

fn compute_word_ranges(old: &str, new: &str) -> Option<(Vec<WordRange>, Vec<WordRange>)> {
    let diff = TextDiff::from_words(old, new);

    if diff.ratio() < 0.5 {
        return None;
    }

    let mut old_ranges: Vec<WordRange> = Vec::new();
    let mut new_ranges: Vec<WordRange> = Vec::new();
    let mut old_cursor = 0usize;
    let mut new_cursor = 0usize;

    for change in diff.iter_all_changes() {
        let value = change.value();
        let len = value.len();

        match change.tag() {
            ChangeTag::Equal => {
                old_cursor += len;
                new_cursor += len;
            }

            ChangeTag::Delete => {
                push_range(&mut old_ranges, old_cursor, old_cursor + len);
                old_cursor += len;
            }

            ChangeTag::Insert => {
                push_range(&mut new_ranges, new_cursor, new_cursor + len);
                new_cursor += len;
            }
        }
    }

    if old_ranges.is_empty() && new_ranges.is_empty() {
        return None;
    }

    // Suppress highlighting when it would cover more than half of either side. If so much is marked, it is useless
    if coverage(&old_ranges, old.len()) > MAX_COVERAGE || coverage(&new_ranges, new.len()) > MAX_COVERAGE {
        return None;
    }

    Some((old_ranges, new_ranges))
}

fn coverage(ranges: &[WordRange], total: usize) -> f32 {
    if total == 0 {
        return 0.0;
    }

    let covered: usize = ranges.iter().map(|r| r.end - r.start).sum();
    covered as f32 / total as f32
}

fn push_range(ranges: &mut Vec<WordRange>, start: usize, end: usize) {
    if start == end {
        return;
    }

    if let Some(last) = ranges.last_mut()
        && last.end == start
    {
        last.end = end;
        return;
    }

    ranges.push(WordRange { start, end });
}

fn build_new_file_diff(repo_path: &Path, file_path: &str) -> Result<FileDiff, String> {
    let full_path = repo_path.join(file_path);
    let content = std::fs::read(&full_path).map_err(|e| format!("Failed to read file {}: {e}", full_path.display()))?;

    if content.contains(&0) {
        return Ok(FileDiff {
            path: file_path.to_string(),
            hunks: Vec::new(),
            insertions: 0,
            deletions: 0,
            is_binary: true,
            is_too_large: false,
        });
    }

    let text = String::from_utf8_lossy(&content);
    let file_lines: Vec<&str> = text.lines().collect();
    let insertions = file_lines.len();

    let too_large = insertions > MAX_DIFF_LINES || file_lines.iter().any(|l| l.len() > MAX_LINE_BYTES);

    if too_large {
        return Ok(FileDiff {
            path: file_path.to_string(),
            hunks: Vec::new(),
            insertions,
            deletions: 0,
            is_binary: false,
            is_too_large: true,
        });
    }

    let lines = file_lines
        .iter()
        .enumerate()
        .map(|(i, line)| DiffLine {
            kind: DiffLineKind::Addition,
            old_lineno: None,
            new_lineno: Some(i as u32 + 1),
            content: line.to_string(),
            word_ranges: None,
        })
        .collect();

    Ok(FileDiff {
        path: file_path.to_string(),
        hunks: vec![DiffHunk {
            header: format!("@@ -0,0 +1,{insertions} @@"),
            old_start: 0,
            old_lines: 0,
            new_start: 1,
            new_lines: insertions as u32,
            lines,
        }],
        insertions,
        deletions: 0,
        is_binary: false,
        is_too_large: false,
    })
}

fn apply_hunk_op(
    repo_path: &Path,
    file_path: &str,
    hunk_index: usize,
    selected_lines: Option<&[usize]>,
    diff_mode: DiffMode,
    reverse: bool,
    location: git2::ApplyLocation,
) -> Result<(), String> {
    let diff = load_file_diff(repo_path, file_path, diff_mode, None)?;
    let hunk = diff
        .hunks
        .get(hunk_index)
        .ok_or_else(|| format!("Hunk index {hunk_index} out of range"))?;

    let patch = build_patch(file_path, hunk, selected_lines, reverse);
    apply_patch(repo_path, &patch, location)
}

pub fn stage_hunk(repo_path: &Path, file_path: &str, hunk_index: usize) -> Result<(), String> {
    apply_hunk_op(repo_path, file_path, hunk_index, None, DiffMode::Unstaged, false, git2::ApplyLocation::Index)
}

pub fn unstage_hunk(repo_path: &Path, file_path: &str, hunk_index: usize) -> Result<(), String> {
    apply_hunk_op(repo_path, file_path, hunk_index, None, DiffMode::Staged, true, git2::ApplyLocation::Index)
}

pub fn discard_hunk(repo_path: &Path, file_path: &str, hunk_index: usize) -> Result<(), String> {
    apply_hunk_op(repo_path, file_path, hunk_index, None, DiffMode::Unstaged, true, git2::ApplyLocation::WorkDir)
}

pub fn stage_lines(repo_path: &Path, file_path: &str, hunk_index: usize, line_indices: &[usize]) -> Result<(), String> {
    apply_hunk_op(
        repo_path,
        file_path,
        hunk_index,
        Some(line_indices),
        DiffMode::Unstaged,
        false,
        git2::ApplyLocation::Index,
    )
}

pub fn unstage_lines(
    repo_path: &Path,
    file_path: &str,
    hunk_index: usize,
    line_indices: &[usize],
) -> Result<(), String> {
    apply_hunk_op(
        repo_path,
        file_path,
        hunk_index,
        Some(line_indices),
        DiffMode::Staged,
        true,
        git2::ApplyLocation::Index,
    )
}

pub fn discard_lines(
    repo_path: &Path,
    file_path: &str,
    hunk_index: usize,
    line_indices: &[usize],
) -> Result<(), String> {
    apply_hunk_op(
        repo_path,
        file_path,
        hunk_index,
        Some(line_indices),
        DiffMode::Unstaged,
        true,
        git2::ApplyLocation::WorkDir,
    )
}

fn apply_patch(repo_path: &Path, patch_bytes: &[u8], location: git2::ApplyLocation) -> Result<(), String> {
    let repo = open_repo(repo_path)?;
    let diff = git2::Diff::from_buffer(patch_bytes).map_err(git_err("Invalid patch"))?;

    repo.apply(&diff, location, None)
        .map_err(git_err("Failed to apply patch"))
}

/*
 * Build a unified diff patch for a single hunk, optionally filtered to specific lines.
 * If `reverse` is true, additions and deletions are swapped (for unstaging/discarding).
 * If `selected_lines` is None, all lines are included (whole hunk).
 */
fn build_patch(file_path: &str, hunk: &DiffHunk, selected_lines: Option<&[usize]>, reverse: bool) -> Vec<u8> {
    let mut body = String::new();
    let mut old_count = 0u32;
    let mut new_count = 0u32;

    for (idx, line) in hunk.lines.iter().enumerate() {
        let selected = selected_lines.is_none_or(|sel| sel.contains(&idx));

        // In reverse mode, additions become deletions and vice versa
        let (is_add, is_del) = if reverse {
            (line.kind == DiffLineKind::Deletion, line.kind == DiffLineKind::Addition)
        } else {
            (line.kind == DiffLineKind::Addition, line.kind == DiffLineKind::Deletion)
        };

        if line.kind == DiffLineKind::Context {
            body.push(' ');
            body.push_str(&line.content);
            ensure_newline(&mut body);
            old_count += 1;
            new_count += 1;
        } else if is_add {
            if selected {
                body.push('+');
                body.push_str(&line.content);
                ensure_newline(&mut body);
                new_count += 1;
            }
            // Unselected addition: skip entirely
        } else if is_del {
            if selected {
                body.push('-');
                body.push_str(&line.content);
                ensure_newline(&mut body);
                old_count += 1;
            } else {
                // Unselected deletion: keep as context
                body.push(' ');
                body.push_str(&line.content);
                ensure_newline(&mut body);
                old_count += 1;
                new_count += 1;
            }
        }
    }

    let old_start = if reverse { hunk.new_start } else { hunk.old_start };

    format_patch(file_path, old_start, old_count, old_start, new_count, &body)
}

fn ensure_newline(s: &mut String) {
    if !s.ends_with('\n') {
        s.push('\n');
    }
}

fn format_patch(
    file_path: &str,
    old_start: u32,
    old_count: u32,
    new_start: u32,
    new_count: u32,
    body: &str,
) -> Vec<u8> {
    let patch = format!(
        "diff --git a/{file_path} b/{file_path}\n--- a/{file_path}\n+++ b/{file_path}\n@@ -{old_start},{old_count} \
         +{new_start},{new_count} @@\n{body}"
    );

    patch.into_bytes()
}
