use std::sync::OnceLock;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RepeatCliMode {
    Off,
    All,
    One,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SearchCliScope {
    Track,
    Album,
    Artist,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PlayerCliCmd {
    NoArgCommand(String),
    PlayOpaqueId(String),
    Seek { delta_secs: i32 },
    Volume { percent: u8 },
    VolumeRelative { delta_percent: i32 },
    Repeat(RepeatCliMode),
    Rating { stars: u8 },
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MixCliMode {
    Append,
    New,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CliCommand {
    Player(PlayerCliCmd),
    AudioDeviceList,
    /// `None` → follow host default output (same as Settings “system default”).
    AudioDeviceSet(Option<String>),
    LibraryList,
    /// `"all"` or a music-folder id from `library list`.
    LibrarySet(String),
    Mix(MixCliMode),
    ServerList,
    ServerSet(String),
    Search {
        scope: SearchCliScope,
        query: String,
    },
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct CliActionRegistryEntry {
    pub(super) command: String,
    pub(super) verb: String,
    pub(super) description: String,
}

fn shortcut_actions_registry_source() -> &'static str {
    include_str!(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/../src/config/shortcutActions.ts"
    ))
}

fn extract_quoted_field(line: &str, key: &str) -> Option<String> {
    let needle = format!("{key}: '");
    let start = line.find(&needle)? + needle.len();
    let tail = &line[start..];
    let end = tail.find('\'')?;
    Some(tail[..end].to_string())
}

fn parse_registry_action_id(line: &str) -> Option<String> {
    let trimmed = line.trim_start();
    if !trimmed.ends_with('{') {
        return None;
    }
    if let Some(rest) = trimmed.strip_prefix('\'') {
        let end = rest.find('\'')?;
        let id = &rest[..end];
        let tail = rest[end + 1..].trim_start();
        if !tail.starts_with(':') {
            return None;
        }
        return Some(id.to_string());
    }
    let brace_idx = trimmed.find(':')?;
    let candidate = trimmed[..brace_idx].trim();
    if candidate.is_empty() || !trimmed[brace_idx + 1..].trim_start().starts_with('{') {
        return None;
    }
    if !candidate
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
    {
        return None;
    }
    Some(candidate.to_string())
}

fn parse_cli_action_registry_entries() -> Vec<CliActionRegistryEntry> {
    let mut entries = Vec::new();
    let mut current_action_id: Option<String> = None;

    for line in shortcut_actions_registry_source().lines() {
        if let Some(id) = parse_registry_action_id(line) {
            current_action_id = Some(id);
            continue;
        }
        let trimmed = line.trim();
        if !trimmed.starts_with("cli: {") {
            continue;
        }
        let Some(action_id) = current_action_id.clone() else {
            continue;
        };
        let Some(verb) = extract_quoted_field(trimmed, "verb") else {
            continue;
        };
        let Some(description) = extract_quoted_field(trimmed, "description") else {
            continue;
        };
        let command = extract_quoted_field(trimmed, "command").unwrap_or_else(|| action_id.clone());
        entries.push(CliActionRegistryEntry {
            command,
            verb,
            description,
        });
    }

    entries
}

pub(super) fn cli_action_registry_entries() -> &'static Vec<CliActionRegistryEntry> {
    static ENTRIES: OnceLock<Vec<CliActionRegistryEntry>> = OnceLock::new();
    ENTRIES.get_or_init(parse_cli_action_registry_entries)
}

pub(super) fn cli_registry_entry_by_verb(verb: &str) -> Option<&'static CliActionRegistryEntry> {
    cli_action_registry_entries().iter().find(|entry| entry.verb == verb)
}

pub(super) fn cli_registry_entry_by_command(command: &str) -> Option<&'static CliActionRegistryEntry> {
    cli_action_registry_entries()
        .iter()
        .find(|entry| entry.command == command)
}

pub fn wants_version(args: &[String]) -> bool {
    args.iter()
        .skip(1)
        .any(|a| a == "--version" || a == "-V")
}

pub fn wants_help(args: &[String]) -> bool {
    args.iter().skip(1).any(|a| a == "--help" || a == "-h")
}

pub fn wants_info(args: &[String]) -> bool {
    args.iter().skip(1).any(|a| a == "--info")
}

pub fn wants_info_json(args: &[String]) -> bool {
    wants_info(args) && args.iter().skip(1).any(|a| a == "--json")
}

pub fn wants_tail(args: &[String]) -> bool {
    args.iter().skip(1).any(|a| a == "--tail")
}

pub fn wants_logs(args: &[String]) -> bool {
    args.iter().skip(1).any(|a| a == "--logs")
}

/// True when argv uses any CLI surface (`--player`, `--info`, `--help`, …), not a plain GUI launch.
pub fn wants_cli_argv(args: &[String]) -> bool {
    wants_version(args)
        || wants_help(args)
        || args.get(1).is_some_and(|a| a == "completions")
        || wants_info(args)
        || wants_logs(args)
        || wants_tail(args)
        || parse_cli_command(args).is_some()
}

pub fn wants_follow(args: &[String]) -> bool {
    args.iter().skip(1).any(|a| a == "-f" || a == "--follow")
}

pub fn logs_tail_lines(args: &[String]) -> Result<Option<usize>, String> {
    let mut i = 1usize;
    while i < args.len() {
        if args[i] == "--tail" {
            let Some(raw) = args.get(i + 1) else {
                return Err("--tail requires a numeric value".to_string());
            };
            if raw.starts_with('-') {
                return Err("--tail requires a positive integer value".to_string());
            }
            let n: usize = raw
                .parse()
                .map_err(|_| "--tail requires a positive integer value".to_string())?;
            if n == 0 {
                return Err("--tail must be greater than 0".to_string());
            }
            return Ok(Some(n));
        }
        i += 1;
    }
    Ok(None)
}

pub fn wants_quiet(args: &[String]) -> bool {
    args.iter()
        .skip(1)
        .any(|a| a == "--quiet" || a == "-q")
}

/// Machine-readable output for `--json` with list/search commands (`audio-device`, `library`, `server`, `search`).
pub fn wants_cli_json_output(args: &[String]) -> bool {
    args.iter().skip(1).any(|a| a == "--json")
}

fn parse_repeat_mode(arg: &str) -> Option<RepeatCliMode> {
    match arg {
        "off" => Some(RepeatCliMode::Off),
        "all" => Some(RepeatCliMode::All),
        "one" => Some(RepeatCliMode::One),
        _ => None,
    }
}

fn parse_volume_cli_arg(raw: &str) -> Option<PlayerCliCmd> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return None;
    }
    if trimmed.starts_with('+') || trimmed.starts_with('-') {
        let delta_percent: i32 = trimmed.parse().ok()?;
        return Some(PlayerCliCmd::VolumeRelative { delta_percent });
    }
    let v: i64 = trimmed.parse().ok()?;
    if !(0..=100).contains(&v) {
        return None;
    }
    Some(PlayerCliCmd::Volume {
        percent: v as u8,
    })
}

fn parse_player_cli_at(args: &[String], pos: usize) -> Option<PlayerCliCmd> {
    let verb = args.get(pos + 1)?.as_str();
    if let Some(entry) = cli_registry_entry_by_verb(verb).filter(|entry| entry.command == "play") {
        return match args.get(pos + 2).map(|s| s.as_str()) {
            None => Some(PlayerCliCmd::NoArgCommand(entry.command.clone())),
            Some(flag) if flag.starts_with('-') => None,
            Some(extra) => {
                if extra.is_empty() {
                    return None;
                }
                Some(PlayerCliCmd::PlayOpaqueId(extra.to_string()))
            }
        };
    }
    match verb {
        "repeat" => {
            let m = parse_repeat_mode(args.get(pos + 2)?.as_str())?;
            Some(PlayerCliCmd::Repeat(m))
        }
        "rating" => {
            let raw = args.get(pos + 2)?;
            let n: u8 = raw.parse().ok()?;
            if n > 5 {
                return None;
            }
            Some(PlayerCliCmd::Rating { stars: n })
        }
        "seek" => {
            let raw = args.get(pos + 2)?;
            let delta_secs: i32 = raw.parse().ok()?;
            Some(PlayerCliCmd::Seek { delta_secs })
        }
        "volume" => parse_volume_cli_arg(args.get(pos + 2)?),
        _ => cli_registry_entry_by_verb(verb)
            .map(|entry| PlayerCliCmd::NoArgCommand(entry.command.clone())),
    }
}

/// Parse transport / playback / device / mix `psysonic --player …` argv.
pub fn parse_cli_command(args: &[String]) -> Option<CliCommand> {
    let pos = args.iter().position(|a| a == "--player")?;
    let verb = args.get(pos + 1)?.as_str();
    match verb {
        "audio-device" => {
            let sub = args.get(pos + 2)?.as_str();
            match sub {
                "list" => Some(CliCommand::AudioDeviceList),
                "set" => {
                    let arg = args.get(pos + 3)?;
                    let name = if arg == "default" {
                        None
                    } else {
                        Some(arg.clone())
                    };
                    Some(CliCommand::AudioDeviceSet(name))
                }
                _ => None,
            }
        }
        "mix" => {
            let sub = args.get(pos + 2)?.as_str();
            match sub {
                "append" => Some(CliCommand::Mix(MixCliMode::Append)),
                "new" => Some(CliCommand::Mix(MixCliMode::New)),
                _ => None,
            }
        }
        "library" => {
            let sub = args.get(pos + 2)?.as_str();
            match sub {
                "list" => Some(CliCommand::LibraryList),
                "set" => {
                    let arg = args.get(pos + 3)?;
                    Some(CliCommand::LibrarySet(arg.clone()))
                }
                _ => None,
            }
        }
        "server" => {
            let sub = args.get(pos + 2)?.as_str();
            match sub {
                "list" => Some(CliCommand::ServerList),
                "set" => {
                    let id = args.get(pos + 3)?;
                    if id.is_empty() {
                        return None;
                    }
                    Some(CliCommand::ServerSet(id.clone()))
                }
                _ => None,
            }
        }
        "search" => {
            let scope_raw = args.get(pos + 2)?.as_str();
            let scope = match scope_raw {
                "track" => SearchCliScope::Track,
                "album" => SearchCliScope::Album,
                "artist" => SearchCliScope::Artist,
                _ => return None,
            };
            let tail = args.get(pos + 3..)?;
            let query = tail.join(" ").trim().to_string();
            if query.is_empty() {
                return None;
            }
            Some(CliCommand::Search { scope, query })
        }
        _ => parse_player_cli_at(args, pos).map(CliCommand::Player),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse_player(args: &[&str]) -> Option<PlayerCliCmd> {
        let argv: Vec<String> = std::iter::once("psysonic")
            .chain(args.iter().copied())
            .map(String::from)
            .collect();
        match parse_cli_command(&argv) {
            Some(CliCommand::Player(cmd)) => Some(cmd),
            _ => None,
        }
    }

    #[test]
    fn volume_absolute_without_sign() {
        assert_eq!(
            parse_player(&["--player", "volume", "50"]),
            Some(PlayerCliCmd::Volume { percent: 50 })
        );
    }

    #[test]
    fn volume_relative_with_sign() {
        assert_eq!(
            parse_player(&["--player", "volume", "+5"]),
            Some(PlayerCliCmd::VolumeRelative { delta_percent: 5 })
        );
        assert_eq!(
            parse_player(&["-q", "--player", "volume", "-10"]),
            Some(PlayerCliCmd::VolumeRelative { delta_percent: -10 })
        );
    }

    #[test]
    fn volume_rejects_out_of_range_absolute() {
        assert_eq!(parse_player(&["--player", "volume", "101"]), None);
    }
}
