use gaze_types::{Candidate, ConflictTier, DetectContext, LocaleTag, PiiClass, Recognizer};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AnchoredBoundary {
    Punctuation,
    Whitespace,
    LineEnd,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NameShape {
    PersonName,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CuePosition {
    Before,
    After,
}

/// Recognizes person names near literal locale cues.
///
/// The `person_name` grammar accepts Unicode letter components with internal
/// apostrophe or hyphen joins, initials written as a single uppercase letter
/// followed by `.`, and non-leading particles (`de`, `la`, `van`, `der`) only
/// when sandwiched between uppercase components. Matching is max-greedy,
/// capped at four components and 64 bytes.
pub struct AnchoredMatchRecognizer {
    id: String,
    cues: Vec<String>,
    boundary: AnchoredBoundary,
    right_window_chars: u16,
    name_shape: NameShape,
    cue_position: CuePosition,
    source: String,
    score: f32,
    priority: i32,
    token_family: String,
    locales: Vec<LocaleTag>,
    min_components: usize,
}

impl AnchoredMatchRecognizer {
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        id: String,
        cues: Vec<String>,
        boundary: AnchoredBoundary,
        right_window_chars: u16,
        name_shape: NameShape,
        cue_position: CuePosition,
        source_short_label: String,
        min_components: usize,
        score: f32,
        priority: i32,
    ) -> Self {
        let mut cues = cues
            .into_iter()
            .filter(|cue| !cue.trim().is_empty())
            .collect::<Vec<_>>();
        cues.sort_by_key(|cue| std::cmp::Reverse(cue.chars().count()));
        Self {
            id,
            cues,
            boundary,
            right_window_chars,
            name_shape,
            cue_position,
            source: format!("structural.{source_short_label}"),
            score,
            priority,
            token_family: "name.counter".to_string(),
            locales: vec![LocaleTag::Global],
            min_components,
        }
    }

    fn candidate_after_cue(&self, input: &str, cue_end: usize) -> Option<std::ops::Range<usize>> {
        let window = bounded_window_after(input, cue_end, self.right_window_chars);
        let window = if matches!(self.boundary, AnchoredBoundary::LineEnd) {
            window
                .split_once(['\n', '\r'])
                .map_or(window, |(line, _)| line)
        } else {
            window
        };
        let relative = is_person_name_candidate_with_min(window, self.min_components)?;
        Some((cue_end + relative.start)..(cue_end + relative.end))
    }

    fn candidate_before_cue(
        &self,
        input: &str,
        cue_start: usize,
    ) -> Option<std::ops::Range<usize>> {
        let window = bounded_window_before(input, cue_start, self.right_window_chars);
        let relative = last_person_name_candidate_with_min(window, self.min_components)?;
        let window_start = cue_start - window.len();
        Some((window_start + relative.start)..(window_start + relative.end))
    }
}

impl Recognizer for AnchoredMatchRecognizer {
    fn id(&self) -> &str {
        &self.id
    }

    fn supported_class(&self) -> &PiiClass {
        &PiiClass::Name
    }

    fn detect(&self, input: &str, _ctx: &DetectContext<'_>) -> Vec<Candidate> {
        if !matches!(self.name_shape, NameShape::PersonName) {
            return Vec::new();
        }

        let mut candidates = Vec::new();
        for cue in &self.cues {
            for cue_range in find_cue_ranges(input, cue) {
                let span = match self.cue_position {
                    CuePosition::Before => self.candidate_after_cue(input, cue_range.end),
                    CuePosition::After => self.candidate_before_cue(input, cue_range.start),
                };
                let Some(span) = span else {
                    continue;
                };
                if !self.boundary_allows(input, &span) {
                    continue;
                }
                candidates.push(Candidate::new(
                    span,
                    PiiClass::Name,
                    self.id.clone(),
                    self.score,
                    self.priority,
                    None,
                    self.token_family.clone(),
                    self.source.clone(),
                    ConflictTier::None,
                    Vec::new(),
                ));
            }
        }
        candidates.sort_by_key(|candidate| candidate.span.start);
        candidates.dedup_by(|a, b| a.span == b.span);
        candidates
    }

    fn token_family(&self) -> &str {
        &self.token_family
    }

    fn locales(&self) -> &[LocaleTag] {
        &self.locales
    }
}

impl AnchoredMatchRecognizer {
    fn boundary_allows(&self, input: &str, span: &std::ops::Range<usize>) -> bool {
        match self.boundary {
            AnchoredBoundary::Punctuation => input[span.end..]
                .chars()
                .find(|ch| !ch.is_whitespace())
                .is_none_or(|ch| matches!(ch, '.' | ',' | ':' | ';' | ')' | ']' | '>' | '\n')),
            AnchoredBoundary::Whitespace => input[span.end..]
                .chars()
                .next()
                .is_none_or(|ch| ch.is_whitespace() || matches!(ch, '.' | ',' | ':' | ';')),
            AnchoredBoundary::LineEnd => input[span.end..]
                .chars()
                .next()
                .is_none_or(|ch| ch == '\n' || ch == '\r'),
        }
    }
}

pub fn is_person_name_candidate(input: &str) -> Option<std::ops::Range<usize>> {
    is_person_name_candidate_with_min(input, 1)
}

fn is_person_name_candidate_with_min(
    input: &str,
    min_components: usize,
) -> Option<std::ops::Range<usize>> {
    for (start, _) in input.char_indices().filter(|(_, ch)| ch.is_uppercase()) {
        if let Some(span) = person_name_at(input, start, min_components) {
            return Some(span);
        }
    }
    None
}

fn last_person_name_candidate_with_min(
    input: &str,
    min_components: usize,
) -> Option<std::ops::Range<usize>> {
    input
        .char_indices()
        .filter(|(_, ch)| ch.is_uppercase())
        .filter_map(|(start, _)| person_name_at(input, start, min_components))
        .next_back()
}

fn person_name_at(
    input: &str,
    start: usize,
    min_components: usize,
) -> Option<std::ops::Range<usize>> {
    let mut end = start;
    let mut components = 0usize;
    let mut uppercase_components = 0usize;
    let mut pending_particles: Vec<(usize, usize)> = Vec::new();
    let mut cursor = start;

    while components < 4 {
        let Some((token_start, token_end)) = next_space_delimited(input, cursor) else {
            break;
        };
        if token_start != cursor && !input[cursor..token_start].chars().all(char::is_whitespace) {
            break;
        }
        let token = trim_trailing_boundary(&input[token_start..token_end]);
        let effective_end = token_start + token.len();
        if token.is_empty() {
            break;
        }

        if is_upper_component(token) {
            components += 1;
            uppercase_components += 1;
            pending_particles.clear();
            end = effective_end;
            cursor = token_end;
            continue;
        }

        if components > 0 && is_particle(token) {
            pending_particles.push((token_start, effective_end));
            components += 1;
            cursor = token_end;
            continue;
        }
        break;
    }

    if !pending_particles.is_empty() {
        end = pending_particles[0].0.saturating_sub(1);
        components = components.saturating_sub(pending_particles.len());
    }

    let candidate = &input[start..end];
    if has_organization_suffix(candidate) {
        return None;
    }
    (components >= min_components
        && uppercase_components > 0
        && candidate.len() <= 64
        && !candidate.is_empty())
    .then_some(start..end)
}

fn next_space_delimited(input: &str, cursor: usize) -> Option<(usize, usize)> {
    let start = input[cursor..]
        .char_indices()
        .find(|(_, ch)| !ch.is_whitespace())
        .map(|(offset, _)| cursor + offset)?;
    let end = input[start..]
        .char_indices()
        .find(|(_, ch)| ch.is_whitespace())
        .map_or(input.len(), |(offset, _)| start + offset);
    Some((start, end))
}

fn trim_trailing_boundary(token: &str) -> &str {
    if token.len() == 2 {
        let mut chars = token.chars();
        if chars.next().is_some_and(|ch| ch.is_uppercase()) && chars.next() == Some('.') {
            return token;
        }
    }
    token.trim_end_matches(['.', ',', ':', ';', ')', ']', '>'])
}

fn is_upper_component(token: &str) -> bool {
    if token.len() == 2 {
        let mut chars = token.chars();
        if chars.next().is_some_and(|ch| ch.is_uppercase()) && chars.next() == Some('.') {
            return true;
        }
    }
    let mut chars = token.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    first.is_uppercase()
        && !token.chars().all(|ch| ch.is_uppercase())
        && token.chars().all(|ch| {
            ch.is_alphabetic() || matches!(ch, '\'' | '-') || (ch == '.' && token.len() == 2)
        })
        && token
            .split(['\'', '-'])
            .all(|part| part.chars().next().is_some_and(|ch| ch.is_alphabetic()))
}

fn is_particle(token: &str) -> bool {
    matches!(token, "de" | "la" | "van" | "der")
}

fn has_organization_suffix(candidate: &str) -> bool {
    candidate
        .split_whitespace()
        .next_back()
        .is_some_and(|last| {
            matches!(
                trim_trailing_boundary(last),
                "Corp" | "Corporation" | "Inc" | "LLC" | "GmbH" | "AG"
            )
        })
}

fn find_cue_ranges(input: &str, cue: &str) -> Vec<std::ops::Range<usize>> {
    let input_lower = input.to_lowercase();
    let cue_lower = cue.to_lowercase();
    input_lower
        .match_indices(&cue_lower)
        .filter_map(|(start, matched)| {
            let end = start + matched.len();
            (is_boundary(input, start, true) && is_boundary(input, end, false))
                .then_some(start..end)
        })
        .collect()
}

fn is_boundary(input: &str, index: usize, before: bool) -> bool {
    let ch = if before {
        input[..index].chars().next_back()
    } else {
        input[index..].chars().next()
    };
    ch.is_none_or(|ch| !ch.is_alphanumeric() && ch != '_' && ch != '-')
}

fn bounded_window_after(input: &str, start: usize, max_chars: u16) -> &str {
    let end = input[start..]
        .char_indices()
        .nth(max_chars as usize)
        .map_or(input.len(), |(offset, _)| start + offset);
    &input[start..end]
}

fn bounded_window_before(input: &str, end: usize, max_chars: u16) -> &str {
    let start = input[..end]
        .char_indices()
        .rev()
        .nth(max_chars as usize)
        .map_or(0, |(offset, _)| offset);
    &input[start..end]
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use gaze_types::{DictionaryBundle, LocaleTag};

    use super::*;

    #[test]
    fn person_name_shape_accepts_required_matches() {
        for value in [
            "Łukasz Müller",
            "O'Brien",
            "Alice de la Cruz",
            "Jean-Paul Sartre",
            "Müller-Schmidt",
            "José María García",
        ] {
            assert!(
                is_person_name_candidate(value).is_some(),
                "{value} should match"
            );
        }
    }

    #[test]
    fn person_name_shape_rejects_required_non_matches() {
        for value in ["IBM", "mailgun", "the alice"] {
            assert!(
                is_person_name_candidate(value).is_none(),
                "{value} should not match"
            );
        }
        assert!(is_person_name_candidate_with_min("Acme Corp", 2).is_none());
    }

    #[test]
    fn detects_cue_followed_by_name_with_punctuation_boundary() {
        let recognizer = test_recognizer(
            "test.from_cue",
            vec!["from"],
            AnchoredBoundary::Punctuation,
            CuePosition::Before,
            "from",
        );
        let hits = recognizer.detect("Hello from Alice Example, please reply.", &ctx());

        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].span, 11..24);
        assert_eq!(hits[0].recognizer_id, "test.from_cue");
        assert_eq!(hits[0].source, "structural.from");
    }

    #[test]
    fn detects_line_end_and_whitespace_bounded_names() {
        let line_end = test_recognizer(
            "test.line",
            vec!["from"],
            AnchoredBoundary::LineEnd,
            CuePosition::Before,
            "from",
        );
        assert_eq!(
            line_end.detect("Forwarded from Alice Example\nBody", &ctx())[0].span,
            15..28
        );

        let whitespace = test_recognizer(
            "test.ws",
            vec!["reply to"],
            AnchoredBoundary::Whitespace,
            CuePosition::Before,
            "agent_recipient",
        );
        assert_eq!(
            whitespace.detect("Please reply to Alice Example today", &ctx())[0].span,
            16..29
        );
    }

    #[test]
    fn detects_cue_after_name_and_rejects_window_overflow() {
        let recognizer = test_recognizer(
            "test.after",
            vec!["sent"],
            AnchoredBoundary::Whitespace,
            CuePosition::After,
            "footer",
        );
        assert_eq!(
            recognizer.detect("Alice Example sent this", &ctx())[0].span,
            0..13
        );

        let short = AnchoredMatchRecognizer::new(
            "test.short".to_string(),
            vec!["from".to_string()],
            AnchoredBoundary::Punctuation,
            4,
            NameShape::PersonName,
            CuePosition::Before,
            "from".to_string(),
            2,
            0.9,
            10,
        );
        assert!(short.detect("from Alice Example.", &ctx()).is_empty());
    }

    #[test]
    fn min_components_is_explicit_not_source_label_derived() {
        let one_component_non_forward = AnchoredMatchRecognizer::new(
            "test.one_component".to_string(),
            vec!["from".to_string()],
            AnchoredBoundary::Punctuation,
            64,
            NameShape::PersonName,
            CuePosition::Before,
            "agent_recipient".to_string(),
            1,
            0.9,
            10,
        );
        assert_eq!(
            one_component_non_forward.detect("from Alice:", &ctx())[0].span,
            5..10
        );

        let two_component_forward = AnchoredMatchRecognizer::new(
            "test.two_component".to_string(),
            vec!["from".to_string()],
            AnchoredBoundary::Punctuation,
            64,
            NameShape::PersonName,
            CuePosition::Before,
            "forward_marker".to_string(),
            2,
            0.9,
            10,
        );
        assert!(two_component_forward
            .detect("from Alice:", &ctx())
            .is_empty());
    }

    #[test]
    fn handles_unicode_overlap_and_non_name_rejection() {
        let recognizer = test_recognizer(
            "test.forward",
            vec!["Forwarded", "Forwarded message from"],
            AnchoredBoundary::Punctuation,
            CuePosition::Before,
            "forward_marker",
        );
        let hits = recognizer.detect("Forwarded message from Łukasz Müller:", &ctx());
        assert_eq!(hits.len(), 1);
        assert_eq!(
            &"Forwarded message from Łukasz Müller:"[hits[0].span.clone()],
            "Łukasz Müller"
        );

        assert!(recognizer
            .detect("Forwarded message from mailgun:", &ctx())
            .is_empty());
    }

    #[test]
    fn emits_structural_source_labels_for_core_anchored_match_families() {
        for (short_label, cue, boundary, input, expected_source) in [
            (
                "forward_marker",
                "Forwarded message from",
                AnchoredBoundary::Punctuation,
                "Forwarded message from Alice Example:",
                "structural.forward_marker",
            ),
            (
                "agent_recipient",
                "antwortest",
                AnchoredBoundary::Punctuation,
                "Du antwortest als Artistfy-Support an Alice Example.",
                "structural.agent_recipient",
            ),
            (
                "footer",
                "Sent by",
                AnchoredBoundary::Whitespace,
                "Sent by Alice Example via Mailgun",
                "structural.footer",
            ),
        ] {
            let recognizer = test_recognizer(
                &format!("test.{short_label}"),
                vec![cue],
                boundary,
                CuePosition::Before,
                short_label,
            );
            let hits = recognizer.detect(input, &ctx());

            assert_eq!(
                hits.first().map(|hit| hit.source.as_str()),
                Some(expected_source),
                "{short_label} should persist the structural source label"
            );
        }
    }

    fn test_recognizer(
        id: &str,
        cues: Vec<&str>,
        boundary: AnchoredBoundary,
        cue_position: CuePosition,
        source_short_label: &str,
    ) -> AnchoredMatchRecognizer {
        AnchoredMatchRecognizer::new(
            id.to_string(),
            cues.into_iter().map(str::to_string).collect(),
            boundary,
            64,
            NameShape::PersonName,
            cue_position,
            source_short_label.to_string(),
            2,
            0.91,
            60,
        )
    }

    fn ctx() -> DetectContext<'static> {
        let dictionaries = Box::leak(Box::new(DictionaryBundle::from_entries(HashMap::new())));
        let locale_chain = Box::leak(Box::new([LocaleTag::Global]));
        DetectContext::new(locale_chain, dictionaries)
    }
}
