//! Inline mark matchers for markdown shortcuts (bold, italic, strike,
//! underline, highlight, code, link).
//!
//! Each matcher mirrors the corresponding `*Rule.apply()` from the
//! React plugin exactly: it inspects the last character(s) typed,
//! walks back to find the opening delimiter, and returns the range +
//! inner text so the React side can dispatch the right command batch.
//!
//! All matchers operate on UTF-16 code-unit offsets to match the
//! engine's selection contract. They never touch the editor state.

use serde::{Deserialize, Serialize};

/// The eight kinds of inline-mark matchers exposed to callers. Using
/// an enum (rather than eight separate free functions) keeps the WASM
/// boundary narrow — JS passes a tag string, Rust dispatches.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MarkKind {
    /// `**bold**`
    Bold,
    /// `*italic*` (must run AFTER `Bold` so the closing `*` in
    /// `**bold**` hits bold first).
    ItalicAsterisk,
    /// `_italic_` (must run AFTER `Underline` for the same reason).
    ItalicUnderscore,
    /// `~~strike~~`
    Strike,
    /// `__underline__`
    Underline,
    /// `==highlight==`
    Highlight,
    /// `` `code` ``
    Code,
    /// `[text](url)` — returns a richer `LinkMatch` (two text ranges).
    Link,
}

/// Match payload for the seven delimiter-pair marks (bold, italic *2,
/// strike, underline, highlight, code).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InlineMatch {
    /// UTF-16 offset of the opening delimiter's first character.
    #[serde(rename = "openIdx")]
    pub open_idx: usize,
    /// UTF-16 offset of the closing delimiter's last character
    /// (inclusive, so `close_idx + 1` is the exclusive end for
    /// `setSelection`).
    #[serde(rename = "closeIdx")]
    pub close_idx: usize,
    /// Text between the delimiters (without them).
    pub inner: String,
}

/// Match payload specifically for `[text](url)`. Carries both the
/// text and the URL so the React side can store `href` on the mark.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinkMatch {
    #[serde(rename = "openIdx")]
    pub open_idx: usize,
    #[serde(rename = "closeIdx")]
    pub close_idx: usize,
    #[serde(rename = "innerText")]
    pub inner_text: String,
    pub url: String,
}

/// Unified return type — simpler API shape for the WASM layer.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum AnyInlineMatch {
    Link(LinkMatch),
    Inline(InlineMatch),
}

/// Run the matcher associated with `kind` against `block_text` up to
/// `cursor_offset` (UTF-16 units). Returns `None` when no match.
#[must_use]
pub fn match_inline_mark(
    kind: MarkKind,
    block_text: &str,
    cursor_offset: usize,
) -> Option<AnyInlineMatch> {
    // Work on a char-indexed buffer so all offsets are UTF-16 units
    // matching the engine's selection contract.
    let chars: Vec<(usize, char)> = utf16_indexed_chars(block_text);
    if cursor_offset == 0 {
        return None;
    }
    match kind {
        MarkKind::Bold => {
            match_double(&chars, cursor_offset, '*', true).map(AnyInlineMatch::Inline)
        }
        MarkKind::Strike => {
            match_double(&chars, cursor_offset, '~', true).map(AnyInlineMatch::Inline)
        }
        MarkKind::Underline => {
            match_double(&chars, cursor_offset, '_', true).map(AnyInlineMatch::Inline)
        }
        MarkKind::Highlight => {
            match_double(&chars, cursor_offset, '=', true).map(AnyInlineMatch::Inline)
        }
        MarkKind::Code => match_code(&chars, cursor_offset).map(AnyInlineMatch::Inline),
        MarkKind::ItalicAsterisk => {
            match_single(&chars, cursor_offset, '*').map(AnyInlineMatch::Inline)
        }
        MarkKind::ItalicUnderscore => {
            match_single(&chars, cursor_offset, '_').map(AnyInlineMatch::Inline)
        }
        MarkKind::Link => match_link(&chars, cursor_offset).map(AnyInlineMatch::Link),
    }
}

/// Walk `block_text` into a table `(utf16_offset, char)` so subsequent
/// lookups are O(1) by UTF-16 offset without repeatedly counting surrogate
/// pairs. The final entry's `utf16_offset` equals the total length.
fn utf16_indexed_chars(s: &str) -> Vec<(usize, char)> {
    let mut out = Vec::with_capacity(s.len());
    let mut units = 0usize;
    for ch in s.chars() {
        out.push((units, ch));
        units += ch.len_utf16();
    }
    // Sentinel at end so callers can use `chars[close_idx+1..]` safely
    // via indexing by utf-16 offset.
    out.push((units, '\0'));
    out
}

fn char_at(chars: &[(usize, char)], utf16_offset: usize) -> Option<char> {
    chars
        .iter()
        .find(|(off, _)| *off == utf16_offset)
        .map(|(_, c)| *c)
        .filter(|c| *c != '\0')
}

/// Extract the substring between UTF-16 offsets `[from, to)` from the
/// chars table. Returns an empty string when ranges are empty or
/// inverted.
fn slice_chars(chars: &[(usize, char)], from: usize, to: usize) -> String {
    if from >= to {
        return String::new();
    }
    chars
        .iter()
        .filter(|(off, c)| *c != '\0' && *off >= from && *off < to)
        .map(|(_, c)| *c)
        .collect()
}

/// Find the UTF-16 offset of the last occurrence of `delim delim`
/// strictly before `end_exclusive`. Returns the offset of the FIRST
/// delimiter character.
fn last_double_before(chars: &[(usize, char)], delim: char, end_exclusive: usize) -> Option<usize> {
    let mut candidate: Option<usize> = None;
    for window in chars.windows(2) {
        let (off_a, ca) = window[0];
        let (_off_b, cb) = window[1];
        if cb == '\0' {
            break;
        }
        if off_a + 2 > end_exclusive {
            break;
        }
        if ca == delim && cb == delim {
            candidate = Some(off_a);
        }
    }
    candidate
}

/// Find the UTF-16 offset of the last single `delim` strictly before
/// `end_exclusive`, excluding positions that are part of a `delim delim`
/// pair (e.g. single `*` that's not part of `**`).
fn last_single_before(chars: &[(usize, char)], delim: char, end_exclusive: usize) -> Option<usize> {
    let mut best: Option<usize> = None;
    for i in (0..chars.len().saturating_sub(1)).rev() {
        let (off, c) = chars[i];
        if c == '\0' {
            continue;
        }
        if off >= end_exclusive {
            continue;
        }
        if c != delim {
            continue;
        }
        // Check neighbouring chars to reject positions that form `delim delim`.
        let prev = if i > 0 { Some(chars[i - 1].1) } else { None };
        let next = chars.get(i + 1).map(|(_, c)| *c);
        if prev == Some(delim) || next == Some(delim) {
            continue;
        }
        best = Some(off);
        break;
    }
    best
}

/// Double-delimiter matcher used by bold/strike/underline/highlight.
/// `require_no_inner_dup`: if true, inner text containing the delimiter
/// is rejected (matches TS).
fn match_double(
    chars: &[(usize, char)],
    cursor_offset: usize,
    delim: char,
    require_no_inner_dup: bool,
) -> Option<InlineMatch> {
    let close_idx = cursor_offset.checked_sub(1)?;
    if char_at(chars, close_idx)? != delim {
        return None;
    }
    if char_at(chars, close_idx.checked_sub(1)?)? != delim {
        return None;
    }
    let search_end = close_idx - 1; // exclusive
    let open_idx = last_double_before(chars, delim, search_end)?;
    let inner_start = open_idx + 2;
    let inner_end = close_idx - 1;
    if inner_start >= inner_end {
        return None;
    }
    let inner = slice_chars(chars, inner_start, inner_end);
    if inner.is_empty() || inner.contains('\n') {
        return None;
    }
    if require_no_inner_dup && inner.contains(delim) {
        return None;
    }
    Some(InlineMatch {
        open_idx,
        close_idx,
        inner,
    })
}

/// Single-delimiter matcher used by italic (`*` and `_` flavours).
/// Rejects the closing char when it's the second of a doubled pair.
fn match_single(chars: &[(usize, char)], cursor_offset: usize, delim: char) -> Option<InlineMatch> {
    let close_idx = cursor_offset.checked_sub(1)?;
    if char_at(chars, close_idx)? != delim {
        return None;
    }
    // Skip `**` / `__` signature — the double-delim rule claims these.
    if let Some(prev_idx) = close_idx.checked_sub(1)
        && char_at(chars, prev_idx) == Some(delim)
    {
        return None;
    }
    let open_idx = last_single_before(chars, delim, close_idx)?;
    let inner_start = open_idx + 1;
    let inner_end = close_idx;
    if inner_start >= inner_end {
        return None;
    }
    let inner = slice_chars(chars, inner_start, inner_end);
    if inner.is_empty() || inner.contains('\n') || inner.contains(delim) {
        return None;
    }
    Some(InlineMatch {
        open_idx,
        close_idx,
        inner,
    })
}

/// Backtick code — last-`` ` `` via simple single-delim scan (no
/// doubling variant). Matches `backtickCodeRule.apply()`.
fn match_code(chars: &[(usize, char)], cursor_offset: usize) -> Option<InlineMatch> {
    let close_idx = cursor_offset.checked_sub(1)?;
    if char_at(chars, close_idx)? != '`' {
        return None;
    }
    // Find the last `` ` `` strictly before close — unlike italic, the
    // TS rule doesn't skip doubled backticks (no `` `` `` concept here).
    let open_idx = {
        let mut best: Option<usize> = None;
        for i in (0..chars.len().saturating_sub(1)).rev() {
            let (off, c) = chars[i];
            if c == '\0' {
                continue;
            }
            if off >= close_idx {
                continue;
            }
            if c == '`' {
                best = Some(off);
                break;
            }
        }
        best?
    };
    let inner = slice_chars(chars, open_idx + 1, close_idx);
    if inner.is_empty() || inner.contains('\n') {
        return None;
    }
    Some(InlineMatch {
        open_idx,
        close_idx,
        inner,
    })
}

/// `[text](url)` matcher — walks back from `)` to find `](` then `[`.
fn match_link(chars: &[(usize, char)], cursor_offset: usize) -> Option<LinkMatch> {
    let close_idx = cursor_offset.checked_sub(1)?;
    if char_at(chars, close_idx)? != ')' {
        return None;
    }
    // Last `](` strictly before close
    let mid_idx = {
        let mut best: Option<usize> = None;
        for i in (0..chars.len().saturating_sub(1)).rev() {
            let (off, c) = chars[i];
            if c == '\0' {
                continue;
            }
            if off >= close_idx {
                continue;
            }
            let next_c = chars.get(i + 1).map(|(_, c)| *c);
            if c == ']' && next_c == Some('(') {
                best = Some(off);
                break;
            }
        }
        best?
    };
    // Last `[` strictly before mid
    let open_idx = {
        let mut best: Option<usize> = None;
        for i in (0..chars.len().saturating_sub(1)).rev() {
            let (off, c) = chars[i];
            if c == '\0' {
                continue;
            }
            if off >= mid_idx {
                continue;
            }
            if c == '[' {
                best = Some(off);
                break;
            }
        }
        best?
    };

    let inner_text = slice_chars(chars, open_idx + 1, mid_idx);
    let url = slice_chars(chars, mid_idx + 2, close_idx);

    if inner_text.is_empty() || url.is_empty() {
        return None;
    }
    if inner_text.contains('\n') || url.contains('\n') {
        return None;
    }
    if inner_text.contains('[') || inner_text.contains(']') {
        return None;
    }
    if url.contains('(') || url.contains(')') {
        return None;
    }

    Some(LinkMatch {
        open_idx,
        close_idx,
        inner_text,
        url,
    })
}

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

    fn inner_of(m: AnyInlineMatch) -> InlineMatch {
        match m {
            AnyInlineMatch::Inline(im) => im,
            AnyInlineMatch::Link(_) => panic!("expected InlineMatch"),
        }
    }

    fn link_of(m: AnyInlineMatch) -> LinkMatch {
        match m {
            AnyInlineMatch::Link(lm) => lm,
            AnyInlineMatch::Inline(_) => panic!("expected LinkMatch"),
        }
    }

    // ── Bold (`**…**`) ──

    #[test]
    fn bold_basic() {
        let m = inner_of(match_inline_mark(MarkKind::Bold, "**hi**", 6).unwrap());
        assert_eq!(m.open_idx, 0);
        assert_eq!(m.close_idx, 5);
        assert_eq!(m.inner, "hi");
    }

    #[test]
    fn bold_with_leading_text() {
        let m = inner_of(match_inline_mark(MarkKind::Bold, "abc **yo**", 10).unwrap());
        assert_eq!(m.open_idx, 4);
        assert_eq!(m.inner, "yo");
    }

    #[test]
    fn bold_rejects_empty_inner() {
        assert!(match_inline_mark(MarkKind::Bold, "****", 4).is_none());
    }

    #[test]
    fn bold_rejects_inner_with_asterisk() {
        assert!(match_inline_mark(MarkKind::Bold, "***foo***", 9).is_none());
    }

    #[test]
    fn bold_rejects_multiline() {
        assert!(match_inline_mark(MarkKind::Bold, "**a\nb**", 7).is_none());
    }

    // ── Italic asterisk (`*…*`) ──

    #[test]
    fn italic_asterisk_basic() {
        let m = inner_of(match_inline_mark(MarkKind::ItalicAsterisk, "*x*", 3).unwrap());
        assert_eq!(m.inner, "x");
    }

    #[test]
    fn italic_asterisk_rejects_double_signature() {
        // Final char is `*` but prev is also `*` → bold territory, skip.
        assert!(match_inline_mark(MarkKind::ItalicAsterisk, "**hi**", 6).is_none());
    }

    #[test]
    fn italic_asterisk_skips_double_on_opening_search() {
        // The `**` pair should be skipped, so only the standalone
        // `*` after can open italic.
        let m = inner_of(
            match_inline_mark(MarkKind::ItalicAsterisk, "**bold** and *italic*", 21).unwrap(),
        );
        assert_eq!(m.inner, "italic");
    }

    // ── Italic underscore (`_…_`) ──

    #[test]
    fn italic_underscore_basic() {
        let m = inner_of(match_inline_mark(MarkKind::ItalicUnderscore, "_x_", 3).unwrap());
        assert_eq!(m.inner, "x");
    }

    #[test]
    fn italic_underscore_rejects_double_signature() {
        assert!(match_inline_mark(MarkKind::ItalicUnderscore, "__hi__", 6).is_none());
    }

    // ── Strike (`~~…~~`) ──

    #[test]
    fn strike_basic() {
        let m = inner_of(match_inline_mark(MarkKind::Strike, "~~x~~", 5).unwrap());
        assert_eq!(m.inner, "x");
    }

    #[test]
    fn strike_rejects_empty() {
        assert!(match_inline_mark(MarkKind::Strike, "~~~~", 4).is_none());
    }

    // ── Underline (`__…__`) ──

    #[test]
    fn underline_basic() {
        let m = inner_of(match_inline_mark(MarkKind::Underline, "__x__", 5).unwrap());
        assert_eq!(m.inner, "x");
    }

    // ── Highlight (`==…==`) ──

    #[test]
    fn highlight_basic() {
        let m = inner_of(match_inline_mark(MarkKind::Highlight, "==yo==", 6).unwrap());
        assert_eq!(m.inner, "yo");
    }

    // ── Code (`` `…` ``) ──

    #[test]
    fn code_basic() {
        let m = inner_of(match_inline_mark(MarkKind::Code, "`foo`", 5).unwrap());
        assert_eq!(m.inner, "foo");
    }

    #[test]
    fn code_rejects_empty() {
        assert!(match_inline_mark(MarkKind::Code, "``", 2).is_none());
    }

    // ── Link (`[text](url)`) ──

    #[test]
    fn link_basic() {
        let m =
            link_of(match_inline_mark(MarkKind::Link, "[devup](https://devup.kr)", 25).unwrap());
        assert_eq!(m.inner_text, "devup");
        assert_eq!(m.url, "https://devup.kr");
    }

    #[test]
    fn link_rejects_empty_text_or_url() {
        assert!(match_inline_mark(MarkKind::Link, "[](url)", 7).is_none());
        assert!(match_inline_mark(MarkKind::Link, "[text]()", 8).is_none());
    }

    #[test]
    fn link_rejects_nested_brackets() {
        assert!(match_inline_mark(MarkKind::Link, "[te[x]t](url)", 13).is_none());
    }

    #[test]
    fn link_rejects_nested_parens_in_url() {
        assert!(match_inline_mark(MarkKind::Link, "[t](u(r)l)", 10).is_none());
    }

    // ── Cross-cutting ──

    #[test]
    fn all_reject_when_cursor_zero() {
        assert!(match_inline_mark(MarkKind::Bold, "", 0).is_none());
        assert!(match_inline_mark(MarkKind::Code, "`", 0).is_none());
        assert!(match_inline_mark(MarkKind::Link, ")", 0).is_none());
    }

    #[test]
    fn utf16_offsets_respected_with_surrogate_pair() {
        // "😀" is 2 UTF-16 units, so "*😀*" has cursor at 4.
        let m = inner_of(match_inline_mark(MarkKind::ItalicAsterisk, "*😀*", 4).unwrap());
        assert_eq!(m.inner, "😀");
        assert_eq!(m.open_idx, 0);
        assert_eq!(m.close_idx, 3);
    }
}
