use std::{borrow::Cow, ops::Range};

use crate::types::{ByteRange, MatchInfo};

/// Number of context lines kept on either side of a match.
pub const CONTEXT_LINES: usize = 2;

/// Max chars of context preserved on either side of a match within a single line.
pub const MAX_CONTEXT_CHARS: usize = 160;

#[derive(Debug, Clone)]
pub struct PreviewData {
    /// The preview information for matches in a file.
    pub matches: Box<[PreviewMatch]>,

    /// Object size in bytes, used to limit the quantity of data in the LRU cache.
    pub size: usize,
}

impl PreviewData {
    /// Build rich preview data for a file given its content and the byte ranges of each match.
    #[must_use]
    pub fn new(content: &str, byte_ranges: &[ByteRange]) -> Self {
        let mut line_starts: Vec<usize> = std::iter::once(0)
            .chain(memchr::memchr_iter(b'\n', content.as_bytes()).map(|i| i + 1))
            .collect();
        if line_starts.last() == Some(&content.len()) {
            line_starts.pop();
        }
        let num_lines = line_starts.len();

        let mut matches = Vec::with_capacity(byte_ranges.len());
        let mut line_idx = 0;
        let mut size = 0;
        for &range in byte_ranges {
            while line_starts
                .get(line_idx + 1)
                .is_some_and(|&offset| offset <= range.start)
            {
                line_idx += 1;
            }
            let pm = PreviewMatch::new(content, &line_starts, num_lines, range, line_idx);
            size += pm.size();
            matches.push(pm);
        }
        PreviewData {
            matches: matches.into_boxed_slice(),
            size,
        }
    }
}

#[derive(Debug, Clone)]
pub struct PreviewMatch {
    pub match_col_start: usize,
    pub match_col_end: usize,
    pub context_before: Box<[ContextLine]>,
    pub context_after: Box<[ContextLine]>,
    pub kind: PreviewMatchKind,
}

impl PreviewMatch {
    fn new(
        content: &str,
        line_starts: &[usize],
        num_lines: usize,
        range: ByteRange,
        line_idx: usize,
    ) -> PreviewMatch {
        let get_line = |idx: usize| -> &str {
            let start = line_starts[idx];
            let end = line_starts.get(idx + 1).map_or(content.len(), |&s| s - 1);
            content[start..end].trim_end_matches(['\r', '\n'])
        };

        let line_number = line_idx + 1;

        let context_before: Box<[ContextLine]> = (line_idx.saturating_sub(CONTEXT_LINES)..line_idx)
            .map(|i| ContextLine::from_content(content, line_starts, i))
            .collect();

        let line_idx_end = if range.len() > 1024 {
            line_starts.partition_point(|&s| s < range.end) - 1
        } else {
            line_starts[line_idx + 1..]
                .iter()
                .position(|&s| s >= range.end)
                .map_or(num_lines - 1, |pos| line_idx + pos)
        };

        let context_after: Box<[ContextLine]> = ((line_idx_end + 1)
            ..=(line_idx_end + CONTEXT_LINES).min(num_lines.saturating_sub(1)))
            .map(|i| ContextLine::from_content(content, line_starts, i))
            .collect();

        let line_start_byte = line_starts[line_idx];
        let last_line_byte = line_starts[line_idx_end];
        let first_line_str = get_line(line_idx);
        let last_line_str = get_line(line_idx_end);
        // clamp to the trimmed line length so a match landing on `\r` (e.g. from
        // a multiline regex) doesn't produce an out-of-range slice index downstream
        let mut match_col_start = (range.start - line_start_byte).min(first_line_str.len());
        let mut match_col_end = (range.end - last_line_byte).min(last_line_str.len());

        let kind = if line_idx_end == line_idx {
            let (line_content, new_start, new_end) =
                truncate_around_match(last_line_str, match_col_start, match_col_end);
            match_col_start = new_start;
            match_col_end = new_end;
            PreviewMatchKind::SingleLine {
                line_number,
                line_content,
            }
        } else {
            PreviewMatchKind::MultiLine {
                line_number_start: line_idx + 1,
                matched_lines: (line_idx..=line_idx_end)
                    .map(|i| {
                        let line = get_line(i);
                        if i == line_idx {
                            truncate_right(line, match_col_start + MAX_CONTEXT_CHARS)
                        } else if i == line_idx_end {
                            truncate_right(line, match_col_end + MAX_CONTEXT_CHARS)
                        } else {
                            truncate_right(line, 0)
                        }
                    })
                    .collect(),
            }
        };

        PreviewMatch {
            match_col_start,
            match_col_end,
            context_before,
            context_after,
            kind,
        }
    }

    /// Build a `(content, range)` pair for word-boundary expansion of this match.
    ///
    /// `SingleLine` borrows the line content directly. `MultiLine` stitches the matched lines
    /// with `\n` so word-boundary expansion behaves identically to operating on the original
    /// file content (newlines are not word characters, so expansion stays inside the first/last
    /// matched line).
    pub fn match_context(&self) -> (Cow<'_, str>, Range<usize>) {
        match &self.kind {
            PreviewMatchKind::SingleLine { line_content, .. } => (
                Cow::Borrowed(line_content.as_ref()),
                self.match_col_start..self.match_col_end,
            ),
            PreviewMatchKind::MultiLine { matched_lines, .. } => {
                let stitched: String = matched_lines
                    .iter()
                    .map(AsRef::as_ref)
                    .collect::<Vec<&str>>()
                    .join("\n");
                let last_line_len = matched_lines.last().map_or(0, |l| l.len());
                let end = stitched.len() - (last_line_len - self.match_col_end);
                (Cow::Owned(stitched), self.match_col_start..end)
            }
        }
    }

    /// Number of preview lines this match produces (context + match lines).
    #[must_use]
    pub fn line_count(&self, info: &MatchInfo, replacement: &str) -> usize {
        let mut n = self.context_before.len();
        match &self.kind {
            PreviewMatchKind::SingleLine { .. } => n += 1,
            PreviewMatchKind::MultiLine { matched_lines, .. } => {
                n += self.multiline_line_count(info, matched_lines, replacement);
            }
        }
        n + self.context_after.len()
    }

    /// Number of preview lines a multiline match produces (removed + added/skipped lines).
    fn multiline_line_count(
        &self,
        info: &MatchInfo,
        matched_lines: &[Box<str>],
        replacement: &str,
    ) -> usize {
        if info.skip {
            return matched_lines.len();
        }
        let mut n = matched_lines.len();
        let has_prefix = self.match_col_start > 0;
        let has_suffix = matched_lines
            .last()
            .is_some_and(|l| self.match_col_end < l.len());
        if !replacement.is_empty() || has_prefix || has_suffix {
            // even an empty replacement still emits one `+` line carrying the prefix and/or suffix
            n += replacement.split('\n').count();
        }
        n
    }

    /// The size in bytes of this object in memory
    fn size(&self) -> usize {
        let ctx_size = |c: &[ContextLine]| -> usize {
            size_of_val(c) + c.iter().map(|cl| cl.content.len()).sum::<usize>()
        };
        let kind_size = match &self.kind {
            PreviewMatchKind::SingleLine { line_content, .. } => line_content.len(),
            PreviewMatchKind::MultiLine { matched_lines, .. } => {
                matched_lines.len() * size_of::<Box<str>>()
                    + matched_lines.iter().map(|s| s.len()).sum::<usize>()
            }
        };
        size_of::<PreviewMatch>()
            + ctx_size(&self.context_before)
            + ctx_size(&self.context_after)
            + kind_size
    }
}

#[derive(Debug, Clone)]
pub struct ContextLine {
    pub line_number: usize,
    pub content: Box<str>,
}

impl ContextLine {
    fn new(line_idx: usize, line: &str) -> Self {
        Self {
            line_number: line_idx + 1,
            content: truncate_right(line, 0),
        }
    }

    fn from_content(content: &str, line_starts: &[usize], line_idx: usize) -> Self {
        let start = line_starts[line_idx];
        let end = line_starts
            .get(line_idx + 1)
            .map_or(content.len(), |&s| s - 1);
        Self::new(line_idx, content[start..end].trim_end_matches(['\r', '\n']))
    }
}

#[derive(Debug, Clone)]
pub enum PreviewMatchKind {
    SingleLine {
        line_number: usize,
        line_content: Box<str>,
    },
    MultiLine {
        line_number_start: usize,
        matched_lines: Box<[Box<str>]>,
    },
}

/// Truncate from the right, keeping at least `min_chars` chars from the start.
fn truncate_right(s: &str, min_chars: usize) -> Box<str> {
    let limit = MAX_CONTEXT_CHARS.max(min_chars);
    let Some((end, _)) = s.char_indices().nth(limit) else {
        return s.into();
    };
    format!("{}\u{2026}", &s[..end]).into()
}

/// Truncate a match line, keeping `MAX_CONTEXT_CHARS` chars of context on each side of the
/// match region `[col_start..col_end]` (byte offsets).
///
/// Returns `(truncated_line, new_col_start, new_col_end)`.
fn truncate_around_match(line: &str, col_start: usize, col_end: usize) -> (Box<str>, usize, usize) {
    let keep_start = line
        .char_indices()
        .nth(
            line[..col_start]
                .chars()
                .count()
                .saturating_sub(MAX_CONTEXT_CHARS),
        )
        .map_or(0, |(i, _)| i);

    let keep_end = line
        .char_indices()
        .nth(line[..col_end].chars().count() + MAX_CONTEXT_CHARS)
        .map_or(line.len(), |(i, _)| i);

    (
        line[keep_start..keep_end].into(),
        col_start - keep_start,
        col_end - keep_start,
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn build_preview_single_line() {
        let content = "hello world\n";
        let data = PreviewData::new(content, &[ByteRange::new(0, 5)]);
        assert_eq!(data.matches.len(), 1);
        let m = &data.matches[0];
        assert_eq!(m.match_col_start, 0);
        assert_eq!(m.match_col_end, 5);
        assert!(matches!(
            m.kind,
            PreviewMatchKind::SingleLine { line_number: 1, .. }
        ));
    }

    #[test]
    fn build_preview_context_before_and_after() {
        let content = "a\nb\nc\nmatch\nd\ne\nf\n";
        let pos = content.find("match").unwrap();
        let data = PreviewData::new(content, &[ByteRange::new(pos, pos + 5)]);
        let m = &data.matches[0];
        assert_eq!(m.context_before.len(), CONTEXT_LINES);
        assert_eq!(&*m.context_before[0].content, "b");
        assert_eq!(&*m.context_before[1].content, "c");
        assert_eq!(m.context_after.len(), CONTEXT_LINES);
        assert_eq!(&*m.context_after[0].content, "d");
    }

    #[test]
    fn build_preview_multiline_match() {
        let content = "foo\nbar\nbaz\n";
        let data = PreviewData::new(content, &[ByteRange::new(0, 7)]);
        let m = &data.matches[0];
        let PreviewMatchKind::MultiLine {
            line_number_start,
            matched_lines,
        } = &m.kind
        else {
            panic!("expected MultiLine");
        };
        assert_eq!(*line_number_start, 1);
        assert_eq!(matched_lines.len(), 2);
    }

    #[test]
    fn build_preview_truncates_long_line_around_match() {
        let prefix = "a".repeat(200);
        let suffix = "b".repeat(200);
        let content = format!("{prefix}NEEDLE{suffix}\n");
        let pos = content.find("NEEDLE").unwrap();
        let data = PreviewData::new(&content, &[ByteRange::new(pos, pos + 6)]);
        let m = &data.matches[0];
        let PreviewMatchKind::SingleLine { line_content, .. } = &m.kind else {
            panic!("expected SingleLine");
        };
        let before = &line_content[..m.match_col_start];
        let after = &line_content[m.match_col_end..];
        assert_eq!(before.len(), MAX_CONTEXT_CHARS);
        assert_eq!(after.len(), MAX_CONTEXT_CHARS);
    }

    #[test]
    fn build_preview_strips_crlf_line_endings() {
        let content = "a\r\nb\r\nc\r\nmatch\r\nd\r\ne\r\n";
        let pos = content.find("match").unwrap();
        let data = PreviewData::new(content, &[ByteRange::new(pos, pos + 5)]);
        let m = &data.matches[0];

        assert_eq!(&*m.context_before[0].content, "b");
        assert_eq!(&*m.context_before[1].content, "c");
        assert_eq!(&*m.context_after[0].content, "d");
        assert_eq!(&*m.context_after[1].content, "e");

        let PreviewMatchKind::SingleLine { line_content, .. } = &m.kind else {
            panic!("expected SingleLine");
        };
        assert_eq!(&**line_content, "match");
        assert_eq!(m.match_col_end, 5);
    }

    #[test]
    fn build_preview_clamps_match_col_start_when_match_starts_on_line_terminator() {
        // a match whose byte_start lands on `\n` produces a column past the
        // \r-stripped line length; without clamping, downstream slicing of
        // matched_lines[0] would panic
        let content = "foo bar\r\nbaz\r\n";
        let lf_byte = 8;
        let baz_end = 12;
        let data = PreviewData::new(content, &[ByteRange::new(lf_byte, baz_end)]);
        let m = &data.matches[0];
        let PreviewMatchKind::MultiLine { matched_lines, .. } = &m.kind else {
            panic!("expected MultiLine");
        };
        assert!(m.match_col_start <= matched_lines[0].len());
    }

    #[test]
    fn build_preview_strips_crlf_in_multiline_match() {
        let content = "foo\r\nbar\r\nbaz\r\n";
        let data = PreviewData::new(content, &[ByteRange::new(0, 8)]);
        let m = &data.matches[0];
        let PreviewMatchKind::MultiLine { matched_lines, .. } = &m.kind else {
            panic!("expected MultiLine");
        };
        assert_eq!(matched_lines.len(), 2);
        assert_eq!(&*matched_lines[0], "foo");
        assert_eq!(&*matched_lines[1], "bar");
    }

    #[test]
    fn build_preview_size_bytes_nonzero() {
        let content = "hello world\n";
        let data = PreviewData::new(content, &[ByteRange::new(0, 5)]);
        assert!(data.size > 0);
    }

    #[test]
    fn build_preview_empty_byte_ranges() {
        let data = PreviewData::new("hello\n", &[]);
        assert_eq!(data.matches.len(), 0);
        assert_eq!(data.size, 0);
    }

    fn make_info() -> MatchInfo {
        MatchInfo {
            byte_range: ByteRange::new(0, 5),
            skip: false,
            captures: Box::new([]),
        }
    }

    fn make_preview_single(line: &str, col_start: usize, col_end: usize) -> PreviewMatch {
        PreviewMatch {
            match_col_start: col_start,
            match_col_end: col_end,
            context_before: Box::new([]),
            context_after: Box::new([]),
            kind: PreviewMatchKind::SingleLine {
                line_number: 1,
                line_content: Box::from(line),
            },
        }
    }

    fn make_preview_multiline(lines: &[&str], col_start: usize, col_end: usize) -> PreviewMatch {
        let boxed_lines: Box<[Box<str>]> = lines.iter().map(|s| Box::from(*s)).collect();
        PreviewMatch {
            match_col_start: col_start,
            match_col_end: col_end,
            context_before: Box::new([]),
            context_after: Box::new([]),
            kind: PreviewMatchKind::MultiLine {
                line_number_start: 1,
                matched_lines: boxed_lines,
            },
        }
    }

    #[test]
    fn line_count_single_line_match() {
        let info = make_info();
        let preview = make_preview_single("hello world", 0, 5);
        assert_eq!(preview.line_count(&info, ""), 1);
    }

    #[test]
    fn line_count_single_line_with_context() {
        let info = make_info();
        let mut preview = make_preview_single("hello world", 0, 5);
        preview.context_before = vec![
            ContextLine {
                line_number: 1,
                content: Box::from("before"),
            },
            ContextLine {
                line_number: 2,
                content: Box::from("before2"),
            },
        ]
        .into();
        preview.context_after = vec![ContextLine {
            line_number: 4,
            content: Box::from("after"),
        }]
        .into();
        // context_before(2) + match(1) + context_after(1) = 4
        assert_eq!(preview.line_count(&info, ""), 4);
    }

    #[test]
    fn line_count_multiline_skipped() {
        let mut info = make_info();
        info.skip = true;
        let preview = make_preview_multiline(&["foo", "bar", "baz"], 0, 3);
        // 3 matched lines
        assert_eq!(preview.line_count(&info, "repl"), 3);
    }

    #[test]
    fn line_count_multiline_with_replacement() {
        let info = make_info();
        let preview = make_preview_multiline(&["  foo", "bar"], 2, 3);
        // 2 removed + 1 replacement (single line, prefix non-empty) = 3
        assert_eq!(preview.line_count(&info, "baz"), 3);
        // multi-line replacement: "a\nb\nc" = 3 lines, plus 2 removed = 5
        assert_eq!(preview.line_count(&info, "a\nb\nc"), 5);
    }

    #[test]
    fn line_count_multiline_empty_replacement_no_affixes() {
        let info = make_info();
        // match spans entire lines, so prefix and suffix are empty
        let preview = make_preview_multiline(&["foo", "bar"], 0, 3);
        // 2 removed, no replacement lines (empty repl + empty prefix + empty suffix)
        assert_eq!(preview.line_count(&info, ""), 2);
    }
}
