use std::path::{Component, Path, PathBuf};

use bone_ui::frame::FrameCtx;
use bone_ui::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
use bone_ui::widgets::{
    FilePickerDialog, FilePickerEntry, FilePickerLabels, FilePickerMode, FilePickerOutcome,
    FilePickerState, LabelText, WidgetPaint, show_file_picker,
};
use bone_ui::{WidgetId, WidgetKey};

use crate::strings;

pub const DEFAULT_DOCUMENTS_SUBDIR: &str = "bone-documents";

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FileKind {
    Document,
    Step,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PickerEntry {
    pub id: WidgetId,
    pub label: String,
    pub path: PathBuf,
}

pub struct FilePickerSession {
    pub mode: FilePickerMode,
    pub kind: FileKind,
    pub root: PathBuf,
    pub entries: Vec<PickerEntry>,
    pub state: FilePickerState,
}

impl FilePickerSession {
    #[must_use]
    pub fn open(
        root: PathBuf,
        mode: FilePickerMode,
        kind: FileKind,
        seed_filename: Option<String>,
        entries: Vec<PickerEntry>,
    ) -> Self {
        let mut state = FilePickerState::default();
        if let Some(name) = seed_filename {
            state.filename.text = name;
        }
        Self {
            mode,
            kind,
            root,
            entries,
            state,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PickerCommand {
    Cancel,
    Open(PathBuf),
    SaveAs(PathBuf),
}

#[must_use]
pub fn title_key(kind: FileKind, mode: FilePickerMode) -> bone_ui::strings::StringKey {
    match (kind, mode) {
        (FileKind::Document, FilePickerMode::Open) => strings::FILE_PICKER_TITLE_OPEN,
        (FileKind::Document, FilePickerMode::Save) => strings::FILE_PICKER_TITLE_SAVE_AS,
        (FileKind::Step, FilePickerMode::Open) => strings::FILE_PICKER_TITLE_IMPORT,
        (FileKind::Step, FilePickerMode::Save) => strings::FILE_PICKER_TITLE_EXPORT,
    }
}

#[must_use]
pub fn accept_key(kind: FileKind, mode: FilePickerMode) -> bone_ui::strings::StringKey {
    match (kind, mode) {
        (FileKind::Document, FilePickerMode::Open) => strings::FILE_PICKER_OPEN,
        (FileKind::Document, FilePickerMode::Save) => strings::FILE_PICKER_SAVE,
        (FileKind::Step, FilePickerMode::Open) => strings::FILE_PICKER_IMPORT,
        (FileKind::Step, FilePickerMode::Save) => strings::FILE_PICKER_EXPORT,
    }
}

#[must_use]
pub fn picker_labels(kind: FileKind, mode: FilePickerMode) -> FilePickerLabels {
    let (list, filename_placeholder) = match kind {
        FileKind::Document => (
            strings::FILE_PICKER_LIST,
            strings::FILE_PICKER_FILENAME_PLACEHOLDER,
        ),
        FileKind::Step => (
            strings::FILE_PICKER_LIST_STEP,
            strings::FILE_PICKER_FILENAME_PLACEHOLDER_STEP,
        ),
    };
    FilePickerLabels {
        title: title_key(kind, mode),
        confirm: accept_key(kind, mode),
        list,
        filename_placeholder,
    }
}

pub struct PickerModalOutcome {
    pub paints: Vec<WidgetPaint>,
    pub command: Option<PickerCommand>,
}

pub fn render(
    ctx: &mut FrameCtx<'_>,
    session: &mut FilePickerSession,
    viewport: LayoutSize,
) -> PickerModalOutcome {
    let viewport_rect = LayoutRect::new(LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO), viewport);
    let entries: Vec<FilePickerEntry> = session
        .entries
        .iter()
        .map(|e| FilePickerEntry {
            id: e.id,
            label: LabelText::Owned(e.label.clone()),
        })
        .collect();
    let empty_key = match session.kind {
        FileKind::Document => strings::FILE_PICKER_DIR_EMPTY,
        FileKind::Step => strings::FILE_PICKER_NO_STEP_FILES,
    };
    let current_path = session.root.display().to_string();
    let entries_empty = entries.is_empty();
    let response = show_file_picker(
        ctx,
        FilePickerDialog::new(
            picker_id(),
            viewport_rect,
            session.mode,
            current_path,
            &entries,
            picker_labels(session.kind, session.mode),
            &mut session.state,
        ),
    );
    let mut paints = response.paint;
    if entries_empty {
        paints.push(empty_state_label(ctx, viewport_rect, empty_key));
    }
    let command = response
        .outcome
        .and_then(|o| translate(o, &session.entries, &session.root));
    PickerModalOutcome { paints, command }
}

fn empty_state_label(
    ctx: &FrameCtx<'_>,
    viewport: LayoutRect,
    key: bone_ui::strings::StringKey,
) -> WidgetPaint {
    let rect = LayoutRect::new(
        LayoutPos::new(
            LayoutPx::new(viewport.origin.x.value() + viewport.size.width.value() * 0.5 - 160.0),
            LayoutPx::new(viewport.origin.y.value() + viewport.size.height.value() * 0.5 - 12.0),
        ),
        LayoutSize::new(LayoutPx::new(320.0), LayoutPx::new(24.0)),
    );
    WidgetPaint::Label {
        rect,
        text: LabelText::Key(key),
        color: ctx.theme().colors.text_secondary(),
        role: ctx.theme().typography.body,
    }
}

fn translate(
    outcome: FilePickerOutcome,
    entries: &[PickerEntry],
    root: &Path,
) -> Option<PickerCommand> {
    match outcome {
        FilePickerOutcome::Cancelled => Some(PickerCommand::Cancel),
        FilePickerOutcome::Open { folder } => entries
            .iter()
            .find(|e| e.id == folder)
            .map(|e| PickerCommand::Open(e.path.clone())),
        FilePickerOutcome::Save { folder, filename } => {
            if filename.is_empty() {
                return folder
                    .and_then(|f| entries.iter().find(|e| e.id == f))
                    .map(|e| PickerCommand::SaveAs(e.path.clone()));
            }
            let Some(name) = validate_save_name(filename.as_str()) else {
                tracing::warn!(input = filename.as_str(), "rejected save filename");
                return None;
            };
            Some(PickerCommand::SaveAs(root.join(name)))
        }
    }
}

const WINDOWS_RESERVED: &[&str] = &[
    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
const WINDOWS_RESERVED_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*'];
pub const MAX_SAVE_NAME_BYTES: usize = 200;

#[must_use]
pub fn validate_save_name(name: &str) -> Option<&str> {
    if name.is_empty() || name.trim().is_empty() {
        return None;
    }
    if name.len() > MAX_SAVE_NAME_BYTES {
        return None;
    }
    if name != name.trim() {
        return None;
    }
    if name.ends_with('.') {
        return None;
    }
    if name
        .chars()
        .any(|c| c.is_control() || WINDOWS_RESERVED_CHARS.contains(&c))
    {
        return None;
    }
    let stem = name.split('.').next().unwrap_or("");
    if WINDOWS_RESERVED
        .iter()
        .any(|r| stem.eq_ignore_ascii_case(r))
    {
        return None;
    }
    let path = Path::new(name);
    let mut components = path.components();
    let first = components.next()?;
    if components.next().is_some() {
        return None;
    }
    match first {
        Component::Normal(os) => os.to_str().filter(|s| !s.is_empty()),
        Component::CurDir | Component::ParentDir | Component::Prefix(_) | Component::RootDir => {
            None
        }
    }
}

pub fn picker_id() -> WidgetId {
    WidgetId::ROOT.child(WidgetKey::new("app.file_picker"))
}

const STEP_EXTENSIONS: &[&str] = &["step", "stp"];

#[must_use]
pub fn is_step_file(path: &Path) -> bool {
    path.extension().and_then(|e| e.to_str()).is_some_and(|e| {
        STEP_EXTENSIONS
            .iter()
            .any(|known| e.eq_ignore_ascii_case(known))
    })
}

#[must_use]
pub fn with_step_extension(path: PathBuf) -> PathBuf {
    if is_step_file(&path) {
        return path;
    }
    [path.into_os_string(), std::ffi::OsString::from(".step")]
        .into_iter()
        .collect::<std::ffi::OsString>()
        .into()
}

pub fn scan_document_folders(root: &Path) -> Result<Vec<PickerEntry>, std::io::Error> {
    scan_entries(root, "app.file_picker.entry", |path| {
        path.is_dir()
            && path
                .join(bone_document::io::folder::DOCUMENT_FILE)
                .is_file()
    })
}

pub fn scan_step_files(root: &Path) -> Result<Vec<PickerEntry>, std::io::Error> {
    scan_entries(root, "app.file_picker.step", |path| {
        path.is_file() && is_step_file(path)
    })
}

fn scan_entries(
    root: &Path,
    key: &'static str,
    accept: impl Fn(&Path) -> bool,
) -> Result<Vec<PickerEntry>, std::io::Error> {
    let read = match std::fs::read_dir(root) {
        Ok(read) => read,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => return Err(e),
    };
    let mut entries: Vec<PickerEntry> = read
        .filter_map(Result::ok)
        .filter_map(|entry| {
            let path = entry.path();
            if !accept(&path) {
                return None;
            }
            let label = path
                .file_name()
                .map(|s| s.to_string_lossy().into_owned())
                .unwrap_or_default();
            let id = WidgetId::ROOT
                .child(WidgetKey::new(key))
                .child_named(WidgetKey::new("name"), &label);
            Some(PickerEntry { id, label, path })
        })
        .collect();
    entries.sort_by(|a, b| a.label.cmp(&b.label));
    Ok(entries)
}

#[must_use]
pub fn documents_root() -> PathBuf {
    if let Some(path) = std::env::var_os("BONE_DOCUMENTS_DIR") {
        return PathBuf::from(path);
    }
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    cwd.join(DEFAULT_DOCUMENTS_SUBDIR)
}

#[cfg(test)]
mod tests {
    use super::{
        FileKind, FilePickerSession, PickerCommand, is_step_file, scan_document_folders,
        scan_step_files, translate, validate_save_name, with_step_extension,
    };
    use bone_ui::widgets::{FilePickerMode, FilePickerOutcome};
    use std::fs;
    use std::path::PathBuf;

    fn tmp(prefix: &str) -> PathBuf {
        let nonce = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or_default();
        let base = std::env::temp_dir().join(format!("bone-file-menu-{prefix}-{nonce}"));
        match fs::create_dir_all(&base) {
            Ok(()) => base,
            Err(e) => panic!("temp dir create failed: {e}"),
        }
    }

    fn write_stub(path: &std::path::Path, body: &str) {
        if let Some(parent) = path.parent()
            && let Err(e) = fs::create_dir_all(parent)
        {
            panic!("create parent {}: {e}", parent.display());
        }
        if let Err(e) = fs::write(path, body) {
            panic!("write stub {}: {e}", path.display());
        }
    }

    const RON_STUB: &str = "(schema:(name:\"bone-document\",version:(major:1,minor:0)))";

    fn scan(root: &std::path::Path) -> Vec<super::PickerEntry> {
        match scan_document_folders(root) {
            Ok(v) => v,
            Err(e) => panic!("scan {}: {e}", root.display()),
        }
    }

    #[test]
    fn scan_picks_only_document_folders() {
        let root = tmp("scan");
        write_stub(&root.join("doc_a/document.ron"), "stub");
        write_stub(&root.join("doc_b/document.ron"), "stub");
        write_stub(&root.join("not_a_doc/.keep"), "");
        write_stub(&root.join("loose-file.txt"), "x");
        let entries = scan(&root);
        assert_eq!(entries.len(), 2);
        let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
        assert_eq!(labels, ["doc_a", "doc_b"]);
    }

    #[test]
    fn scan_missing_root_is_ok_empty() {
        let root = tmp("scan-missing").join("does_not_exist");
        let entries = scan_document_folders(&root);
        assert!(matches!(entries, Ok(v) if v.is_empty()));
    }

    #[test]
    fn scan_ids_are_stable_across_reruns() {
        let root = tmp("scan-stable");
        write_stub(&root.join("alpha/document.ron"), RON_STUB);
        write_stub(&root.join("beta/document.ron"), RON_STUB);
        let first = scan(&root);
        let second = scan(&root);
        assert_eq!(
            first.iter().map(|e| e.id).collect::<Vec<_>>(),
            second.iter().map(|e| e.id).collect::<Vec<_>>(),
        );
    }

    #[test]
    fn open_session_seeds_filename() {
        let root = tmp("seed");
        let session = FilePickerSession::open(
            root,
            FilePickerMode::Save,
            FileKind::Document,
            Some("Untitled".to_owned()),
            Vec::new(),
        );
        assert_eq!(session.state.filename.text, "Untitled");
    }

    #[test]
    fn translate_open_resolves_widget_id_to_path() {
        let root = tmp("translate-open");
        write_stub(&root.join("alpha/document.ron"), RON_STUB);
        let entries = scan(&root);
        let cmd = translate(
            FilePickerOutcome::Open {
                folder: entries[0].id,
            },
            &entries,
            &root,
        );
        assert_eq!(cmd, Some(PickerCommand::Open(entries[0].path.clone())));
    }

    #[test]
    fn scan_step_files_picks_only_step_extensions() {
        let root = tmp("scan-step");
        write_stub(&root.join("anemone.step"), "ISO-10303-21;");
        write_stub(&root.join("barnacle.STP"), "ISO-10303-21;");
        write_stub(&root.join("limpet.txt"), "x");
        write_stub(&root.join("whelk.step/document.ron"), RON_STUB);
        let entries = match scan_step_files(&root) {
            Ok(v) => v,
            Err(e) => panic!("scan {}: {e}", root.display()),
        };
        let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
        assert_eq!(labels, ["anemone.step", "barnacle.STP"]);
    }

    #[test]
    fn step_extension_recognized_case_insensitively() {
        assert!(is_step_file(std::path::Path::new("conch.step")));
        assert!(is_step_file(std::path::Path::new("conch.STEP")));
        assert!(is_step_file(std::path::Path::new("conch.stp")));
        assert!(!is_step_file(std::path::Path::new("conch.ron")));
        assert!(!is_step_file(std::path::Path::new("conch")));
    }

    #[test]
    fn step_extension_appended_without_clobbering_stem() {
        assert_eq!(
            with_step_extension(PathBuf::from("/tmp/mussel")),
            PathBuf::from("/tmp/mussel.step"),
        );
        assert_eq!(
            with_step_extension(PathBuf::from("/tmp/mussel.v2")),
            PathBuf::from("/tmp/mussel.v2.step"),
        );
        assert_eq!(
            with_step_extension(PathBuf::from("/tmp/mussel.STP")),
            PathBuf::from("/tmp/mussel.STP"),
        );
    }

    #[test]
    fn translate_save_uses_filename_field_under_root() {
        let root = tmp("translate-save");
        let entries = scan(&root);
        let cmd = translate(
            FilePickerOutcome::Save {
                folder: None,
                filename: "brand_new".into(),
            },
            &entries,
            &root,
        );
        assert_eq!(cmd, Some(PickerCommand::SaveAs(root.join("brand_new"))));
    }

    #[test]
    fn translate_cancel_is_cancel() {
        let root = tmp("translate-cancel");
        let entries = scan(&root);
        let cmd = translate(FilePickerOutcome::Cancelled, &entries, &root);
        assert_eq!(cmd, Some(PickerCommand::Cancel));
    }

    #[test]
    fn translate_save_empty_filename_falls_back_to_selection() {
        let root = tmp("translate-save-fallback");
        write_stub(&root.join("seed/document.ron"), RON_STUB);
        let entries = scan(&root);
        let cmd = translate(
            FilePickerOutcome::Save {
                folder: Some(entries[0].id),
                filename: String::new(),
            },
            &entries,
            &root,
        );
        assert_eq!(cmd, Some(PickerCommand::SaveAs(entries[0].path.clone())));
    }

    #[test]
    fn translate_save_rejects_whitespace_only_filename() {
        let root = tmp("translate-save-whitespace");
        write_stub(&root.join("seed/document.ron"), RON_STUB);
        let entries = scan(&root);
        let cmd = translate(
            FilePickerOutcome::Save {
                folder: Some(entries[0].id),
                filename: "   ".into(),
            },
            &entries,
            &root,
        );
        assert_eq!(cmd, None);
    }

    #[test]
    fn validate_accepts_simple_name() {
        assert_eq!(validate_save_name("Untitled"), Some("Untitled"));
        assert_eq!(validate_save_name("doc_42"), Some("doc_42"));
        assert_eq!(validate_save_name("a-b.c"), Some("a-b.c"));
    }

    #[test]
    fn validate_rejects_path_escape_and_separators() {
        assert_eq!(validate_save_name(""), None);
        assert_eq!(validate_save_name(".."), None);
        assert_eq!(validate_save_name("."), None);
        assert_eq!(validate_save_name("../escape"), None);
        assert_eq!(validate_save_name("a/b"), None);
        #[cfg(windows)]
        assert_eq!(validate_save_name(r"a\b"), None);
        #[cfg(unix)]
        assert_eq!(validate_save_name("/abs/path"), None);
    }

    #[test]
    fn validate_rejects_windows_reserved_stems() {
        assert_eq!(validate_save_name("CON"), None);
        assert_eq!(validate_save_name("con"), None);
        assert_eq!(validate_save_name("NUL.txt"), None);
        assert_eq!(validate_save_name("COM1"), None);
        assert_eq!(validate_save_name("LPT9.bak"), None);
        assert_eq!(validate_save_name("AUX"), None);
    }

    #[test]
    fn validate_rejects_windows_reserved_chars() {
        assert_eq!(validate_save_name("foo<bar"), None);
        assert_eq!(validate_save_name("a:b"), None);
        assert_eq!(validate_save_name("pipe|name"), None);
        assert_eq!(validate_save_name("ask?"), None);
        assert_eq!(validate_save_name("star*"), None);
        assert_eq!(validate_save_name("quote\""), None);
        assert_eq!(validate_save_name("ctrl\x07char"), None);
    }

    #[test]
    fn validate_rejects_trailing_dot_or_space() {
        assert_eq!(validate_save_name("trailing."), None);
        assert_eq!(validate_save_name("trailing "), None);
        assert_eq!(validate_save_name(" leading"), None);
        assert_eq!(validate_save_name("middle.dots.ok"), Some("middle.dots.ok"));
    }

    #[test]
    fn validate_rejects_names_exceeding_byte_cap() {
        use super::MAX_SAVE_NAME_BYTES;
        let at_cap: String = "a".repeat(MAX_SAVE_NAME_BYTES);
        let over_cap: String = "a".repeat(MAX_SAVE_NAME_BYTES + 1);
        assert!(validate_save_name(&at_cap).is_some());
        assert_eq!(validate_save_name(&over_cap), None);
    }

    #[test]
    fn translate_save_rejects_parent_traversal() {
        let root = tmp("translate-save-traversal");
        let entries = scan(&root);
        let cmd = translate(
            FilePickerOutcome::Save {
                folder: None,
                filename: "../escape".into(),
            },
            &entries,
            &root,
        );
        assert_eq!(cmd, None);
    }

    #[test]
    fn translate_save_rejects_nested_path() {
        let root = tmp("translate-save-nested");
        let entries = scan(&root);
        let cmd = translate(
            FilePickerOutcome::Save {
                folder: None,
                filename: "a/b".into(),
            },
            &entries,
            &root,
        );
        assert_eq!(cmd, None);
    }

    #[test]
    #[cfg(unix)]
    fn translate_save_rejects_absolute_path() {
        let root = tmp("translate-save-abs");
        let entries = scan(&root);
        let cmd = translate(
            FilePickerOutcome::Save {
                folder: None,
                filename: "/etc/passwd".into(),
            },
            &entries,
            &root,
        );
        assert_eq!(cmd, None);
    }
}
