use std::{
    collections::{HashSet, VecDeque, hash_map::DefaultHasher},
    hash::{Hash, Hasher},
    path::{Path, PathBuf},
};

use tokio::fs;

use crate::{config::Config, error::AppError, media::path};

use super::matching::{self, Confidence};

const SUBTITLE_EXTENSIONS: &[&str] = &["ass", "ssa", "srt", "vtt"];
const MAX_DEPTH: usize = 3;
const MAX_ENTRIES: usize = 2_000;

#[derive(Debug, Clone)]
pub struct Candidate {
    pub id: String,
    pub path: PathBuf,
    pub relative_path: String,
    pub codec: String,
    pub label: String,
    pub score: u8,
    pub confidence: Confidence,
}

pub async fn find(config: &Config, video: &Path) -> Result<Vec<Candidate>, AppError> {
    let mut roots = Vec::new();
    if let Some(parent) = video.parent() {
        roots.push((parent.to_owned(), MAX_DEPTH));
        if let Some(grandparent) = parent.parent().filter(|path| *path != config.media_root)
            && grandparent.starts_with(&config.media_root)
        {
            roots.push((grandparent.to_owned(), 2));
        }
    }

    let mut queue = VecDeque::from(roots);
    let mut visited = HashSet::new();
    let mut scanned = 0;
    let mut candidates = Vec::new();
    while let Some((directory, depth)) = queue.pop_front() {
        if !visited.insert(directory.clone()) {
            continue;
        }
        let mut reader = match fs::read_dir(&directory).await {
            Ok(reader) => reader,
            Err(_) => continue,
        };
        while scanned < MAX_ENTRIES {
            let Some(entry) = reader.next_entry().await.map_err(AppError::internal)? else {
                break;
            };
            scanned += 1;
            let file_type = entry.file_type().await.map_err(AppError::internal)?;
            if file_type.is_symlink() {
                continue;
            }
            let candidate_path = entry.path();
            if file_type.is_dir() && depth > 0 {
                queue.push_back((candidate_path, depth - 1));
            } else if file_type.is_file()
                && is_subtitle(&candidate_path)
                && let Some(candidate) = make_candidate(config, video, candidate_path)?
            {
                candidates.push(candidate);
            }
        }
        if scanned >= MAX_ENTRIES {
            break;
        }
    }
    candidates.sort_by(|left, right| {
        right
            .score
            .cmp(&left.score)
            .then_with(|| left.relative_path.cmp(&right.relative_path))
    });
    candidates.dedup_by(|left, right| left.path == right.path);
    Ok(candidates)
}

pub async fn resolve_id(config: &Config, video: &Path, id: &str) -> Result<Candidate, AppError> {
    find(config, video)
        .await?
        .into_iter()
        .find(|candidate| candidate.id == id)
        .ok_or_else(|| AppError::not_found("external subtitle not found"))
}

fn make_candidate(
    config: &Config,
    video: &Path,
    subtitle: PathBuf,
) -> Result<Option<Candidate>, AppError> {
    let Some(matched) = matching::score(video, &subtitle) else {
        return Ok(None);
    };
    let relative_path = path::relative(config, &subtitle)?;
    let codec = subtitle
        .extension()
        .and_then(|value| value.to_str())
        .unwrap_or("unknown")
        .to_ascii_lowercase();
    Ok(Some(Candidate {
        id: candidate_id(&relative_path),
        path: subtitle,
        relative_path,
        codec,
        label: matched.label,
        score: matched.score,
        confidence: matched.confidence,
    }))
}

fn candidate_id(relative_path: &str) -> String {
    let mut hasher = DefaultHasher::new();
    relative_path.hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

fn is_subtitle(path: &Path) -> bool {
    path.extension()
        .and_then(|value| value.to_str())
        .is_some_and(|value| SUBTITLE_EXTENSIONS.contains(&value.to_ascii_lowercase().as_str()))
}
