use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::{Context, Result, bail};

use crate::config::PickerBindings;
use crate::process::{CommandRunner, default_runner};
use crate::ui::DisplayStyle;

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EntryKind {
    Session,
    Directory,
    Project,
    InvalidProject,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SelectAction {
    Open,
    Delete,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Entry {
    pub kind: EntryKind,
    pub label: String,
    pub value: String,
}

impl Entry {
    pub fn session(style: DisplayStyle, value: String) -> Self {
        Self {
            kind: EntryKind::Session,
            label: style.session_label(&value),
            value,
        }
    }

    pub fn directory(style: DisplayStyle, value: String) -> Self {
        Self {
            kind: EntryKind::Directory,
            label: style.directory_label(&value),
            value,
        }
    }

    pub fn project(style: DisplayStyle, value: String) -> Self {
        Self {
            kind: EntryKind::Project,
            label: style.project_label(&value),
            value,
        }
    }

    pub fn invalid_project(style: DisplayStyle, value: String, error: &str) -> Self {
        Self {
            kind: EntryKind::InvalidProject,
            label: style.invalid_project_label(&value, error),
            value,
        }
    }

    fn encode(&self) -> String {
        let kind = match self.kind {
            EntryKind::Session => "session",
            EntryKind::Directory => "folder",
            EntryKind::Project => "project",
            EntryKind::InvalidProject => "project-broken",
        };

        format!("{kind}\t{}\t{}", self.value, self.label)
    }

    fn decode(line: &str) -> Result<Self> {
        let mut parts = line.splitn(3, '\t');
        let kind = parts.next().context("missing entry kind")?;
        let value = parts.next().context("missing entry value")?.to_owned();
        let label = parts.next().context("missing entry label")?.to_owned();

        let kind = match kind {
            "session" => EntryKind::Session,
            "folder" => EntryKind::Directory,
            "project" => EntryKind::Project,
            "project-broken" => EntryKind::InvalidProject,
            other => bail!("unknown picker entry kind: {other}"),
        };

        Ok(Self { kind, label, value })
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Selection {
    pub action: SelectAction,
    pub entry: Entry,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Choice {
    pub kind: String,
    pub label: String,
    pub value: String,
}

impl Choice {
    pub fn new(kind: impl Into<String>, label: String, value: String) -> Self {
        Self {
            kind: kind.into(),
            label,
            value,
        }
    }

    fn encode(&self) -> String {
        format!("{}\t{}\t{}", self.kind, self.value, self.label)
    }

    fn decode(line: &str) -> Result<Self> {
        let mut parts = line.splitn(3, '\t');
        let kind = parts.next().context("missing choice kind")?.to_owned();
        let value = parts.next().context("missing choice value")?.to_owned();
        let label = parts.next().context("missing choice label")?.to_owned();
        Ok(Self { kind, label, value })
    }
}

pub fn select(entries: Vec<Entry>, bindings: &PickerBindings) -> Result<Option<Selection>> {
    select_with_runner(default_runner(), entries, "smux> ", bindings)
}

pub fn select_value(prompt: &str, choices: Vec<Choice>) -> Result<Option<String>> {
    select_value_with_runner(default_runner(), prompt, choices)
}

struct TempInputFile {
    path: PathBuf,
}

impl TempInputFile {
    fn new(contents: &str) -> Result<Self> {
        let mut path = std::env::temp_dir();
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .context("system clock should be after unix epoch")?
            .as_nanos();
        path.push(format!("smux-fzf-{}-{nanos}.tsv", std::process::id()));
        fs::write(&path, contents)
            .with_context(|| format!("failed to write {}", path.display()))?;
        Ok(Self { path })
    }

    fn shell_quoted_path(&self) -> String {
        shell_quote(&self.path)
    }
}

impl Drop for TempInputFile {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.path);
    }
}

fn shell_quote(path: &Path) -> String {
    let value = path.to_string_lossy();
    format!("'{}'", value.replace('\'', "'\\''"))
}

fn cat_command(file: &TempInputFile) -> String {
    format!("cat {}", file.shell_quoted_path())
}

fn filter_command(file: &TempInputFile, kind: &str) -> String {
    if kind == "project" {
        format!(
            "awk -F '\\t' '$1 == \"project\" || $1 == \"project-broken\"' {}",
            file.shell_quoted_path()
        )
    } else {
        format!(
            "awk -F '\\t' '$1 == \"{kind}\"' {}",
            file.shell_quoted_path()
        )
    }
}

fn add_common_picker_args(args: &mut Vec<String>, prompt: &str, header: &str, bindings: &str) {
    args.extend([
        "--ansi".to_owned(),
        "--delimiter".to_owned(),
        "\t".to_owned(),
        "--layout".to_owned(),
        "reverse".to_owned(),
        "--header".to_owned(),
        header.to_owned(),
        "--bind".to_owned(),
        "tab:down,btab:up".to_owned(),
        "--bind".to_owned(),
        bindings.to_owned(),
        "--with-nth".to_owned(),
        "3".to_owned(),
        "--nth".to_owned(),
        "1,2".to_owned(),
        "--prompt".to_owned(),
        prompt.to_owned(),
        "--no-sort".to_owned(),
    ]);
}

fn select_value_with_runner(
    runner: Arc<dyn CommandRunner>,
    prompt: &str,
    choices: Vec<Choice>,
) -> Result<Option<String>> {
    let mut args = Vec::new();
    let input = choices
        .into_iter()
        .map(|choice| choice.encode())
        .collect::<Vec<_>>()
        .join("\n")
        + "\n";
    let input_file = TempInputFile::new(&input)?;
    let all_command = cat_command(&input_file);
    let template_command = filter_command(&input_file, "template");
    add_common_picker_args(
        &mut args,
        prompt,
        "ctrl-x all  ctrl-t templates",
        &format!(
            "ctrl-x:change-prompt(template> )+clear-query+reload({all_command}),ctrl-t:change-prompt(template> )+clear-query+reload({template_command})"
        ),
    );
    let output = runner
        .run_capture_with_input("fzf", &args, &input)
        .context("failed to launch fzf")?;

    if output.status.code == Some(130) {
        return Ok(None);
    }

    if !output.status.success {
        bail!("fzf exited with status {:?}", output.status.code);
    }

    let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
    let selection = selection.trim_end();

    if selection.is_empty() {
        return Ok(None);
    }

    Ok(Some(Choice::decode(selection)?.value))
}

fn select_with_runner(
    runner: Arc<dyn CommandRunner>,
    entries: Vec<Entry>,
    prompt: &str,
    bindings: &PickerBindings,
) -> Result<Option<Selection>> {
    let mut args = Vec::new();
    let input = entries
        .into_iter()
        .map(|entry| entry.encode())
        .collect::<Vec<_>>()
        .join("\n")
        + "\n";
    let input_file = TempInputFile::new(&input)?;
    let all_command = cat_command(&input_file);
    let session_command = filter_command(&input_file, "session");
    let folder_command = filter_command(&input_file, "folder");
    let project_command = filter_command(&input_file, "project");
    add_common_picker_args(
        &mut args,
        prompt,
        &format!(
            "enter open  {delete} kill session  {reset} all  {sessions} sessions  {folders} folders  {projects} projects",
            delete = bindings.delete_session,
            reset = bindings.reset,
            sessions = bindings.sessions,
            folders = bindings.folders,
            projects = bindings.projects,
        ),
        &format!(
            "{reset}:change-prompt(smux> )+clear-query+reload({all_command}),{sessions}:change-prompt(session> )+clear-query+reload({session_command}),{folders}:change-prompt(folder> )+clear-query+reload({folder_command}),{projects}:change-prompt(project> )+clear-query+reload({project_command})",
            reset = bindings.reset,
            sessions = bindings.sessions,
            folders = bindings.folders,
            projects = bindings.projects,
        ),
    );
    args.extend(["--expect".to_owned(), bindings.delete_session.clone()]);
    let output = runner
        .run_capture_with_input("fzf", &args, &input)
        .context("failed to launch fzf")?;

    if output.status.code == Some(130) {
        return Ok(None);
    }

    if !output.status.success {
        bail!("fzf exited with status {:?}", output.status.code);
    }

    let selection = String::from_utf8(output.stdout).context("fzf output was not valid utf-8")?;
    let selection = selection.trim_end();

    if selection.is_empty() {
        return Ok(None);
    }

    let mut lines = selection.lines();
    let first = lines
        .next()
        .context("fzf selection output was unexpectedly empty")?;
    let (action, encoded_entry) = match lines.next() {
        Some(encoded_entry) if !first.is_empty() => {
            let action = match first {
                key if key == bindings.delete_session => SelectAction::Delete,
                other => bail!("unknown picker action: {other}"),
            };
            (action, encoded_entry)
        }
        Some(encoded_entry) => (SelectAction::Open, encoded_entry),
        None => (SelectAction::Open, first),
    };

    Ok(Some(Selection {
        action,
        entry: Entry::decode(encoded_entry)?,
    }))
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use crate::process::{CommandOutput, CommandStatus, FakeCommandRunner};

    use super::{
        Choice, Entry, EntryKind, SelectAction, select_value_with_runner, select_with_runner,
    };
    use crate::config::{IconMode, PickerBindings};
    use crate::ui::DisplayStyle;

    #[test]
    fn entry_round_trip() {
        let entry = Entry {
            kind: EntryKind::Directory,
            label: "dir      /tmp/example".to_owned(),
            value: "/tmp/example".to_owned(),
        };

        let decoded = Entry::decode(&entry.encode()).expect("entry should decode");
        assert_eq!(decoded, entry);
    }

    #[test]
    fn selector_passes_entries_to_fzf() {
        let runner = Arc::new(FakeCommandRunner::new());
        runner.push_capture(Ok(CommandOutput {
            status: CommandStatus {
                success: true,
                code: Some(0),
            },
            stdout: b"folder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
            stderr: Vec::new(),
        }));

        let result = select_with_runner(
            runner.clone(),
            vec![Entry::directory(
                DisplayStyle::from_icon_mode(IconMode::Never),
                "/tmp/example".to_owned(),
            )],
            "smux> ",
            &PickerBindings::default(),
        )
        .expect("selection should succeed");

        assert!(result.is_some());
        let recorded = runner.recorded();
        assert_eq!(recorded[0].program, "fzf");
        assert!(recorded[0].args.contains(&"--ansi".to_owned()));
        assert!(recorded[0].args.contains(&"reverse".to_owned()));
        assert!(recorded[0].args.contains(&"3".to_owned()));
        assert!(recorded[0].args.contains(&"1,2".to_owned()));
        assert!(recorded[0].args.contains(&"--expect".to_owned()));
        assert!(recorded[0].args.contains(&"ctrl-x".to_owned()));
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("enter open  ctrl-x kill session"))
        );
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("ctrl-c:change-prompt(smux> )+clear-query+reload("))
        );
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("ctrl-p projects"))
        );
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("ctrl-p:change-prompt(project> )+clear-query+reload("))
        );
        assert_eq!(
            recorded[0].stdin.as_deref(),
            Some("folder\t/tmp/example\tdir      /tmp/example\n")
        );
        let selection = result.expect("selection should be present");
        assert_eq!(selection.action, SelectAction::Open);
        assert_eq!(selection.entry.kind, EntryKind::Directory);
    }

    #[test]
    fn selector_supports_delete_action_for_sessions() {
        let runner = Arc::new(FakeCommandRunner::new());
        runner.push_capture(Ok(CommandOutput {
            status: CommandStatus {
                success: true,
                code: Some(0),
            },
            stdout: b"ctrl-x\nsession\tdemo\tsession  demo\n".to_vec(),
            stderr: Vec::new(),
        }));

        let result = select_with_runner(
            runner,
            vec![Entry::session(
                DisplayStyle::from_icon_mode(IconMode::Never),
                "demo".to_owned(),
            )],
            "smux> ",
            &PickerBindings::default(),
        )
        .expect("selection should succeed")
        .expect("selection should be present");

        assert_eq!(result.action, SelectAction::Delete);
        assert_eq!(result.entry.kind, EntryKind::Session);
        assert_eq!(result.entry.value, "demo");
    }

    #[test]
    fn selector_treats_empty_expect_key_as_open() {
        let runner = Arc::new(FakeCommandRunner::new());
        runner.push_capture(Ok(CommandOutput {
            status: CommandStatus {
                success: true,
                code: Some(0),
            },
            stdout: b"\nfolder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
            stderr: Vec::new(),
        }));

        let result = select_with_runner(
            runner,
            vec![Entry::directory(
                DisplayStyle::from_icon_mode(IconMode::Never),
                "/tmp/example".to_owned(),
            )],
            "smux> ",
            &PickerBindings::default(),
        )
        .expect("selection should succeed")
        .expect("selection should be present");

        assert_eq!(result.action, SelectAction::Open);
        assert_eq!(result.entry.kind, EntryKind::Directory);
        assert_eq!(result.entry.value, "/tmp/example");
    }

    #[test]
    fn selector_uses_configured_picker_bindings() {
        let runner = Arc::new(FakeCommandRunner::new());
        runner.push_capture(Ok(CommandOutput {
            status: CommandStatus {
                success: true,
                code: Some(0),
            },
            stdout: b"\nfolder\t/tmp/example\tdir      /tmp/example\n".to_vec(),
            stderr: Vec::new(),
        }));

        let bindings = PickerBindings {
            reset: "alt-a".to_owned(),
            sessions: "alt-s".to_owned(),
            folders: "alt-f".to_owned(),
            projects: "alt-p".to_owned(),
            delete_session: "alt-x".to_owned(),
        };

        let _ = select_with_runner(
            runner.clone(),
            vec![Entry::directory(
                DisplayStyle::from_icon_mode(IconMode::Never),
                "/tmp/example".to_owned(),
            )],
            "smux> ",
            &bindings,
        )
        .expect("selection should succeed");

        let recorded = runner.recorded();
        assert!(recorded[0].args.contains(&"alt-x".to_owned()));
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("alt-a:change-prompt(smux> )+clear-query+reload("))
        );
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("alt-s:change-prompt(session> )+clear-query+reload("))
        );
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("alt-f:change-prompt(folder> )+clear-query+reload("))
        );
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("alt-p:change-prompt(project> )+clear-query+reload("))
        );
    }

    #[test]
    fn template_selector_returns_selected_value() {
        let runner = Arc::new(FakeCommandRunner::new());
        runner.push_capture(Ok(CommandOutput {
            status: CommandStatus {
                success: true,
                code: Some(0),
            },
            stdout: b"template\trust\ttemplate rust\n".to_vec(),
            stderr: Vec::new(),
        }));

        let result = select_value_with_runner(
            runner.clone(),
            "template> ",
            vec![
                Choice::new(
                    "template",
                    "template default".to_owned(),
                    "default".to_owned(),
                ),
                Choice::new("template", "template rust".to_owned(), "rust".to_owned()),
            ],
        )
        .expect("selection should succeed");

        assert_eq!(result.as_deref(), Some("rust"));
        let recorded = runner.recorded();
        assert!(recorded[0].args.contains(&"--ansi".to_owned()));
        assert!(recorded[0].args.contains(&"reverse".to_owned()));
        assert!(recorded[0].args.contains(&"3".to_owned()));
        assert!(recorded[0].args.contains(&"1,2".to_owned()));
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("ctrl-t templates"))
        );
        assert!(
            recorded[0]
                .args
                .iter()
                .any(|arg| arg.contains("ctrl-t:change-prompt(template> )+clear-query+reload("))
        );
        assert_eq!(
            recorded[0].stdin.as_deref(),
            Some("template\tdefault\ttemplate default\ntemplate\trust\ttemplate rust\n")
        );
    }
}
