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, 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(Clone, Debug, PartialEq, Eq)]
pub struct PickerEntry {
    pub id: WidgetId,
    pub label: String,
    pub path: PathBuf,
}

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

impl FilePickerSession {
    #[must_use]
    pub fn open(
        root: PathBuf,
        mode: FilePickerMode,
        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,
            root,
            entries,
            state,
        }
    }
}

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

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 title = match session.mode {
        FilePickerMode::Open => strings::FILE_PICKER_TITLE_OPEN,
        FilePickerMode::Save => strings::FILE_PICKER_TITLE_SAVE_AS,
    };
    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,
            title,
            &mut session.state,
        ),
    );
    let mut paints = response.paint;
    if entries_empty {
        paints.push(empty_state_label(ctx, viewport_rect));
    }
    let command = response
        .outcome
        .and_then(|o| translate(o, &session.entries, &session.root));
    PickerModalOutcome { paints, command }
}

fn empty_state_label(ctx: &FrameCtx<'_>, viewport: LayoutRect) -> 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(strings::FILE_PICKER_DIR_EMPTY),
        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::OpenFolder(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"))
}

pub fn scan_document_folders(root: &Path) -> 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();
            let document_ron = path.join(bone_document::io::folder::DOCUMENT_FILE);
            let is_doc = path.is_dir() && document_ron.is_file();
            if !is_doc {
                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("app.file_picker.entry"))
                .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::{
        FilePickerSession, PickerCommand, scan_document_folders, translate, validate_save_name,
    };
    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,
            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::OpenFolder(entries[0].path.clone()))
        );
    }

    #[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);
    }
}
