use std::fs;
use std::path::Path;

const ALLOWED_STRING_FIELDS: &[(&str, &str)] = &[
    ("text_input.rs", "pub text: String,"),
    ("text_input.rs", "pub before_text: String,"),
    ("text_input.rs", "pub after_text: String,"),
    ("dropdown.rs", "pub filter: String,"),
    ("tree_view.rs", "pub text: String,"),
    ("property_grid.rs", "pub value: String,"),
];

const RAW_STRING_LEAVES: &[&str] = &[
    "String",
    "str",
    "SmolStr",
    "CompactString",
    "ArcStr",
    "EcoString",
    "KString",
];

#[test]
fn widget_public_strings_stay_on_the_user_input_allow_list() {
    let widgets = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/widgets");
    let Ok(entries) = fs::read_dir(&widgets) else {
        panic!("widgets dir readable: {}", widgets.display());
    };
    let violations: Vec<String> = entries
        .filter_map(Result::ok)
        .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "rs"))
        .flat_map(|entry| scan_file(&entry.path()))
        .collect();
    assert!(
        violations.is_empty(),
        "found pub raw-string fields in widget public surface that are not on the user-input allow-list. \
         Convert label-shaped fields to StringKey, or add the field to ALLOWED_STRING_FIELDS if it carries user-typed content:\n{}",
        violations.join("\n"),
    );
}

fn scan_file(path: &Path) -> Vec<String> {
    let Some(name) = path.file_name().and_then(|f| f.to_str()) else {
        return Vec::new();
    };
    let Ok(contents) = fs::read_to_string(path) else {
        return Vec::new();
    };
    contents
        .lines()
        .enumerate()
        .filter_map(|(idx, raw)| {
            let line = raw.trim();
            if !is_pub_raw_string_field(line) {
                return None;
            }
            if ALLOWED_STRING_FIELDS
                .iter()
                .any(|(file, snippet)| *file == name && *snippet == line)
            {
                return None;
            }
            Some(format!("{name}:{}: {line}", idx + 1))
        })
        .collect()
}

fn is_pub_raw_string_field(line: &str) -> bool {
    field_type(line).is_some_and(type_carries_raw_string)
}

fn field_type(line: &str) -> Option<&str> {
    let body = line.strip_prefix("pub ")?;
    let (_, ty) = body.split_once(": ")?;
    ty.strip_suffix(',').map(str::trim)
}

fn type_carries_raw_string(ty: &str) -> bool {
    RAW_STRING_LEAVES.iter().any(|leaf| contains_word(ty, leaf))
}

fn contains_word(haystack: &str, word: &str) -> bool {
    haystack.match_indices(word).any(|(idx, _)| {
        let before = haystack[..idx].chars().next_back();
        let after = haystack[idx + word.len()..].chars().next();
        before.is_none_or(|c| !is_ident_char(c)) && after.is_none_or(|c| !is_ident_char(c))
    })
}

const fn is_ident_char(c: char) -> bool {
    c.is_ascii_alphanumeric() || c == '_'
}
