//! Rendering a [`Snippet`] into egui layout jobs: marked ranges,
//! middle-out elision around the first match, and row budgeting.

use super::*;

struct SnippetFormats {
    normal: TextFormat,
    highlight: TextFormat,
    weak: TextFormat,
}

fn snippet_formats(ui: &egui::Ui) -> SnippetFormats {
    let font_id = egui::TextStyle::Body.resolve(ui.style());
    SnippetFormats {
        normal: TextFormat {
            font_id: font_id.clone(),
            color: ui.visuals().text_color(),
            ..Default::default()
        },
        highlight: TextFormat {
            font_id: font_id.clone(),
            color: ui.visuals().strong_text_color(),
            background: ui.visuals().selection.bg_fill.gamma_multiply(0.4),
            ..Default::default()
        },
        weak: TextFormat {
            font_id,
            color: ui.visuals().weak_text_color(),
            ..Default::default()
        },
    }
}

/// The mark on a snippet that starts partway into its window;
/// `first_visible_byte` pays for its width in advance.
const SNIPPET_LEAD: &str = "… ";

/// Append `window[range]` to `job`, highlighting whatever parts of `ranges`
/// (byte offsets into `window`) fall inside it.
///
/// Ranges are clipped to the slice, so a caller rendering a string in pieces
/// can hand each piece the *whole* set: a range inside this one survives, one
/// straddling an edge survives as the part that is here, and one wholly
/// outside disappears.
fn append_marked(
    job: &mut LayoutJob,
    fmt: &SnippetFormats,
    window: &str,
    ranges: &[(usize, usize)],
    range: std::ops::Range<usize>,
) {
    let mut cursor = range.start;
    for &(a, b) in ranges {
        let (a, b) = (a.max(range.start), b.min(range.end));
        if a >= b {
            continue; // wholly before or after the slice
        }
        if a > cursor {
            job.append(&window[cursor..a], 0.0, fmt.normal.clone());
        }
        job.append(&window[a..b], 0.0, fmt.highlight.clone());
        cursor = b;
    }
    if cursor < range.end {
        job.append(&window[cursor..range.end], 0.0, fmt.normal.clone());
    }
}

/// A whole field — a filename — with its matched spans marked.
///
/// Wrapping is left at the job's defaults on purpose: `egui::Label` overwrites
/// only `wrap.max_width`, so this is laid out exactly like the plain string it
/// replaces, and the cell keeps the height and clipping it had before.
pub(super) fn marked_field_job(ui: &egui::Ui, text: &str, ranges: &[(usize, usize)]) -> LayoutJob {
    let fmt = snippet_formats(ui);
    let mut job = LayoutJob::default();
    append_marked(&mut job, &fmt, text, ranges, 0..text.len());
    job
}

/// The byte offset in `snip.window` that rendering has to start at for the
/// first match to land on a row that survives `max_rows`; `0` when it
/// already does. epaint stops at `wrap.max_rows` and *every* `\n` costs a
/// row, blank line or not, so a ragged lead-in can spend the whole row
/// budget before layout reaches the match.
fn first_visible_byte(
    ui: &egui::Ui,
    snip: &Snippet,
    fmt: &SnippetFormats,
    max_rows: usize,
    wrap_width: f32,
) -> usize {
    let Some(&(match_start, _)) = snip.ranges.first() else {
        return 0; // a head-of-file window, with nothing to keep on screen
    };

    // The rendered job pays for a leading mark this probe does not, so the
    // probe wraps to a narrower width — a point narrower still, since epaint
    // rounds `wrap.max_width` before laying out. Every rendered row then
    // holds at least what the probe row starting at the same character held,
    // so the match cannot drift *down* a row when the job is rebuilt.
    let lead_width = ui.fonts(|f| {
        SNIPPET_LEAD
            .chars()
            .map(|c| f.glyph_width(&fmt.normal.font_id, c))
            .sum::<f32>()
    });
    let mut probe = LayoutJob::default();
    probe.wrap.max_width = (wrap_width - lead_width - 1.0).max(1.0);
    probe.append(&snip.window, 0.0, fmt.normal.clone());
    let galley = ui.fonts(|f| f.layout_job(probe));

    // Cursors index characters; snippet ranges are byte offsets. epaint
    // counts the `\n` that ends a row, so the two spaces line up 1:1.
    let cursor = egui::text::CCursor {
        index: snip.window[..match_start].chars().count(),
        // At a wrap, the character belongs to the row it is drawn on, not
        // the one it was pushed off.
        prefer_next_row: true,
    };
    let match_row = galley.layout_from_cursor(cursor).row;

    // epaint trades a glyph or two off the end of the last visible row for
    // its own overflow ellipsis, so a match sitting there only counts as
    // visible when there was nothing below it to elide in the first place.
    let visible_rows = if galley.rows.len() > max_rows {
        max_rows.saturating_sub(1)
    } else {
        max_rows
    };
    if match_row < visible_rows {
        return 0;
    }

    // Keep a third of the budget as lead-in so the hit is not pinned to the
    // top edge.
    let mut cursor = cursor;
    for _ in 0..max_rows / 3 {
        // `Some(0.0)` asks for the row above, not the character above.
        cursor = galley.cursor_up_one_row(&cursor, Some(0.0)).0;
    }
    let start_char = galley.cursor_begin_of_row(&cursor).index;
    snip.window
        .char_indices()
        .nth(start_char)
        .map_or(snip.window.len(), |(i, _)| i)
}

/// Build a highlighted snippet LayoutJob from byte ranges, wrapped to at
/// most `max_rows` and started far enough into the window that the first
/// match survives the cap.
pub(super) fn snippet_job(ui: &egui::Ui, snip: &Snippet, max_rows: usize) -> LayoutJob {
    let fmt = snippet_formats(ui);
    // In a top-down `Ui`, `ui.label` overwrites `wrap.max_width` with exactly
    // `ui.available_width()`; setting it here anyway lets `first_visible_byte`
    // (and a test) lay out the rows the user will see.
    let wrap_width = ui.available_width();
    let start = first_visible_byte(ui, snip, &fmt, max_rows, wrap_width);

    let mut job = LayoutJob::default();
    job.wrap.max_width = wrap_width;
    job.wrap.max_rows = max_rows;
    if start > 0 || snip.truncated_start {
        job.append(SNIPPET_LEAD, 0.0, fmt.weak.clone());
    }
    append_marked(
        &mut job,
        &fmt,
        &snip.window,
        &snip.ranges,
        start..snip.window.len(),
    );
    if snip.truncated_end {
        job.append(" …", 0.0, fmt.weak);
    }
    job
}

/// The Content Match column cell: one line with the (first) matched span
/// centered and an equal amount of context on both sides, trimmed to what fits
/// the column width.
///
/// Only ever called with a content snippet. Name and path matches are
/// highlighted in their own columns and leave a dash here, so the bracketed
/// `[whole field]` rendering this used to carry is gone.
pub(super) fn centered_match_job(ui: &egui::Ui, snip: &Snippet, width_px: f32) -> LayoutJob {
    let fmt = snippet_formats(ui);

    // Newlines force line breaks even in a one-row LayoutJob; flatten them
    // to spaces — a byte-for-byte ASCII replacement, so the match ranges
    // stay valid. The mouseover renders the original window untouched.
    let flattened: Option<String> = snip
        .window
        .contains(['\n', '\r', '\t'])
        .then(|| snip.window.replace(['\n', '\r', '\t'], " "));
    let window = flattened.as_deref().unwrap_or(&snip.window);

    // The budget is in pixels, summed from the font's own glyph advances:
    // the centered-and-justified layout puts egui in Extend mode, which lays
    // the job out at infinite width, and `Column::clip` then trims a
    // *centered* overflow from both ends at once — silently, taking the
    // highlighted match with it.
    let (start, end, decorate) = ui.fonts(|f| {
        let font_id = &fmt.normal.font_id;
        let width_of = |c: char| f.glyph_width(font_id, c);
        let ellipsis = width_of('…');
        let mut marks = 0.0;
        if snip.truncated_start {
            marks += ellipsis;
        }
        if snip.truncated_end {
            marks += ellipsis;
        }
        if fits_within(window, width_px - marks, width_of) {
            return (0, window.len(), true);
        }

        // Something has to go, so either end may gain a mark; reserve for
        // both so a cut never overflows the column.
        let budget = width_px - 2.0 * ellipsis;
        let Some(&(a, b)) = snip.ranges.first() else {
            // No ranges (shouldn't happen for match cells) — head trim.
            return (0, take_forward(window, 0, budget.max(0.0), width_of), true);
        };
        if budget <= 0.0 {
            // A column narrower than its own punctuation: spend every point
            // on the hit and drop the decoration.
            return (a, take_forward(window, a, width_px, width_of), false);
        }
        if !fits_within(&window[a..b], budget, width_of) {
            // A hit wider than the whole column — a greedy regex or wildcard
            // match. Its beginning is the part that has to survive.
            return (a, take_forward(window, a, budget, width_of), true);
        }
        let match_w: f32 = window[a..b].chars().map(width_of).sum();

        // Equal context on both sides, grown outward one character at a
        // time; whichever side is currently narrower is fed first, so the
        // leftover from a short side flows to the other.
        let (mut start, mut end) = (a, b);
        let (mut before_w, mut after_w) = (0.0f32, 0.0f32);
        loop {
            let prev = window[..start].chars().next_back();
            let next = window[end..].chars().next();
            let used = before_w + match_w + after_w;
            let prev_fits = prev.is_some_and(|c| used + width_of(c) <= budget);
            let next_fits = next.is_some_and(|c| used + width_of(c) <= budget);
            if !prev_fits && !next_fits {
                break;
            }
            // The preferred side wins when it fits; otherwise the other one
            // does, since at least one of them just did.
            let take_prev = if before_w <= after_w {
                prev_fits
            } else {
                !next_fits
            };
            if take_prev {
                let c = prev.expect("prev_fits");
                start -= c.len_utf8();
                before_w += width_of(c);
            } else {
                let c = next.expect("next_fits");
                end += c.len_utf8();
                after_w += width_of(c);
            }
        }
        (start, end, true)
    });

    let mut job = LayoutJob::default();
    job.wrap.max_rows = 1;
    job.wrap.break_anywhere = true;
    if decorate && (start > 0 || snip.truncated_start) {
        job.append("…", 0.0, fmt.weak.clone());
    }
    append_marked(&mut job, &fmt, window, &snip.ranges, start..end);
    if decorate && (end < window.len() || snip.truncated_end) {
        job.append("…", 0.0, fmt.weak);
    }
    job
}

/// The Path column cell: middle-elided to `width_px`, with whatever of a
/// path-tier match survives the cut highlighted.
///
/// The path reads at full strength — it is the one column that identifies a
/// result on its own. Only the elision mark is weak, since it is punctuation
/// this renderer added rather than anything the file is named.
///
/// Returns the job and whether anything was actually elided — the caller's
/// trigger for a full-path tooltip, since egui offers one only when *it* did
/// the eliding and it is handed an already-shortened string.
pub(super) fn path_cell_job(
    ui: &egui::Ui,
    path: &str,
    ranges: &[(usize, usize)],
    width_px: f32,
    font_id: &egui::FontId,
) -> (LayoutJob, bool) {
    let fmt = snippet_formats(ui);
    let mut job = LayoutJob::default();
    match crate::ui_util::middle_elide_cut(ui, path, width_px, font_id) {
        // It fits: the whole path, marked — which is exactly
        // [`marked_field_job`].
        None => (marked_field_job(ui, path, ranges), false),
        // The two surviving ends are appended straight from `path` at their
        // original offsets: `append_marked` clips the ranges to each end, so a
        // match that fell in the dropped middle drops with it rather than
        // landing on whatever glyphs moved into those offsets.
        Some((head, tail)) => {
            append_marked(&mut job, &fmt, path, ranges, 0..head);
            job.append("…", 0.0, fmt.weak.clone());
            append_marked(&mut job, &fmt, path, ranges, tail..path.len());
            (job, true)
        }
    }
}

/// Whether the whole of `text` fits in `budget` pixels; stops at the first
/// character that does not.
fn fits_within(text: &str, budget: f32, width_of: impl Fn(char) -> f32) -> bool {
    let mut used = 0.0;
    for c in text.chars() {
        used += width_of(c);
        if used > budget {
            return false;
        }
    }
    true
}

/// The byte offset one past the last character of `text[from..]` that still
/// fits in `budget` pixels.
fn take_forward(text: &str, from: usize, budget: f32, width_of: impl Fn(char) -> f32) -> usize {
    let mut end = from;
    let mut used = 0.0;
    for c in text[from..].chars() {
        let w = width_of(c);
        if used + w > budget {
            break;
        }
        used += w;
        end += c.len_utf8();
    }
    end
}
