use std::path::Path;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
    Certain,
    Likely,
    Ambiguous,
}

impl Confidence {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Certain => "certain",
            Self::Likely => "likely",
            Self::Ambiguous => "ambiguous",
        }
    }
}

#[derive(Debug, Clone)]
pub struct Match {
    pub score: u8,
    pub confidence: Confidence,
    pub label: String,
}

pub fn score(video: &Path, subtitle: &Path) -> Option<Match> {
    let video_stem = stem(video)?;
    let subtitle_stem = stem(subtitle)?;
    let video_name = Normalized::new(&video_stem);
    let subtitle_name = Normalized::new(&subtitle_stem);
    if video_name.tokens.is_empty() || subtitle_name.tokens.is_empty() {
        return None;
    }

    let exact = video_name.tokens == subtitle_name.tokens;
    let contains_video = contains_sequence(&subtitle_name.tokens, &video_name.tokens);
    let same_directory = video.parent() == subtitle.parent();
    let episode_video = video_name.episode();
    let episode_subtitle = subtitle_name.episode();

    if !contains_video
        && episode_video.is_some()
        && episode_subtitle.is_some()
        && episode_video != episode_subtitle
    {
        return None;
    }

    let similarity = jaccard(&video_name.tokens, &subtitle_name.tokens);
    let (score, confidence) = if exact {
        (100, Confidence::Certain)
    } else if contains_video {
        (95, Confidence::Certain)
    } else {
        let mut value = (similarity * 55.0).round() as u8;
        if episode_video.is_some() && episode_video == episode_subtitle {
            value = value.saturating_add(30);
        }
        if same_directory {
            value = value.saturating_add(10);
        }
        let confidence = if value >= 75 {
            Confidence::Likely
        } else {
            Confidence::Ambiguous
        };
        (value.min(94), confidence)
    };
    if score < 40 {
        return None;
    }

    Some(Match {
        score,
        confidence,
        label: candidate_label(video, subtitle, contains_video),
    })
}

fn stem(path: &Path) -> Option<String> {
    path.file_stem()
        .map(|value| value.to_string_lossy().into_owned())
}

struct Normalized {
    tokens: Vec<String>,
}

impl Normalized {
    fn new(value: &str) -> Self {
        let mut tokens = Vec::new();
        let mut current = String::new();
        let mut digit = None;
        for character in value.chars().flat_map(char::to_lowercase) {
            if !character.is_alphanumeric() {
                push_token(&mut tokens, &mut current);
                digit = None;
                continue;
            }
            let is_digit = character.is_numeric();
            if digit.is_some_and(|previous| previous != is_digit) {
                push_token(&mut tokens, &mut current);
            }
            digit = Some(is_digit);
            current.push(character);
        }
        push_token(&mut tokens, &mut current);
        Self { tokens }
    }

    fn episode(&self) -> Option<u32> {
        for window in self.tokens.windows(4) {
            if window[0] == "s"
                && window[2] == "e"
                && let Ok(value) = window[3].parse()
            {
                return Some(value);
            }
        }
        let technical_boundary = self.tokens.iter().position(|token| {
            token
                .parse::<u32>()
                .is_ok_and(|value| is_technical_number(value, None))
        });
        self.tokens
            .iter()
            .enumerate()
            .take(technical_boundary.unwrap_or(self.tokens.len()))
            .filter_map(|(index, token)| {
                let value: u32 = token.parse().ok()?;
                if is_technical_number(value, self.tokens.get(index.wrapping_sub(1))) {
                    None
                } else {
                    Some(value)
                }
            })
            .next_back()
    }
}

fn push_token(tokens: &mut Vec<String>, current: &mut String) {
    if !current.is_empty() {
        tokens.push(std::mem::take(current));
    }
}

fn is_technical_number(value: u32, previous: Option<&String>) -> bool {
    matches!(
        value,
        240 | 264 | 265 | 360 | 480 | 576 | 720 | 1080 | 1440 | 2160 | 4320
    ) || previous.is_some_and(|token| matches!(token.as_str(), "x" | "h"))
}

fn contains_sequence(haystack: &[String], needle: &[String]) -> bool {
    needle.len() <= haystack.len()
        && haystack
            .windows(needle.len())
            .any(|window| window == needle)
}

fn jaccard(left: &[String], right: &[String]) -> f32 {
    let common = left.iter().filter(|token| right.contains(token)).count();
    let union = left.len() + right.len() - common;
    if union == 0 {
        0.0
    } else {
        common as f32 / union as f32
    }
}

fn candidate_label(video: &Path, subtitle: &Path, contains_video: bool) -> String {
    let video_stem = stem(video).unwrap_or_default();
    let subtitle_stem = stem(subtitle).unwrap_or_default();
    let suffix = if contains_video {
        subtitle_stem
            .strip_prefix(&video_stem)
            .unwrap_or("")
            .trim_matches(|character: char| !character.is_alphanumeric())
            .to_owned()
    } else {
        String::new()
    };
    let folder = (video.parent() != subtitle.parent())
        .then(|| subtitle.parent()?.file_name()?.to_str().map(str::to_owned))
        .flatten();
    match (folder, suffix.is_empty()) {
        (Some(folder), false) => format!("{folder} · {suffix}"),
        (Some(folder), true) => folder,
        (None, false) => suffix,
        (None, true) => "External".to_owned(),
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::{Confidence, score};

    #[test]
    fn exact_stems_are_certain() {
        let result = score(
            Path::new("05. Famous Shooter.avi"),
            Path::new("05. Famous Shooter.ass"),
        )
        .unwrap();
        assert_eq!(result.confidence, Confidence::Certain);
        assert_eq!(result.score, 100);
    }

    #[test]
    fn release_suffixes_are_certain_and_become_labels() {
        let result = score(
            Path::new("Show - 01 [1080p WEB-DL].mkv"),
            Path::new("Show - 01 [1080p WEB-DL]-RELEASE2.ass"),
        )
        .unwrap();
        assert_eq!(result.confidence, Confidence::Certain);
        assert_eq!(result.label, "RELEASE2");
    }

    #[test]
    fn rejects_a_neighboring_episode() {
        assert!(score(Path::new("Show - 01.mkv"), Path::new("Show - 02.ass")).is_none());
    }

    #[test]
    fn folder_names_are_labels_not_languages() {
        let result = score(
            Path::new("Show/01.mkv"),
            Path::new("Show/SUBS/Variant A/01.srt"),
        )
        .unwrap();
        assert_eq!(result.label, "Variant A");
    }
}
