//! Pure naming and relative path rendering for [`Clip`] values.

use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;

use serde::{Deserialize, Serialize};

use crate::Clip;
use crate::error::{Error, Result};
use crate::lineage::LineageContext;

/// The default relative path template.
///
/// Supported placeholders are `{creator}`, `{handle}`, `{album}`, `{title}`,
/// `{id}`, `{id8}` (first 8 characters of the clip id), and `{root_id8}`
/// (first 8 of the resolved lineage root id). Empty path segments are dropped
/// after rendering.
///
/// The default embeds `[{id8}]` in the file name so same-title clips never
/// collide, and folders under `{album}`, which resolves to the lineage root's
/// title (else the clip's own title).
pub const DEFAULT_TEMPLATE: &str = "{creator}/{album}/{creator}-{title} [{id8}]";
const DEFAULT_MAX_COMPONENT_LEN: usize = 80;

const MIN_BASE_CHARS_WITH_SUFFIX: usize = 1;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CharacterSet {
    #[default]
    Unicode,
    Ascii,
}

impl FromStr for CharacterSet {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self> {
        match s.to_ascii_lowercase().as_str() {
            "unicode" => Ok(Self::Unicode),
            "ascii" => Ok(Self::Ascii),
            other => Err(Error::Config(format!(
                "unknown character_set '{other}'; expected 'unicode' or 'ascii'"
            ))),
        }
    }
}

impl fmt::Display for CharacterSet {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Unicode => f.write_str("unicode"),
            Self::Ascii => f.write_str("ascii"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NamingConfig {
    pub template: String,
    pub character_set: CharacterSet,
    pub max_component_len: usize,
}

impl Default for NamingConfig {
    fn default() -> Self {
        Self {
            template: DEFAULT_TEMPLATE.to_string(),
            character_set: CharacterSet::Unicode,
            max_component_len: DEFAULT_MAX_COMPONENT_LEN,
        }
    }
}

#[derive(Debug, Clone, Copy)]
pub struct NamingRequest<'a> {
    pub clip: &'a Clip,
    pub lineage: &'a LineageContext,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedName {
    pub relative_path: PathBuf,
    pub base_name: String,
}

pub fn render_clip_name(request: NamingRequest<'_>, config: &NamingConfig) -> RenderedName {
    let album = album_component(request, config);
    render_with_album(request, config, &album)
}

pub fn render_clip_names(
    requests: &[NamingRequest<'_>],
    config: &NamingConfig,
    colliding_albums: &BTreeSet<String>,
) -> Vec<RenderedName> {
    let albums = disambiguated_albums(requests, config, colliding_albums);
    let mut rendered = requests
        .iter()
        .zip(&albums)
        .map(|(request, album)| render_with_album(*request, config, album))
        .collect::<Vec<_>>();

    // Filename fallback: any distinct clips that still render to one path (a
    // custom template lacking `{id8}`) are separated by their full clip id.
    let mut collisions = BTreeMap::<String, Vec<usize>>::new();
    for (index, name) in rendered.iter().enumerate() {
        collisions
            .entry(name.relative_path.to_string_lossy().into_owned())
            .or_default()
            .push(index);
    }

    for indexes in collisions.into_values().filter(|indexes| indexes.len() > 1) {
        for index in indexes {
            let suffix = &requests[index].clip.id;
            rendered[index] =
                with_suffix(rendered[index].clone(), suffix, config.max_component_len);
        }
    }

    rendered
}

/// The album path component for every request, with a clip whose root title
/// collides across distinct roots disambiguated by `[{root_id8}]`.
///
/// Distinct roots must never share an album folder (two different upload roots
/// titled "Break Through" exist). `colliding_albums` is the authoritative set
/// of such shared root titles, computed once from the whole lineage store, so
/// the decision is stable across runs and independent of which clips appear in
/// this batch. A clip whose resolved album is in that set always gets its
/// root's short id appended; every other clip keeps the bare album and groups
/// with its same-root siblings.
fn disambiguated_albums(
    requests: &[NamingRequest<'_>],
    config: &NamingConfig,
    colliding_albums: &BTreeSet<String>,
) -> Vec<String> {
    requests
        .iter()
        .map(|request| album_for(*request, config, colliding_albums))
        .collect()
}

/// The (possibly disambiguated) album component for one request.
fn album_for(
    request: NamingRequest<'_>,
    config: &NamingConfig,
    colliding_albums: &BTreeSet<String>,
) -> String {
    let raw_album = request.lineage.album(&title_name(request.clip));
    let album = sanitise_component(&raw_album, config.character_set, config.max_component_len);
    if colliding_albums.contains(raw_album.trim()) {
        let suffix = truncate_chars(&request.lineage.root_id, 8);
        sanitise_component(
            &format!("{album} [{suffix}]"),
            config.character_set,
            config.max_component_len,
        )
    } else {
        album
    }
}

/// The sanitised album component: the resolved lineage album (root title, else
/// the clip's own title).
fn album_component(request: NamingRequest<'_>, config: &NamingConfig) -> String {
    let album = request.lineage.album(&title_name(request.clip));
    sanitise_component(&album, config.character_set, config.max_component_len)
}

/// Render one clip's path with an already-resolved album component.
fn render_with_album(
    request: NamingRequest<'_>,
    config: &NamingConfig,
    album: &str,
) -> RenderedName {
    let clip = request.clip;
    let creator = sanitise_component(
        &creator_name(clip),
        config.character_set,
        config.max_component_len,
    );
    let handle = sanitise_component(&clip.handle, config.character_set, config.max_component_len);
    let title = sanitise_component(
        &title_name(clip),
        config.character_set,
        config.max_component_len,
    );
    let id = sanitise_component(&clip.id, CharacterSet::Ascii, config.max_component_len);
    let id8 = sanitise_component(
        &truncate_chars(&clip.id, 8),
        CharacterSet::Ascii,
        config.max_component_len,
    );
    let root_id8 = sanitise_component(
        &truncate_chars(&request.lineage.root_id, 8),
        CharacterSet::Ascii,
        config.max_component_len,
    );
    let mut components = config
        .template
        .split('/')
        .filter_map(|segment| {
            let rendered = segment
                .replace("{creator}", &creator)
                .replace("{handle}", &handle)
                .replace("{album}", album)
                .replace("{title}", &title)
                .replace("{root_id8}", &root_id8)
                .replace("{id8}", &id8)
                .replace("{id}", &id);
            let sanitised =
                sanitise_component(&rendered, config.character_set, config.max_component_len);
            (!sanitised.is_empty()).then_some(sanitised)
        })
        .collect::<Vec<_>>();

    if components.is_empty() {
        components.push(title.clone());
    }

    let mut base_name = components
        .pop()
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| title.clone());
    // Guarantee a non-empty file name even when every token sanitises away.
    if base_name.is_empty() {
        base_name = append_suffix(&base_name, &clip.id, config.max_component_len);
    }

    let mut relative_path = PathBuf::new();
    for component in components {
        relative_path.push(component);
    }

    relative_path.push(&base_name);
    RenderedName {
        relative_path,
        base_name,
    }
}

fn with_suffix(mut rendered: RenderedName, suffix: &str, max_component_len: usize) -> RenderedName {
    rendered.base_name = append_suffix(&rendered.base_name, suffix, max_component_len);
    rendered.relative_path.set_file_name(&rendered.base_name);
    rendered
}

fn creator_name(clip: &Clip) -> String {
    non_blank(&clip.display_name)
        .or_else(|| non_blank(&clip.handle))
        .unwrap_or("Unknown Creator")
        .to_string()
}

fn title_name(clip: &Clip) -> String {
    let title = clip.title.trim();
    if title.is_empty() || title.eq_ignore_ascii_case("untitled") {
        "Untitled".to_string()
    } else {
        title.to_string()
    }
}

fn append_suffix(base: &str, suffix: &str, max_component_len: usize) -> String {
    let suffix_pattern = format!(" [{suffix}]");
    if base.ends_with(&suffix_pattern) {
        return sanitise_component(base, CharacterSet::Unicode, max_component_len);
    }

    let max_len =
        max_component_len.max(suffix_pattern.chars().count() + MIN_BASE_CHARS_WITH_SUFFIX);
    let allowed = max_len.saturating_sub(suffix_pattern.chars().count());
    let truncated = truncate_chars(base.trim_end(), allowed);
    let combined = format!("{truncated}{suffix_pattern}");
    sanitise_component(&combined, CharacterSet::Unicode, max_len)
}

/// Sanitise a free-form playlist name into a single safe path component.
///
/// Applies the same Unicode filtering and length cap as clip path components
/// (default [`CharacterSet::Unicode`], [`DEFAULT_MAX_COMPONENT_LEN`]), so a
/// playlist file name obeys the same filesystem rules as the rest of the
/// library. An empty or fully-stripped name falls back to `playlist` so the
/// caller always has a non-empty stem to append `.m3u8` to.
pub fn sanitise_name(name: &str) -> String {
    let cleaned = sanitise_component(name, CharacterSet::Unicode, DEFAULT_MAX_COMPONENT_LEN);
    if cleaned.is_empty() {
        "playlist".to_string()
    } else {
        cleaned
    }
}

fn sanitise_component(
    value: &str,
    character_set: CharacterSet,
    max_component_len: usize,
) -> String {
    let filtered = match character_set {
        CharacterSet::Unicode => value.chars().map(unicode_char).collect::<String>(),
        CharacterSet::Ascii => value.chars().flat_map(ascii_chars).collect::<String>(),
    };
    let collapsed = filtered.split_whitespace().collect::<Vec<_>>().join(" ");
    let trimmed = collapsed.trim_matches([' ', '.']);
    if trimmed.is_empty() {
        return String::new();
    }

    let mut result = truncate_chars(trimmed, max_component_len.max(1));
    result = result.trim_matches([' ', '.']).to_string();
    if result.is_empty() {
        return String::new();
    }
    if result == "." || result == ".." {
        return "item".to_string();
    }
    if !result.ends_with('_') && is_reserved_name(&result) {
        result.push('_');
    }
    result
}

fn unicode_char(ch: char) -> char {
    if matches!(
        ch,
        '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' | '\0'
    ) || ch.is_control()
    {
        ' '
    } else {
        ch
    }
}

fn ascii_chars(ch: char) -> Vec<char> {
    if ch.is_ascii() {
        return vec![unicode_char(ch)];
    }

    match ch {
        'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => vec!['A'],
        'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => vec!['a'],
        'Ç' => vec!['C'],
        'ç' => vec!['c'],
        'È' | 'É' | 'Ê' | 'Ë' => vec!['E'],
        'è' | 'é' | 'ê' | 'ë' => vec!['e'],
        'Ì' | 'Í' | 'Î' | 'Ï' => vec!['I'],
        'ì' | 'í' | 'î' | 'ï' => vec!['i'],
        'Ñ' => vec!['N'],
        'ñ' => vec!['n'],
        'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' | 'Ø' => vec!['O'],
        'ò' | 'ó' | 'ô' | 'õ' | 'ö' | 'ø' => vec!['o'],
        'Ù' | 'Ú' | 'Û' | 'Ü' => vec!['U'],
        'ù' | 'ú' | 'û' | 'ü' => vec!['u'],
        'Ý' | 'Ÿ' => vec!['Y'],
        'ý' | 'ÿ' => vec!['y'],
        'Æ' => vec!['A', 'E'],
        'æ' => vec!['a', 'e'],
        'Œ' => vec!['O', 'E'],
        'œ' => vec!['o', 'e'],
        'ß' => vec!['s', 's'],
        _ => vec![' '],
    }
}

fn truncate_chars(value: &str, max_len: usize) -> String {
    value.chars().take(max_len).collect()
}

fn non_blank(value: &str) -> Option<&str> {
    let trimmed = value.trim();
    (!trimmed.is_empty()).then_some(trimmed)
}

fn is_reserved_name(value: &str) -> bool {
    let stem = value.split('.').next().unwrap_or(value);
    matches!(
        stem.to_ascii_uppercase().as_str(),
        "CON"
            | "PRN"
            | "AUX"
            | "NUL"
            | "COM1"
            | "COM2"
            | "COM3"
            | "COM4"
            | "COM5"
            | "COM6"
            | "COM7"
            | "COM8"
            | "COM9"
            | "LPT1"
            | "LPT2"
            | "LPT3"
            | "LPT4"
            | "LPT5"
            | "LPT6"
            | "LPT7"
            | "LPT8"
            | "LPT9"
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::lineage::{EdgeType, ResolveStatus};
    use std::collections::{BTreeMap, BTreeSet};

    fn test_clip(id: &str, title: &str) -> Clip {
        Clip {
            id: id.to_string(),
            title: title.to_string(),
            display_name: "München".to_string(),
            handle: "munchen".to_string(),
            album_title: String::new(),
            root_ancestor_id: String::new(),
            ..Clip::default()
        }
    }

    fn render_own(clip: &Clip, config: &NamingConfig) -> RenderedName {
        let lineage = LineageContext::own_root(clip);
        render_clip_name(
            NamingRequest {
                clip,
                lineage: &lineage,
            },
            config,
        )
    }

    fn render_all_own(
        clips: &[Clip],
        config: &NamingConfig,
        colliding: &BTreeSet<String>,
    ) -> Vec<RenderedName> {
        let lineages: Vec<LineageContext> = clips.iter().map(LineageContext::own_root).collect();
        let requests: Vec<NamingRequest> = clips
            .iter()
            .zip(&lineages)
            .map(|(clip, lineage)| NamingRequest { clip, lineage })
            .collect();
        render_clip_names(&requests, config, colliding)
    }

    #[test]
    fn unicode_names_are_preserved_and_ascii_falls_back() {
        let clip = test_clip("abc12345", "Beyoncé/東京");

        let unicode = render_own(&clip, &NamingConfig::default());
        assert_eq!(
            unicode.relative_path.to_string_lossy(),
            "München/Beyoncé 東京/München-Beyoncé 東京 [abc12345]"
        );

        let ascii = render_own(
            &clip,
            &NamingConfig {
                character_set: CharacterSet::Ascii,
                ..NamingConfig::default()
            },
        );
        assert_eq!(
            ascii.relative_path.to_string_lossy(),
            "Munchen/Beyonce/Munchen-Beyonce [abc12345]"
        );
    }

    #[test]
    fn reserved_and_hostile_names_are_sanitised() {
        let clip = Clip {
            id: "deadbeef".to_string(),
            title: "CON<>:\"/\\|?*.".to_string(),
            display_name: "AUX".to_string(),
            ..Clip::default()
        };

        let rendered = render_own(&clip, &NamingConfig::default());
        let path = rendered.relative_path.to_string_lossy();
        assert!(path.starts_with("AUX_/CON_/"), "path was {path}");
        assert!(rendered.base_name.contains("[deadbeef]"));
    }

    #[test]
    fn default_template_always_embeds_id8() {
        let clip = test_clip("abcdef1234567890", "Any Title");
        let rendered = render_own(&clip, &NamingConfig::default());
        assert!(
            rendered.base_name.contains("[abcdef12]"),
            "base_name was {}",
            rendered.base_name
        );
    }

    #[test]
    fn blank_titles_use_a_stable_suffix() {
        let clip = test_clip("12345678-clip", "   ");

        let rendered = render_own(&clip, &NamingConfig::default());
        assert_eq!(rendered.base_name, "München-Untitled [12345678]");
        assert_eq!(
            rendered.relative_path.to_string_lossy(),
            "München/Untitled/München-Untitled [12345678]"
        );
    }

    #[test]
    fn very_long_titles_are_trimmed() {
        let clip = test_clip("abcdef12", &"a".repeat(120));
        let rendered = render_own(
            &clip,
            &NamingConfig {
                max_component_len: 24,
                ..NamingConfig::default()
            },
        );

        for component in rendered.relative_path.components() {
            let text = component.as_os_str().to_string_lossy();
            assert!(
                text.chars().count() <= 24,
                "component {text:?} exceeds 24 chars"
            );
        }
    }

    #[test]
    fn same_title_siblings_stay_distinct_via_id8() {
        // Two clips sharing a root (same album folder) and the same title must
        // still land on distinct files; the default template's {id8} does that.
        let lineage = LineageContext {
            root_id: "root-9".to_string(),
            root_title: "Origin".to_string(),
            parent_id: "root-9".to_string(),
            edge_type: Some(EdgeType::Cover),
            status: ResolveStatus::Resolved,
        };
        let first = test_clip("11111111-alpha", "Shared");
        let second = test_clip("22222222-beta", "Shared");
        let requests = [
            NamingRequest {
                clip: &first,
                lineage: &lineage,
            },
            NamingRequest {
                clip: &second,
                lineage: &lineage,
            },
        ];

        let names = render_clip_names(&requests, &NamingConfig::default(), &BTreeSet::new());

        assert_eq!(
            names[0].relative_path.to_string_lossy(),
            "München/Origin/München-Shared [11111111]"
        );
        assert_eq!(
            names[1].relative_path.to_string_lossy(),
            "München/Origin/München-Shared [22222222]"
        );
    }

    #[test]
    fn id8_prefix_collision_falls_back_to_full_id() {
        // Custom template without {id8} so identical titles collide and the
        // filename fallback (full id) has to keep them distinct.
        let config = NamingConfig {
            template: "{creator}/{title}".to_string(),
            ..NamingConfig::default()
        };
        let first = test_clip("abcd1234-first", "Untitled");
        let second = test_clip("abcd1234-second", "Untitled");

        let names = render_all_own(&[first.clone(), second.clone()], &config, &BTreeSet::new());
        let swapped = render_all_own(&[second.clone(), first.clone()], &config, &BTreeSet::new());

        assert_ne!(
            names[0].relative_path.to_string_lossy(),
            names[1].relative_path.to_string_lossy()
        );

        let ordered = |rendered: &[RenderedName], clips: &[Clip]| {
            clips
                .iter()
                .zip(rendered)
                .map(|(clip, name)| {
                    (
                        clip.id.clone(),
                        name.relative_path.to_string_lossy().into_owned(),
                    )
                })
                .collect::<BTreeMap<_, _>>()
        };
        assert_eq!(
            ordered(&names, &[first.clone(), second.clone()]),
            ordered(&swapped, &[second, first])
        );
    }

    #[test]
    fn album_is_root_title_for_a_remix() {
        let clip = Clip {
            id: "child".to_string(),
            title: "Remix".to_string(),
            display_name: "München".to_string(),
            ..Clip::default()
        };
        let lineage = LineageContext {
            root_id: "root-1".to_string(),
            root_title: "Original".to_string(),
            parent_id: "root-1".to_string(),
            edge_type: Some(EdgeType::Cover),
            status: ResolveStatus::Resolved,
        };

        let rendered = render_clip_name(
            NamingRequest {
                clip: &clip,
                lineage: &lineage,
            },
            &NamingConfig::default(),
        );
        assert_eq!(
            rendered.relative_path.to_string_lossy(),
            "München/Original/München-Remix [child]"
        );
    }

    #[test]
    fn album_is_own_title_for_a_root() {
        let clip = Clip {
            id: "root-1".to_string(),
            title: "Original".to_string(),
            display_name: "München".to_string(),
            ..Clip::default()
        };

        let rendered = render_own(&clip, &NamingConfig::default());
        assert_eq!(
            rendered.relative_path.to_string_lossy(),
            "München/Original/München-Original [root-1]"
        );
    }

    #[test]
    fn shared_album_title_from_distinct_roots_is_disambiguated() {
        let first = Clip {
            id: "aaaa1111-x".to_string(),
            title: "Break Through".to_string(),
            display_name: "München".to_string(),
            ..Clip::default()
        };
        let second = Clip {
            id: "bbbb2222-y".to_string(),
            title: "Break Through".to_string(),
            display_name: "München".to_string(),
            ..Clip::default()
        };

        // The colliding set is authoritative (store-driven), so disambiguation
        // does not depend on both roots appearing in the same batch.
        let colliding: BTreeSet<String> = ["Break Through".to_string()].into_iter().collect();
        let names = render_all_own(
            &[first.clone(), second.clone()],
            &NamingConfig::default(),
            &colliding,
        );
        let swapped = render_all_own(
            &[second.clone(), first.clone()],
            &NamingConfig::default(),
            &colliding,
        );

        let album_of = |rendered: &RenderedName| {
            rendered
                .relative_path
                .components()
                .nth(1)
                .map(|component| component.as_os_str().to_string_lossy().into_owned())
                .unwrap_or_default()
        };

        assert_eq!(album_of(&names[0]), "Break Through [aaaa1111]");
        assert_eq!(album_of(&names[1]), "Break Through [bbbb2222]");
        // Deterministic regardless of input order.
        assert_eq!(album_of(&swapped[0]), "Break Through [bbbb2222]");
        assert_eq!(album_of(&swapped[1]), "Break Through [aaaa1111]");

        // The MEDIUM fix: a narrowed run showing only one of the two roots
        // still gets the suffixed folder, so folders never oscillate.
        let alone = render_all_own(
            std::slice::from_ref(&first),
            &NamingConfig::default(),
            &colliding,
        );
        assert_eq!(album_of(&alone[0]), "Break Through [aaaa1111]");
    }

    #[test]
    fn unique_root_title_stays_a_bare_album() {
        // A title absent from the colliding set keeps its bare folder even when
        // the batch happens to hold a same-titled sibling of the same root.
        let clip = Clip {
            id: "solo-1".to_string(),
            title: "Solo".to_string(),
            display_name: "München".to_string(),
            ..Clip::default()
        };
        let names = render_all_own(&[clip], &NamingConfig::default(), &BTreeSet::new());
        assert_eq!(
            names[0].relative_path.to_string_lossy(),
            "München/Solo/München-Solo [solo-1]"
        );
    }

    #[test]
    fn sanitise_name_strips_separators_and_falls_back_when_empty() {
        assert_eq!(sanitise_name("Road/Trip: 2024"), "Road Trip 2024");
        assert_eq!(sanitise_name(""), "playlist");
        // A name made only of illegal characters strips to nothing, so the
        // caller still gets a usable, non-empty stem.
        assert_eq!(sanitise_name("///"), "playlist");
    }
}
