pub fn infer_key(title: &str) -> String {
    use SemesterSeason::*;

    let season = detect_season(title);
    let numbers = extract_numbers(title);

    match season {
        Winter => {
            let first = numbers
                .get(0)
                .map(|value| last_two_digits(value))
                .unwrap_or_else(|| "00".into());
            let second = numbers
                .get(1)
                .map(|value| last_two_digits(value))
                .unwrap_or_else(|| {
                    // winter spans two years; if only one provided, assume +1
                    numbers
                        .get(0)
                        .map(|n| increment_two_digits(n))
                        .unwrap_or_else(|| "00".into())
                });
            format!("ws{first}{second}")
        }
        Summer => {
            let year = numbers
                .get(0)
                .map(|value| last_two_digits(value))
                .unwrap_or_else(|| "00".into());
            format!("ss{year}")
        }
        Unknown => fallback_key(title),
    }
}

#[derive(Copy, Clone, Debug)]
enum SemesterSeason {
    Winter,
    Summer,
    Unknown,
}

fn detect_season(title: &str) -> SemesterSeason {
    let lower = title.to_ascii_lowercase();
    if lower.contains("wise")
        || lower.contains("winter")
        || lower.contains("ws ")
        || lower.starts_with("ws")
    {
        SemesterSeason::Winter
    } else if lower.contains("sose")
        || lower.contains("sommer")
        || lower.contains("summer")
        || lower.contains("ss ")
        || lower.starts_with("ss")
    {
        SemesterSeason::Summer
    } else {
        SemesterSeason::Unknown
    }
}

fn extract_numbers(title: &str) -> Vec<String> {
    let mut numbers = Vec::new();
    let mut current = String::new();
    for ch in title.chars() {
        if ch.is_ascii_digit() {
            current.push(ch);
        } else if !current.is_empty() {
            numbers.push(current.clone());
            current.clear();
        }
    }
    if !current.is_empty() {
        numbers.push(current);
    }
    numbers
}

fn last_two_digits(value: &str) -> String {
    if value.len() <= 2 {
        format!("{:0>2}", value)
    } else {
        value[value.len() - 2..].to_string()
    }
}

fn increment_two_digits(value: &str) -> String {
    let last_two = last_two_digits(value);
    let parsed = last_two.parse::<u8>().unwrap_or(0);
    format!("{:02}", (parsed + 1) % 100)
}

fn fallback_key(title: &str) -> String {
    let mut key = String::from("sem-");
    for ch in title.chars() {
        if ch.is_ascii_alphanumeric() {
            key.push(ch.to_ascii_lowercase());
        } else if ch.is_whitespace() && !key.ends_with('-') {
            key.push('-');
        }
        if key.len() >= 32 {
            break;
        }
    }
    key.trim_end_matches('-').to_string()
}
