use camino::Utf8PathBuf;
use langcodec::{
    error::Error as LangcodecError,
    formats::{
        strings::Format as StringsFormat,
        xcstrings::{
            Format as XcstringsFormat, Item as XcstringsItem, Localization as XcstringsLocalization,
        },
    },
    traits::Parser,
};
use numi_diagnostics::{Diagnostic, Severity};
use numi_ir::{EntryKind, Metadata, ModuleKind, RawEntry};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
    collections::BTreeMap,
    fs, io,
    path::{Path, PathBuf},
};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LocalizationTable {
    pub table_name: String,
    pub source_path: Utf8PathBuf,
    pub module_kind: ModuleKind,
    pub entries: Vec<RawEntry>,
    pub warnings: Vec<Diagnostic>,
}

#[derive(Debug)]
pub enum ParseL10nError {
    ReadDirectory { path: PathBuf, source: io::Error },
    ReadFile { path: PathBuf, source: io::Error },
    InvalidPath { path: PathBuf },
    InvalidUtf8Path { path: PathBuf },
    ParseFile { path: PathBuf, message: String },
}

impl std::fmt::Display for ParseL10nError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ReadDirectory { path, source } => {
                write!(
                    f,
                    "failed to read localization directory {}: {source}",
                    path.display()
                )
            }
            Self::ReadFile { path, source } => {
                write!(
                    f,
                    "failed to read localization file {}: {source}",
                    path.display()
                )
            }
            Self::InvalidPath { path } => write!(
                f,
                "localization input {} must be a `.strings` or `.xcstrings` file or a directory containing supported localization files",
                path.display()
            ),
            Self::InvalidUtf8Path { path } => write!(
                f,
                "localization path {} is not valid UTF-8 and cannot be represented in the IR",
                path.display()
            ),
            Self::ParseFile { path, message } => {
                write!(
                    f,
                    "failed to parse localization file {}: {message}",
                    path.display()
                )
            }
        }
    }
}

impl std::error::Error for ParseL10nError {}

pub fn parse_strings(path: &Path) -> Result<Vec<LocalizationTable>, ParseL10nError> {
    parse_localization(path, "strings", parse_strings_file)
}

pub fn parse_xcstrings(path: &Path) -> Result<Vec<LocalizationTable>, ParseL10nError> {
    parse_localization(path, "xcstrings", parse_xcstrings_file)
}

fn parse_localization(
    path: &Path,
    extension: &str,
    parse_file: fn(&Path) -> Result<LocalizationTable, ParseL10nError>,
) -> Result<Vec<LocalizationTable>, ParseL10nError> {
    if path.is_file() {
        if path.extension().and_then(|ext| ext.to_str()) != Some(extension) {
            return Err(ParseL10nError::InvalidPath {
                path: path.to_path_buf(),
            });
        }
        return parse_file(path).map(|table| vec![table]);
    }

    if path.is_dir() {
        let mut files = Vec::new();
        collect_localization_files(path, extension, &mut files)?;
        files.sort();

        return files.into_iter().map(|file| parse_file(&file)).collect();
    }

    Err(ParseL10nError::InvalidPath {
        path: path.to_path_buf(),
    })
}

fn collect_localization_files(
    directory: &Path,
    extension: &str,
    files: &mut Vec<PathBuf>,
) -> Result<(), ParseL10nError> {
    let read_dir = fs::read_dir(directory).map_err(|source| ParseL10nError::ReadDirectory {
        path: directory.to_path_buf(),
        source,
    })?;

    for entry in read_dir {
        let entry = entry.map_err(|source| ParseL10nError::ReadDirectory {
            path: directory.to_path_buf(),
            source,
        })?;
        let path = entry.path();
        let file_type = entry
            .file_type()
            .map_err(|source| ParseL10nError::ReadDirectory {
                path: path.clone(),
                source,
            })?;

        if file_type.is_dir() {
            collect_localization_files(&path, extension, files)?;
            continue;
        }

        if path.extension().and_then(|ext| ext.to_str()) == Some(extension) {
            files.push(path);
        }
    }

    Ok(())
}

fn parse_strings_file(path: &Path) -> Result<LocalizationTable, ParseL10nError> {
    if path.extension().and_then(|ext| ext.to_str()) != Some("strings") {
        return Err(ParseL10nError::InvalidPath {
            path: path.to_path_buf(),
        });
    }

    let strings =
        StringsFormat::read_from(path).map_err(|error| map_langcodec_error(path, error))?;
    let table_name = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .ok_or_else(|| ParseL10nError::InvalidUtf8Path {
            path: path.to_path_buf(),
        })?
        .to_owned();

    let source_path = Utf8PathBuf::from_path_buf(path.to_path_buf())
        .map_err(|path| ParseL10nError::InvalidUtf8Path { path })?;
    let mut pairs = Vec::<(String, Metadata)>::new();
    for pair in strings.pairs {
        let key = decode_strings_translation_escapes(path, &pair.key)?;
        let translation = decode_strings_translation_escapes(path, &pair.value)?;
        let mut properties = Metadata::from([
            ("key".to_string(), Value::String(key.clone())),
            (
                "translation".to_string(),
                Value::String(translation.clone()),
            ),
        ]);
        if let Some(placeholders) = build_strings_placeholder_metadata(&translation) {
            properties.insert("placeholders".to_string(), Value::Array(placeholders));
        }
        pairs.push((key, properties));
    }

    let mut seen = std::collections::HashSet::new();
    let mut deduped = Vec::new();
    for (key, properties) in pairs.into_iter().rev() {
        if seen.insert(key.clone()) {
            deduped.push((key, properties));
        }
    }
    deduped.reverse();

    let entries = deduped
        .into_iter()
        .map(|(key, properties)| RawEntry {
            path: key,
            source_path: source_path.clone(),
            kind: EntryKind::StringKey,
            properties,
        })
        .collect::<Vec<_>>();

    Ok(LocalizationTable {
        table_name,
        source_path,
        module_kind: ModuleKind::Strings,
        entries,
        warnings: Vec::new(),
    })
}

fn parse_xcstrings_file(path: &Path) -> Result<LocalizationTable, ParseL10nError> {
    if path.extension().and_then(|ext| ext.to_str()) != Some("xcstrings") {
        return Err(ParseL10nError::InvalidPath {
            path: path.to_path_buf(),
        });
    }

    let xcstrings =
        XcstringsFormat::read_from(path).map_err(|error| map_langcodec_error(path, error))?;
    let table_name = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .ok_or_else(|| ParseL10nError::InvalidUtf8Path {
            path: path.to_path_buf(),
        })?
        .to_owned();

    let source_path = Utf8PathBuf::from_path_buf(path.to_path_buf())
        .map_err(|path| ParseL10nError::InvalidUtf8Path { path })?;
    let adapter_metadata =
        parse_xcstrings_adapter_metadata(path, xcstrings.source_language.as_str())?;

    let mut entries = Vec::new();
    let mut warnings = Vec::new();

    for (key, item) in xcstrings.strings {
        if key.is_empty() {
            warnings.push(xcstrings_warning(path, &key, "has an empty key"));
            continue;
        }

        let Some(localization) = select_localization(&item, xcstrings.source_language.as_str())
        else {
            warnings.push(xcstrings_warning(
                path,
                &key,
                "does not contain a supported string unit",
            ));
            continue;
        };

        if let Some(reason) = adapter_metadata
            .get(&key)
            .and_then(|metadata| metadata.variation_reason)
            .or_else(|| unsupported_variation_reason(localization))
        {
            warnings.push(xcstrings_warning(path, &key, reason));
            continue;
        }

        let Some(string_unit) = localization.string_unit.as_ref() else {
            warnings.push(xcstrings_warning(
                path,
                &key,
                "does not contain a string unit",
            ));
            continue;
        };
        let translation = string_unit.value.clone();

        let mut properties = Metadata::from([
            ("key".to_string(), Value::String(key.clone())),
            (
                "translation".to_string(),
                Value::String(translation.clone()),
            ),
        ]);

        if let Some(placeholders) = adapter_metadata
            .get(&key)
            .and_then(|metadata| build_placeholder_metadata(&metadata.placeholder_specs))
            .or_else(|| build_strings_placeholder_metadata(&translation))
        {
            properties.insert("placeholders".to_string(), Value::Array(placeholders));
        }

        entries.push(RawEntry {
            path: key.clone(),
            source_path: source_path.clone(),
            kind: EntryKind::StringKey,
            properties,
        });
    }

    entries.sort_by(|left, right| left.path.cmp(&right.path));
    warnings.sort_by(|left, right| left.message.cmp(&right.message));

    Ok(LocalizationTable {
        table_name,
        source_path,
        module_kind: ModuleKind::Xcstrings,
        entries,
        warnings,
    })
}

fn map_langcodec_error(path: &Path, error: LangcodecError) -> ParseL10nError {
    match error {
        LangcodecError::Io(source) => ParseL10nError::ReadFile {
            path: path.to_path_buf(),
            source,
        },
        other => ParseL10nError::ParseFile {
            path: path.to_path_buf(),
            message: other.to_string(),
        },
    }
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct PlaceholderMetadataSpec {
    format_specifier: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct XcstringsEntryMetadata {
    placeholder_specs: BTreeMap<String, PlaceholderMetadataSpec>,
    variation_reason: Option<&'static str>,
}

fn parse_xcstrings_adapter_metadata(
    path: &Path,
    source_language: &str,
) -> Result<BTreeMap<String, XcstringsEntryMetadata>, ParseL10nError> {
    let bytes = fs::read(path).map_err(|source| ParseL10nError::ReadFile {
        path: path.to_path_buf(),
        source,
    })?;
    let contents = decode_strings_bytes(&bytes, path)?;
    let root: Value =
        serde_json::from_str(&contents).map_err(|error| ParseL10nError::ParseFile {
            path: path.to_path_buf(),
            message: format!("invalid JSON: {error}"),
        })?;

    let Some(strings) = root.get("strings").and_then(Value::as_object) else {
        return Ok(BTreeMap::new());
    };

    let mut metadata_by_key = BTreeMap::new();
    for (key, record) in strings {
        let Some(localizations) = record.get("localizations").and_then(Value::as_object) else {
            continue;
        };
        let Some(localization) = select_localization_value(localizations, source_language) else {
            continue;
        };

        let mut metadata = XcstringsEntryMetadata {
            variation_reason: unsupported_variation_reason_from_value(localization),
            ..XcstringsEntryMetadata::default()
        };

        if let Some(substitutions) = localization.get("substitutions").and_then(Value::as_object) {
            for (name, substitution) in substitutions {
                let spec = PlaceholderMetadataSpec {
                    format_specifier: substitution
                        .get("formatSpecifier")
                        .and_then(Value::as_str)
                        .map(ToString::to_string),
                };
                metadata.placeholder_specs.insert(name.clone(), spec);

                if metadata.variation_reason.is_none() {
                    metadata.variation_reason = substitution
                        .get("variations")
                        .and_then(unsupported_variation_reason_from_variations_value);
                }
            }
        }

        if !metadata.placeholder_specs.is_empty() || metadata.variation_reason.is_some() {
            metadata_by_key.insert(key.clone(), metadata);
        }
    }

    Ok(metadata_by_key)
}

fn select_localization<'a>(
    item: &'a XcstringsItem,
    source_language: &str,
) -> Option<&'a XcstringsLocalization> {
    item.localizations.get(source_language).or_else(|| {
        item.localizations
            .iter()
            .min_by(|(left, _), (right, _)| left.cmp(right))
            .map(|(_, localization)| localization)
    })
}

fn select_localization_value<'a>(
    localizations: &'a serde_json::Map<String, Value>,
    source_language: &str,
) -> Option<&'a Value> {
    localizations.get(source_language).or_else(|| {
        localizations
            .iter()
            .min_by(|(left, _), (right, _)| left.cmp(right))
            .map(|(_, localization)| localization)
    })
}

fn decode_strings_bytes(bytes: &[u8], path: &Path) -> Result<String, ParseL10nError> {
    if let Some(stripped) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
        return String::from_utf8(stripped.to_vec()).map_err(|error| ParseL10nError::ParseFile {
            path: path.to_path_buf(),
            message: format!("invalid UTF-8 after BOM: {error}"),
        });
    }

    if let Some(stripped) = bytes.strip_prefix(&[0xFF, 0xFE]) {
        return decode_utf16_units(stripped, path, u16::from_le_bytes);
    }

    if let Some(stripped) = bytes.strip_prefix(&[0xFE, 0xFF]) {
        return decode_utf16_units(stripped, path, u16::from_be_bytes);
    }

    String::from_utf8(bytes.to_vec()).map_err(|error| ParseL10nError::ParseFile {
        path: path.to_path_buf(),
        message: format!("invalid UTF-8: {error}"),
    })
}

fn decode_utf16_units(
    bytes: &[u8],
    path: &Path,
    decode_unit: fn([u8; 2]) -> u16,
) -> Result<String, ParseL10nError> {
    if !bytes.len().is_multiple_of(2) {
        return Err(ParseL10nError::ParseFile {
            path: path.to_path_buf(),
            message: "UTF-16 file has an odd number of bytes".to_string(),
        });
    }

    let units = bytes
        .chunks_exact(2)
        .map(|chunk| decode_unit([chunk[0], chunk[1]]))
        .collect::<Vec<_>>();

    String::from_utf16(&units).map_err(|error| ParseL10nError::ParseFile {
        path: path.to_path_buf(),
        message: format!("invalid UTF-16: {error}"),
    })
}

fn decode_strings_translation_escapes(path: &Path, value: &str) -> Result<String, ParseL10nError> {
    let mut output = String::with_capacity(value.len());
    let mut chars = value.chars().peekable();

    while let Some(ch) = chars.next() {
        if ch != '\\' {
            output.push(ch);
            continue;
        }

        let escaped = chars.next().ok_or_else(|| ParseL10nError::ParseFile {
            path: path.to_path_buf(),
            message: "incomplete escape sequence".to_string(),
        })?;

        let decoded = match escaped {
            '"' => '"',
            '\'' => '\'',
            '\\' => '\\',
            '/' => '/',
            'n' => '\n',
            'r' => '\r',
            't' => '\t',
            'U' => {
                let mut hex = String::with_capacity(4);
                for _ in 0..4 {
                    let digit = chars.next().ok_or_else(|| ParseL10nError::ParseFile {
                        path: path.to_path_buf(),
                        message: "unexpected end of input".to_string(),
                    })?;
                    if !digit.is_ascii_hexdigit() {
                        return Err(ParseL10nError::ParseFile {
                            path: path.to_path_buf(),
                            message: "invalid unicode escape".to_string(),
                        });
                    }
                    hex.push(digit);
                }
                let scalar =
                    u32::from_str_radix(&hex, 16).map_err(|_| ParseL10nError::ParseFile {
                        path: path.to_path_buf(),
                        message: "invalid unicode escape".to_string(),
                    })?;
                char::from_u32(scalar).ok_or_else(|| ParseL10nError::ParseFile {
                    path: path.to_path_buf(),
                    message: "invalid unicode scalar".to_string(),
                })?
            }
            other => {
                return Err(ParseL10nError::ParseFile {
                    path: path.to_path_buf(),
                    message: format!("unsupported escape `\\{other}`"),
                });
            }
        };
        output.push(decoded);
    }

    Ok(output)
}

fn build_placeholder_metadata(
    substitutions: &BTreeMap<String, PlaceholderMetadataSpec>,
) -> Option<Vec<Value>> {
    if substitutions.is_empty() {
        return None;
    }

    let mut placeholders = Vec::with_capacity(substitutions.len());

    for (name, placeholder_spec) in substitutions {
        let mut placeholder = Metadata::new();
        placeholder.insert("name".to_string(), Value::String(name.clone()));

        if let Some(format_specifier) = placeholder_spec.format_specifier.as_ref() {
            placeholder.insert(
                "format".to_string(),
                Value::String(format_specifier.clone()),
            );

            if let Some(swift_type) = infer_swift_type(format_specifier) {
                placeholder.insert(
                    "swiftType".to_string(),
                    Value::String(swift_type.to_string()),
                );
            }
        }

        placeholders.push(Value::Object(placeholder.into_iter().collect()));
    }

    Some(placeholders)
}

fn build_strings_placeholder_metadata(translation: &str) -> Option<Vec<Value>> {
    let placeholders = extract_printf_placeholders(translation);
    if placeholders.is_empty() {
        return None;
    }

    Some(
        placeholders
            .into_iter()
            .map(|format| {
                let mut placeholder = Metadata::new();
                placeholder.insert("format".to_string(), Value::String(format.clone()));
                if let Some(swift_type) = infer_swift_type(&format) {
                    placeholder.insert(
                        "swiftType".to_string(),
                        Value::String(swift_type.to_string()),
                    );
                }
                Value::Object(placeholder.into_iter().collect())
            })
            .collect(),
    )
}

fn extract_printf_placeholders(translation: &str) -> Vec<String> {
    let chars = translation.chars().collect::<Vec<_>>();
    let mut placeholders = Vec::new();
    let mut positional = BTreeMap::<usize, String>::new();
    let mut saw_positional = false;
    let mut index = 0;

    while index < chars.len() {
        if chars[index] != '%' {
            index += 1;
            continue;
        }

        if chars.get(index + 1) == Some(&'%') {
            index += 2;
            continue;
        }

        let mut cursor = index + 1;
        let position_start = cursor;
        while chars.get(cursor).is_some_and(|ch| ch.is_ascii_digit()) {
            cursor += 1;
        }
        let position = if cursor > position_start && chars.get(cursor) == Some(&'$') {
            saw_positional = true;
            let position = chars[position_start..cursor]
                .iter()
                .collect::<String>()
                .parse::<usize>()
                .ok();
            cursor += 1;
            position
        } else {
            cursor = index + 1;
            None
        };

        while chars
            .get(cursor)
            .is_some_and(|ch| matches!(ch, '-' | '+' | ' ' | '#' | '0' | '\''))
        {
            cursor += 1;
        }

        if chars.get(cursor) == Some(&'*') {
            cursor += 1;
        } else {
            while chars.get(cursor).is_some_and(|ch| ch.is_ascii_digit()) {
                cursor += 1;
            }
        }

        if chars.get(cursor) == Some(&'.') {
            cursor += 1;
            if chars.get(cursor) == Some(&'*') {
                cursor += 1;
            } else {
                while chars.get(cursor).is_some_and(|ch| ch.is_ascii_digit()) {
                    cursor += 1;
                }
            }
        }

        let modifier_start = cursor;
        if matches!(
            (chars.get(cursor), chars.get(cursor + 1)),
            (Some('h' | 'l'), Some('h' | 'l'))
        ) && chars.get(cursor) == chars.get(cursor + 1)
        {
            cursor += 2;
        } else if chars
            .get(cursor)
            .is_some_and(|ch| matches!(ch, 'h' | 'l' | 'L' | 'q' | 'z' | 't' | 'j'))
        {
            cursor += 1;
        }

        let Some(kind) = chars
            .get(cursor)
            .copied()
            .filter(|ch| ch.is_ascii_alphabetic() || *ch == '@')
        else {
            index += 1;
            continue;
        };

        let mut format = chars[modifier_start..cursor].iter().collect::<String>();
        format.push(kind);

        if let Some(position) = position {
            positional.entry(position).or_insert(format);
        } else {
            placeholders.push(format);
        }

        index = cursor + 1;
    }

    if saw_positional {
        positional.into_values().collect()
    } else {
        placeholders
    }
}

fn infer_swift_type(format_specifier: &str) -> Option<&'static str> {
    let kind = format_specifier
        .strip_prefix('%')
        .unwrap_or(format_specifier)
        .chars()
        .rev()
        .find(|ch| ch.is_ascii_alphabetic() || *ch == '@')?;

    match kind {
        '@' => Some("String"),
        'd' | 'i' | 'u' | 'o' | 'x' | 'X' => Some("Int"),
        'f' | 'F' | 'e' | 'E' | 'g' | 'G' | 'a' | 'A' => Some("Double"),
        'p' => Some("UnsafeRawPointer"),
        _ => None,
    }
}

fn xcstrings_warning(path: &Path, key: &str, reason: &str) -> Diagnostic {
    Diagnostic {
        severity: Severity::Warning,
        message: format!("skipping xcstrings key `{key}`: {reason}"),
        hint: None,
        job: None,
        path: Some(path.to_path_buf()),
    }
}

fn unsupported_variation_reason(localization: &XcstringsLocalization) -> Option<&'static str> {
    if localization
        .variations
        .as_ref()
        .and_then(|variations| variations.plural.as_ref())
        .is_some_and(|plural| !plural.is_empty())
    {
        Some("unsupported plural variations")
    } else {
        None
    }
}

fn unsupported_variation_reason_from_value(localization: &Value) -> Option<&'static str> {
    let localization = localization.as_object()?;
    if let Some(reason) = localization
        .get("variations")
        .and_then(unsupported_variation_reason_from_variations_value)
    {
        return Some(reason);
    }

    let substitutions = localization.get("substitutions")?.as_object()?;
    for substitution in substitutions.values() {
        if let Some(reason) = substitution
            .get("variations")
            .and_then(unsupported_variation_reason_from_variations_value)
        {
            return Some(reason);
        }
    }

    None
}

fn unsupported_variation_reason_from_variations_value(variations: &Value) -> Option<&'static str> {
    match variations {
        Value::Object(object) => {
            if object.is_empty() {
                return None;
            }

            if object
                .get("plural")
                .is_some_and(|value| !is_empty_variation_value(value))
            {
                return Some("unsupported plural variations");
            }

            if object
                .get("device")
                .is_some_and(|value| !is_empty_variation_value(value))
            {
                return Some("unsupported device-specific variations");
            }

            if object
                .values()
                .any(|value| !is_empty_variation_value(value))
            {
                return Some("unsupported variation tree");
            }

            None
        }
        Value::Array(array) => {
            if array.is_empty() {
                None
            } else {
                Some("unsupported variation tree")
            }
        }
        Value::Null => None,
        _ => Some("unsupported variation tree"),
    }
}

fn is_empty_variation_value(value: &Value) -> bool {
    match value {
        Value::Object(object) => object.values().all(is_empty_variation_value),
        Value::Array(array) => array.iter().all(is_empty_variation_value),
        Value::Null => true,
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::time::{SystemTime, UNIX_EPOCH};

    struct ScopedTempDir {
        path: PathBuf,
    }

    impl ScopedTempDir {
        fn new(test_name: &str) -> Self {
            let path = make_temp_dir(test_name);
            Self { path }
        }

        fn path(&self) -> &Path {
            &self.path
        }
    }

    impl Drop for ScopedTempDir {
        fn drop(&mut self) {
            let result = fs::remove_dir_all(&self.path);
            if std::thread::panicking() {
                let _ = result;
            } else {
                result.expect("temp dir should be removed");
            }
        }
    }

    fn make_temp_dir(test_name: &str) -> PathBuf {
        let unique = format!(
            "numi-{test_name}-{}-{}",
            std::process::id(),
            SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("clock should be after epoch")
                .as_nanos()
        );
        let path = std::env::temp_dir().join(unique);
        fs::create_dir_all(&path).expect("temp dir should be created");
        path
    }

    #[test]
    fn parses_strings_files_from_directory() {
        let temp_dir = make_temp_dir("parse-strings");
        let localization_dir = temp_dir.join("Resources/Localization");
        fs::create_dir_all(&localization_dir).expect("localization dir should exist");
        let strings_path = localization_dir.join("Localizable.strings");
        fs::write(
            &strings_path,
            "\"profile.title\" = \"Profile\";\n\"settings.logout\" = \"Log Out\";\n",
        )
        .expect("strings file should be written");

        let tables = parse_strings(&localization_dir).expect("strings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].table_name, "Localizable");
        assert_eq!(
            tables[0].source_path,
            Utf8PathBuf::from_path_buf(strings_path.clone()).expect("utf8 path")
        );
        assert_eq!(tables[0].entries.len(), 2);
        assert_eq!(tables[0].entries[0].path, "profile.title");
        assert_eq!(tables[0].entries[0].kind, EntryKind::StringKey);
        assert_eq!(
            tables[0].entries[0].properties["translation"],
            Value::String("Profile".to_string())
        );

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn parses_comments_and_escapes() {
        let temp_dir = make_temp_dir("parse-comments");
        let strings_path = temp_dir.join("Localizable.strings");
        fs::write(
            &strings_path,
            "// line comment\n/* block comment */\n\"escaped\" = \"Quote: \\\"hi\\\"\\nDone\";\n",
        )
        .expect("strings file should be written");

        let tables = parse_strings(&strings_path).expect("strings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(
            tables[0].entries[0].properties["translation"],
            Value::String("Quote: \"hi\"\nDone".to_string())
        );

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn parses_escaped_apostrophe_in_strings() {
        let temp_dir = ScopedTempDir::new("parse-escaped-apostrophe");
        let strings_path = temp_dir.path().join("Localizable.strings");
        fs::write(
            &strings_path,
            "\"invite.key\" = \"Can\\'t accept the invitation\";\n",
        )
        .expect("strings file should be written");

        let tables = parse_strings(&strings_path).expect("strings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(
            tables[0].entries[0].properties["translation"],
            Value::String("Can't accept the invitation".to_string())
        );
    }

    #[test]
    fn parses_escaped_apostrophe_in_strings_key() {
        let temp_dir = ScopedTempDir::new("parse-escaped-key-apostrophe");
        let strings_path = temp_dir.path().join("Localizable.strings");
        fs::write(&strings_path, "\"invite.can\\'t\" = \"Invite\";\n")
            .expect("strings file should be written");

        let tables = parse_strings(&strings_path).expect("strings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].entries[0].path, "invite.can't");
        assert_eq!(
            tables[0].entries[0].properties["key"],
            Value::String("invite.can't".to_string())
        );
    }

    #[test]
    fn parses_utf8_with_bom() {
        let temp_dir = make_temp_dir("parse-utf8-bom");
        let strings_path = temp_dir.join("Localizable.strings");
        fs::write(
            &strings_path,
            b"\xEF\xBB\xBF\"profile.title\" = \"Profile\";\n",
        )
        .expect("strings file should be written");

        let tables = parse_strings(&strings_path).expect("strings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].entries[0].properties["key"], "profile.title");

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn parses_utf16_with_bom() {
        let temp_dir = make_temp_dir("parse-utf16-bom");
        let strings_path = temp_dir.join("Localizable.strings");
        let utf16: Vec<u16> = "\"profile.title\" = \"Profile\";\n"
            .encode_utf16()
            .collect();
        let mut bytes = vec![0xFF, 0xFE];
        for unit in utf16 {
            bytes.extend_from_slice(&unit.to_le_bytes());
        }
        fs::write(&strings_path, bytes).expect("strings file should be written");

        let tables = parse_strings(&strings_path).expect("strings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].entries[0].properties["translation"], "Profile");

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn parses_utf16_big_endian_with_bom() {
        let temp_dir = make_temp_dir("parse-utf16be-bom");
        let strings_path = temp_dir.join("Localizable.strings");
        let utf16: Vec<u16> = "\"profile.title\" = \"Profile\";\n"
            .encode_utf16()
            .collect();
        let mut bytes = vec![0xFE, 0xFF];
        for unit in utf16 {
            bytes.extend_from_slice(&unit.to_be_bytes());
        }
        fs::write(&strings_path, bytes).expect("strings file should be written");

        let tables = parse_strings(&strings_path).expect("strings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].entries[0].properties["translation"], "Profile");

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn parses_xcstrings_plain_string_and_placeholders() {
        let temp_dir = make_temp_dir("parse-xcstrings-plain");
        let xcstrings_path = temp_dir.join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "greeting.message": {
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": "Hello %#@name@, you have %#@count@ messages"
          },
          "substitutions": {
            "count": {
              "formatSpecifier": "lld"
            },
            "name": {
              "formatSpecifier": "@"
            }
          }
        }
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let tables = parse_xcstrings(&xcstrings_path).expect("xcstrings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].table_name, "Localizable");
        assert_eq!(tables[0].module_kind, ModuleKind::Xcstrings);
        assert_eq!(tables[0].entries.len(), 1);
        assert_eq!(tables[0].entries[0].path, "greeting.message");
        assert_eq!(
            tables[0].entries[0].properties["key"],
            Value::String("greeting.message".to_string())
        );
        assert_eq!(
            tables[0].entries[0].properties["translation"],
            Value::String("Hello %#@name@, you have %#@count@ messages".to_string())
        );
        assert_eq!(
            tables[0].entries[0].properties["placeholders"],
            json!([
                {"name": "count", "format": "lld", "swiftType": "Int"},
                {"name": "name", "format": "@", "swiftType": "String"}
            ])
        );
        assert!(tables[0].warnings.is_empty());

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn keeps_xcstrings_placeholder_name_without_format_specifier() {
        let temp_dir = make_temp_dir("parse-xcstrings-placeholder-name-only");
        let xcstrings_path = temp_dir.join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "welcome.message": {
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": "Hello %#@name@"
          },
          "substitutions": {
            "name": {}
          }
        }
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let tables = parse_xcstrings(&xcstrings_path).expect("xcstrings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].entries.len(), 1);
        assert_eq!(
            tables[0].entries[0].properties["placeholders"],
            json!([{"name": "name"}])
        );
        assert!(tables[0].warnings.is_empty());

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn infers_xcstrings_printf_placeholders_from_plain_string_unit_values() {
        let temp_dir = make_temp_dir("parse-xcstrings-printf-placeholders");
        let xcstrings_path = temp_dir.join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "welcome.user": {
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": "Welcome %@ (%lld)"
          }
        }
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let tables = parse_xcstrings(&xcstrings_path).expect("xcstrings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].entries.len(), 1);
        assert_eq!(
            tables[0].entries[0].properties["placeholders"],
            json!([
                {"format": "@", "swiftType": "String"},
                {"format": "lld", "swiftType": "Int"}
            ])
        );
        assert!(tables[0].warnings.is_empty());

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn skips_xcstrings_plural_variations_with_warning() {
        let temp_dir = make_temp_dir("parse-xcstrings-plural");
        let xcstrings_path = temp_dir.join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "things.label": {
      "localizations": {
        "en": {
          "variations": {
            "plural": {
              "one": {
                "stringUnit": {
                  "state": "translated",
                  "value": "%lld thing"
                }
              },
              "other": {
                "stringUnit": {
                  "state": "translated",
                  "value": "%lld things"
                }
              }
            }
          }
        }
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let tables = parse_xcstrings(&xcstrings_path).expect("xcstrings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].module_kind, ModuleKind::Xcstrings);
        assert!(tables[0].entries.is_empty());
        assert_eq!(tables[0].warnings.len(), 1);
        assert_eq!(tables[0].warnings[0].severity, Severity::Warning);
        assert!(tables[0].warnings[0].message.contains("things.label"));
        assert!(
            tables[0].warnings[0]
                .message
                .contains("unsupported plural variations")
        );
        assert_eq!(tables[0].warnings[0].path, Some(xcstrings_path.clone()));

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn skips_xcstrings_device_variations_with_warning() {
        let temp_dir = make_temp_dir("parse-xcstrings-device");
        let xcstrings_path = temp_dir.join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "title.label": {
      "localizations": {
        "en": {
          "variations": {
            "device": {
              "iphone": {
                "stringUnit": {
                  "state": "translated",
                  "value": "Title"
                }
              }
            }
          }
        }
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let tables = parse_xcstrings(&xcstrings_path).expect("xcstrings should parse");

        assert_eq!(tables.len(), 1);
        assert!(tables[0].entries.is_empty());
        assert_eq!(tables[0].warnings.len(), 1);
        assert!(
            tables[0].warnings[0]
                .message
                .contains("unsupported device-specific variations")
        );

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn skips_xcstrings_unknown_variation_tree_with_warning() {
        let temp_dir = make_temp_dir("parse-xcstrings-unknown-variation");
        let xcstrings_path = temp_dir.join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "title.label": {
      "localizations": {
        "en": {
          "variations": {
            "customDimension": {
              "foo": {
                "stringUnit": {
                  "state": "translated",
                  "value": "Title"
                }
              }
            }
          }
        }
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let tables = parse_xcstrings(&xcstrings_path).expect("xcstrings should parse");

        assert_eq!(tables.len(), 1);
        assert!(tables[0].entries.is_empty());
        assert_eq!(tables[0].warnings.len(), 1);
        assert!(
            tables[0].warnings[0]
                .message
                .contains("unsupported variation tree")
        );

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn xcstrings_warnings_are_sorted_for_multiple_skipped_keys() {
        let temp_dir = make_temp_dir("parse-xcstrings-warning-order");
        let xcstrings_path = temp_dir.join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "z.key": {
      "localizations": {
        "en": {}
      }
    },
    "a.key": {
      "localizations": {
        "en": {
          "variations": {
            "plural": {
              "one": {
                "stringUnit": {
                  "state": "translated",
                  "value": "%lld item"
                }
              }
            }
          }
        }
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let tables = parse_xcstrings(&xcstrings_path).expect("xcstrings should parse");

        assert_eq!(tables.len(), 1);
        assert!(tables[0].entries.is_empty());
        assert_eq!(tables[0].warnings.len(), 2);
        assert!(tables[0].warnings[0].message.contains("a.key"));
        assert!(tables[0].warnings[1].message.contains("z.key"));

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn keeps_xcstrings_plain_string_when_other_localization_has_plural_variations() {
        let temp_dir = make_temp_dir("parse-xcstrings-other-loc-variations");
        let xcstrings_path = temp_dir.join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "greeting.message": {
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": "Hello world"
          }
        },
        "de": {
          "variations": {
            "plural": {
              "one": {
                "stringUnit": {
                  "state": "translated",
                  "value": "Hallo Welt"
                }
              },
              "other": {
                "stringUnit": {
                  "state": "translated",
                  "value": "Hallo Welten"
                }
              }
            }
          }
        }
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let tables = parse_xcstrings(&xcstrings_path).expect("xcstrings should parse");

        assert_eq!(tables.len(), 1);
        assert_eq!(tables[0].entries.len(), 1);
        assert_eq!(tables[0].entries[0].path, "greeting.message");
        assert_eq!(
            tables[0].entries[0].properties["translation"],
            Value::String("Hello world".to_string())
        );
        assert!(tables[0].warnings.is_empty());

        fs::remove_dir_all(temp_dir).expect("temp dir should be removed");
    }

    #[test]
    fn xcstrings_with_lv_lld_missing_string_unit_becomes_warning() {
        let temp_dir = ScopedTempDir::new("parse-xcstrings-lv-format");
        let xcstrings_path = temp_dir.path().join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "Lv.%lld": {
      "localizations": {
        "en": {}
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let result = parse_xcstrings(&xcstrings_path);

        assert!(
            result.is_ok(),
            "{}",
            match result {
                Err(error) => format!("expected warnings, got parse error: {error}"),
                Ok(_) => "".to_string(),
            }
        );

        let tables = result.expect("parse should now warn instead of error");
        assert_eq!(tables.len(), 1);
        assert!(tables[0].entries.is_empty());
        assert_eq!(tables[0].table_name, "Localizable");
        assert_eq!(tables[0].module_kind, ModuleKind::Xcstrings);
        assert_eq!(tables[0].warnings.len(), 1);
        let warning = tables[0]
            .warnings
            .iter()
            .find(|warning| {
                warning.message.contains("`Lv.%lld`") && warning.message.contains("string unit")
            })
            .expect("expected warning for Lv.%lld");
        assert_eq!(warning.path, Some(xcstrings_path.clone()));
        assert_eq!(warning.severity, Severity::Warning);
    }

    #[test]
    fn xcstrings_with_empty_key_missing_string_unit_becomes_warning() {
        let temp_dir = ScopedTempDir::new("parse-xcstrings-empty-key");
        let xcstrings_path = temp_dir.path().join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "": {
      "localizations": {
        "en": {}
      }
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let result = parse_xcstrings(&xcstrings_path);

        assert!(
            result.is_ok(),
            "{}",
            match result {
                Err(error) => format!("expected warnings, got parse error: {error}"),
                Ok(_) => "".to_string(),
            }
        );

        let tables = result.expect("parse should now warn instead of error");
        assert_eq!(tables.len(), 1);
        assert!(tables[0].entries.is_empty());
        assert_eq!(tables[0].table_name, "Localizable");
        assert_eq!(tables[0].module_kind, ModuleKind::Xcstrings);
        assert_eq!(tables[0].warnings.len(), 1);
        let warning = tables[0]
            .warnings
            .iter()
            .find(|warning| warning.message.contains("empty key"))
            .expect("expected warning for empty key");
        assert_eq!(warning.path, Some(xcstrings_path.clone()));
        assert_eq!(warning.severity, Severity::Warning);
    }

    #[test]
    fn xcstrings_with_empty_key_and_empty_string_unit_becomes_warning() {
        let temp_dir = ScopedTempDir::new("parse-xcstrings-empty-key-string-unit");
        let xcstrings_path = temp_dir.path().join("Localizable.xcstrings");
        fs::write(
            &xcstrings_path,
            r#"{
  "version": "1.0",
  "sourceLanguage": "en",
  "strings": {
    "": {
      "localizations": {
        "en": {
          "stringUnit": {
            "state": "translated",
            "value": ""
          }
        }
      },
      "shouldTranslate": false
    }
  }
}
"#,
        )
        .expect("xcstrings file should be written");

        let result = parse_xcstrings(&xcstrings_path);

        assert!(
            result.is_ok(),
            "{}",
            match result {
                Err(error) => format!("expected warnings, got parse error: {error}"),
                Ok(_) => "".to_string(),
            }
        );

        let tables = result.expect("parse should now warn instead of error");
        assert_eq!(tables.len(), 1);
        assert!(tables[0].entries.is_empty());
        assert_eq!(tables[0].warnings.len(), 1);
        let warning = tables[0]
            .warnings
            .iter()
            .find(|warning| warning.message.contains("empty key"))
            .expect("expected warning for empty key");
        assert_eq!(warning.path, Some(xcstrings_path.clone()));
        assert_eq!(warning.severity, Severity::Warning);
    }
}
