use serde::{Deserialize, Serialize};

/// Default alphabet for hint labels
pub const DEFAULT_HINTS_ALPHABET: &str = "jfkdls;ahgurieowpq";

/// Default URL/path regex pattern.
///
/// Ported verbatim from ghostty's `src/config/url.zig`. Requires a regex
/// engine with lookbehind support — rio uses oniguruma via the `onig`
/// crate. Three alternations:
///
/// 1. **Schemed URLs** — `http://`, `https://`, `mailto:`, `file:`, `ssh:`,
///    `magnet:`, `ipfs://`, `gemini://`, etc. IPv6 literals supported.
///    Trailing `.` / `,` and unbalanced parens are excluded via lookbehind.
/// 2. **Rooted or explicitly-relative paths** — `/abs`, `./rel`, `../rel`,
///    `~/x`, `.hidden/x`, `$VAR/x`. Each prefix is guarded by lookbehinds
///    so the `~/` inside `foo~/bar` and the `/` inside `foo/bar` aren't
///    mis-matched. Paths with internal spaces are supported when they
///    contain a dotted filename segment.
/// 3. **Bare relative paths** — `word/.../name.ext`. A dotted segment is
///    required, and lookbehinds prevent matching mid-word starts.
pub const DEFAULT_URL_REGEX: &str = concat!(
 // schemed URLs
    "(?:https?://|mailto:|ftp://|file:|ssh:|git://|ssh://|tel:|magnet:|ipfs://|ipns://|gemini://|gopher://|news:)",
    "(?:",
        r"(?:\[[:0-9a-fA-F]+(?:[:0-9a-fA-F]*)+\](?::[0-9]+)?)",
        "|",
        r"[\w\-.~:/?#@!$&*+,;=%]+(?:[\(\[]\w*[\)\]])?",
    ")+",
    r"(?<![,.])",
    "|",
 // rooted or explicitly-relative paths
    r"(?:\.\./|\./|(?<!\w)~/|(?:[\w][\w\-.]*/)*(?<!\w)\$[A-Za-z_]\w*/|\.[\w][\w\-.]*/|(?<![\w~/])/(?!/))",
    "(?:",
 // Dotted: file-like, allows internal spaces around dotted segments.
        r"(?=[\w\-.~:/?#@!$&*+;=%]*\.)",
        r"[\w\-.~:/?#@!$&*+;=%]+",
        r"(?:(?<!:) (?!\w+://)(?!\.{0,2}/)(?!~/)[\w\-.~:/?#@!$&*+;=%]*[/.])*",
        r"(?<!:)",
        r"(?: +(?= *$))?",
        "|",
 // Non-dotted: directory-like, broader.
        r"(?![\w\-.~:/?#@!$&*+;=%]*\.)",
        r"[\w\-.~:/?#@!$&*+;=%]+",
        r"(?:(?<!:) (?!\w+://)(?!\.{0,2}/)(?!~/)[\w\-.~:/?#@!$&*+;=%]+)*",
        r"(?<!:)",
        r"(?: +(?= *$))?",
    ")",
    "|",
 // bare relative paths (word/foo.ext)
    r"(?=[\w\-.~:/?#@!$&*+;=%]*\.)",
    r"(?<!\$\d*)(?<!\w)[\w][\w\-.]*/",
    r"[\w\-.~:/?#@!$&*+;=%]+",
    r"(?<!:)",
    r"(?: +(?= *$))?",
);

/// Hints configuration
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Hints {
    /// Characters used for hint labels
    #[serde(default = "default_hints_alphabet")]
    pub alphabet: String,

    /// List of hint rules
    #[serde(default = "default_hints_enabled")]
    pub rules: Vec<Hint>,
}

impl Default for Hints {
    fn default() -> Self {
        Self {
            alphabet: default_hints_alphabet(),
            rules: default_hints_enabled(),
        }
    }
}

/// Individual hint configuration
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hint {
    /// Regex pattern to match
    #[serde(default)]
    pub regex: Option<String>,

    /// Whether to include OSC 8 hyperlinks
    #[serde(default = "default_bool_false")]
    pub hyperlinks: bool,

    /// Whether to apply post-processing to matches
    #[serde(default = "default_bool_true", rename = "post-processing")]
    pub post_processing: bool,

    /// Whether hints persist after selection
    #[serde(default = "default_bool_false")]
    pub persist: bool,

    /// Action to perform when hint is activated
    #[serde(flatten)]
    pub action: HintAction,

    /// Mouse configuration for this hint
    #[serde(default)]
    pub mouse: HintMouse,

    /// Keyboard binding to activate hint mode
    #[serde(default)]
    pub binding: Option<HintBinding>,
}

/// Actions that can be performed with hints
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum HintAction {
    /// Built-in action
    Action { action: HintInternalAction },
    /// Custom command
    Command { command: HintCommand },
}

/// Accepted TOML shapes for a rule's action, all of which resolve to
/// [`HintAction`]:
///
/// ```toml
/// action = "Copy"                                  # inline built-in
/// command = "open"                                 # inline command
/// command = { program = "open", args = ["-R"] }    # command with args
/// [hints.rules.action]                             # nested table, both
/// action = "Copy"                                  # keys accepted here
/// [hints.rules.command]
/// command = "open"
/// ```
///
/// The nested `[hints.rules.action] command = ...` form is what the
/// docs always showed; the serde derive only ever accepted the
/// double-nested spellings, so everything else failed with
/// "missing field action" (#1407).
impl<'de> Deserialize<'de> for HintAction {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum ActionValue {
            Inline(HintInternalAction),
            Nested { action: HintInternalAction },
            NestedCommand { command: HintCommand },
        }

        #[derive(Deserialize)]
        #[serde(untagged)]
        enum CommandValue {
            Inline(HintCommand),
            Nested { command: HintCommand },
        }

        #[derive(Deserialize)]
        struct Raw {
            #[serde(default)]
            action: Option<ActionValue>,
            #[serde(default)]
            command: Option<CommandValue>,
        }

        let raw = Raw::deserialize(deserializer)?;
        match (raw.action, raw.command) {
            (Some(ActionValue::Inline(action)), None)
            | (Some(ActionValue::Nested { action }), None) => {
                Ok(HintAction::Action { action })
            }
            (Some(ActionValue::NestedCommand { command }), None)
            | (None, Some(CommandValue::Inline(command)))
            | (None, Some(CommandValue::Nested { command })) => {
                Ok(HintAction::Command { command })
            }
            (Some(_), Some(_)) => Err(serde::de::Error::custom(
                "hint rule has both `action` and `command`; use one",
            )),
            (None, None) => Err(serde::de::Error::custom(
                "hint rule needs an `action` (e.g. \"Copy\") or a `command`",
            )),
        }
    }
}

/// Built-in hint actions
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HintInternalAction {
    /// Copy the hint text to clipboard
    Copy,
    /// Paste the hint text
    Paste,
    /// Select the hint text
    Select,
    /// Move vi mode cursor to hint
    MoveViModeCursor,
    /// Hand the hint text to the platform's default handler.
    ///
    /// Preferred over naming an opener as a command, because the match
    /// comes from terminal output and is therefore attacker-controlled:
    /// the launcher must not be anything that parses metacharacters.
    Open,
}

/// Custom command configuration
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HintCommand {
    /// Simple command string
    Simple(String),
    /// Command with arguments
    WithArgs {
        program: String,
        #[serde(default)]
        args: Vec<String>,
    },
}

/// Mouse configuration for hints
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HintMouse {
    /// Whether mouse highlighting is enabled
    #[serde(default = "default_bool_true")]
    pub enabled: bool,

    /// Required modifiers for mouse highlighting
    #[serde(default)]
    pub mods: Vec<String>,
}

impl Default for HintMouse {
    fn default() -> Self {
        #[cfg(target_os = "macos")]
        let default_mods = vec!["Super".to_string()];

        #[cfg(not(target_os = "macos"))]
        let default_mods = vec!["Alt".to_string()];

        Self {
            enabled: true,
            mods: default_mods,
        }
    }
}

/// Keyboard binding for hint activation
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HintBinding {
    /// Key to press
    pub key: String,

    /// Required modifiers
    #[serde(default)]
    pub mods: Vec<String>,

    /// Terminal mode requirements
    #[serde(default)]
    pub mode: Vec<String>,
}

// Default functions
fn default_hints_alphabet() -> String {
    DEFAULT_HINTS_ALPHABET.to_string()
}

fn default_hints_enabled() -> Vec<Hint> {
    vec![Hint {
        regex: Some(DEFAULT_URL_REGEX.to_string()),
        hyperlinks: true,
        post_processing: true,
        persist: false,
        action: HintAction::Action {
            action: HintInternalAction::Open,
        },
        mouse: HintMouse::default(),
        binding: Some(HintBinding {
            key: "O".to_string(),
            mods: vec!["Control".to_string(), "Shift".to_string()],
            mode: Vec::new(),
        }),
    }]
}

fn default_bool_true() -> bool {
    true
}

fn default_bool_false() -> bool {
    false
}

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

    #[test]
    fn test_hints_default() {
        let hints = Hints::default();
        assert_eq!(hints.alphabet, DEFAULT_HINTS_ALPHABET);
        assert_eq!(hints.rules.len(), 1);

        let default_hint = &hints.rules[0];
        assert!(default_hint.regex.is_some());
        assert!(default_hint.hyperlinks);
        assert!(default_hint.post_processing);
        assert!(!default_hint.persist);
    }

    /// The default hint must not launch anything through a shell. Hint text
    /// comes from terminal output, so it is attacker-controlled, and a
    /// `cmd /c start` default made `&` in a URL a command separator.
    #[test]
    fn test_default_hint_does_not_go_through_a_shell() {
        let hints = Hints::default();
        assert_eq!(
            hints.rules[0].action,
            HintAction::Action {
                action: HintInternalAction::Open
            },
        );

        let serialized = toml::to_string(&hints).unwrap();
        assert!(
            !serialized.contains("program") && !serialized.contains("cmd"),
            "defaults must name no launcher program: {serialized}"
        );
    }

    /// `Open` has to survive a config write/read cycle, so that writing the
    /// default config back out cannot produce something unparseable.
    #[test]
    fn test_open_action_roundtrips_through_toml() {
        let hint = Hint {
            regex: None,
            hyperlinks: true,
            post_processing: true,
            persist: false,
            action: HintAction::Action {
                action: HintInternalAction::Open,
            },
            mouse: HintMouse::default(),
            binding: None,
        };

        let serialized = toml::to_string(&hint).unwrap();
        let deserialized: Hint = toml::from_str(&serialized).unwrap();
        assert_eq!(hint, deserialized);
    }

    #[test]
    fn test_hint_serialization() {
        let hint = Hint {
            regex: Some("test.*pattern".to_string()),
            hyperlinks: false,
            post_processing: true,
            persist: false,
            action: HintAction::Action {
                action: HintInternalAction::Copy,
            },
            mouse: HintMouse::default(),
            binding: None,
        };

        let serialized = toml::to_string(&hint).unwrap();
        let deserialized: Hint = toml::from_str(&serialized).unwrap();
        assert_eq!(hint, deserialized);
    }

    #[test]
    fn test_default_regex_compiles() {
        onig::Regex::new(DEFAULT_URL_REGEX).expect("default regex must compile");
    }

    /// Given input text, return every leftmost non-overlapping match produced
    /// by `DEFAULT_URL_REGEX`. Used to verify the path branches.
    fn find_all(input: &str) -> Vec<&str> {
        let re = onig::Regex::new(DEFAULT_URL_REGEX).unwrap();
        re.find_iter(input).map(|(s, e)| &input[s..e]).collect()
    }

    #[test]
    fn test_default_regex_matches_schemed_urls() {
        assert_eq!(
            find_all("visit https://rioterm.com here"),
            vec!["https://rioterm.com"]
        );
        assert_eq!(find_all("file://foo"), vec!["file://foo"]);
    }

    #[test]
    fn test_default_regex_matches_rooted_paths() {
        // Dotted paths (file-like): match stops at the next non-dotted token.
        assert_eq!(find_all("open ~/notes.md please"), vec!["~/notes.md"],);
        assert_eq!(find_all("see ./script.sh"), vec!["./script.sh"]);
        assert_eq!(
            find_all("check ../parent/file.txt"),
            vec!["../parent/file.txt"],
        );

        // Non-dotted (directory-like): absorbs trailing spaces+words because
        // the path could be a directory whose name contains spaces (e.g.
        // `~/Desktop please/...`). This matches ghostty's behavior.
        assert_eq!(find_all("open ~/Desktop please"), vec!["~/Desktop please"]);
        assert_eq!(find_all("cd /tmp/foo"), vec!["/tmp/foo"]);
        assert_eq!(find_all("logs at $HOME/logs"), vec!["$HOME/logs"]);
    }

    #[test]
    fn test_default_regex_matches_bare_relative_paths_with_extension() {
        assert_eq!(find_all("edit src/main.rs now"), vec!["src/main.rs"]);
        assert_eq!(
            find_all("see frontends/rioterm/src/hints.rs"),
            vec!["frontends/rioterm/src/hints.rs"]
        );
    }

    #[test]
    fn test_default_regex_rejects_midword_slash() {
        // Lookbehind `(?<![\w~/])/` keeps the `/` inside `foo/bar` from
        // anchoring the rooted-path branch. Branch 3 also fails (no dot).
        assert!(find_all("foo/bar").is_empty());
    }

    #[test]
    fn test_default_regex_rejects_midword_tilde() {
        // Lookbehind `(?<!\w)~/` rejects the `~/bar` inside `foo~/bar`.
        assert!(find_all("foo~/bar").is_empty());
    }

    #[test]
    fn test_default_regex_strips_trailing_punctuation_on_urls() {
        // `(?<![,.])` excludes the trailing period.
        assert_eq!(
            find_all("see https://example.com."),
            vec!["https://example.com"],
        );
    }

    #[test]
    fn test_default_regex_matches_dot_prefixed_paths() {
        // `.config/foo.txt` matches the `.word/` branch (hidden dirs).
        assert_eq!(
            find_all(".config/rio/config.toml"),
            vec![".config/rio/config.toml"]
        );
    }

    #[test]
    fn test_default_regex_prefers_bare_relative_over_embedded_slash() {
        // `Compiling src/config/url.zig` — the bare-relative branch anchors
        // at `src/...` and wins over the rooted `/config/url.zig` because
        // it starts earlier in the text.
        assert_eq!(
            find_all("Compiling src/config/url.zig"),
            vec!["src/config/url.zig"],
        );
    }

    #[test]
    fn test_config_with_hints() {
        use crate::config::Config;

        let config_toml = r#"
[hints]
alphabet = "abcdef"

[[hints.rules]]
regex = "test.*pattern"
hyperlinks = false
post-processing = true
persist = false

[hints.rules.action]
action = "Copy"

[hints.rules.binding]
key = "T"
mods = ["Control"]
"#;

        let config: Config = toml::from_str(config_toml).unwrap();
        assert_eq!(config.hints.alphabet, "abcdef");
        assert_eq!(config.hints.rules.len(), 1);

        let hint = &config.hints.rules[0];
        assert_eq!(hint.regex, Some("test.*pattern".to_string()));
        assert!(!hint.hyperlinks);
        assert!(hint.post_processing);
        assert!(!hint.persist);
    }

    fn parse_rule(rule_body: &str) -> Result<crate::config::Config, toml::de::Error> {
        toml::from_str(&format!("[[hints.rules]]\nregex = \"x\"\n{rule_body}"))
    }

    #[test]
    fn test_hint_action_inline_action() {
        let config = parse_rule("action = \"Copy\"").unwrap();
        assert_eq!(
            config.hints.rules[0].action,
            HintAction::Action {
                action: HintInternalAction::Copy
            }
        );
    }

    #[test]
    fn test_hint_action_inline_command() {
        let config = parse_rule("command = \"open\"").unwrap();
        assert_eq!(
            config.hints.rules[0].action,
            HintAction::Command {
                command: HintCommand::Simple("open".to_string())
            }
        );
    }

    #[test]
    fn test_hint_action_inline_command_with_args() {
        let config =
            parse_rule("command = { program = \"open\", args = [\"-R\"] }").unwrap();
        assert_eq!(
            config.hints.rules[0].action,
            HintAction::Command {
                command: HintCommand::WithArgs {
                    program: "open".to_string(),
                    args: vec!["-R".to_string()],
                }
            }
        );
    }

    #[test]
    fn test_hint_action_documented_nested_command() {
        // The shape the docs always showed: a command inside the
        // `action` table. Regression test for #1407.
        let config = parse_rule("[hints.rules.action]\ncommand = \"open\"").unwrap();
        assert_eq!(
            config.hints.rules[0].action,
            HintAction::Command {
                command: HintCommand::Simple("open".to_string())
            }
        );
    }

    #[test]
    fn test_hint_action_legacy_nested_action() {
        let config = parse_rule("[hints.rules.action]\naction = \"Paste\"").unwrap();
        assert_eq!(
            config.hints.rules[0].action,
            HintAction::Action {
                action: HintInternalAction::Paste
            }
        );
    }

    #[test]
    fn test_hint_action_legacy_nested_command() {
        let config = parse_rule("[hints.rules.command]\ncommand = \"open\"").unwrap();
        assert_eq!(
            config.hints.rules[0].action,
            HintAction::Command {
                command: HintCommand::Simple("open".to_string())
            }
        );
    }

    #[test]
    fn test_hint_action_missing_errors() {
        let err = parse_rule("").unwrap_err().to_string();
        assert!(err.contains("action"), "unhelpful error: {err}");
    }

    #[test]
    fn test_hint_action_both_keys_error() {
        let err = parse_rule("action = \"Copy\"\ncommand = \"open\"")
            .unwrap_err()
            .to_string();
        assert!(err.contains("both"), "unhelpful error: {err}");
    }

    #[test]
    fn test_hint_action_serialize_roundtrip() {
        let action = HintAction::Command {
            command: HintCommand::Simple("open".to_string()),
        };
        let hint = Hint {
            regex: Some("x".to_string()),
            hyperlinks: false,
            post_processing: true,
            persist: false,
            action,
            mouse: HintMouse {
                enabled: true,
                mods: Vec::new(),
            },
            binding: None,
        };
        let serialized = toml::to_string(&hint).unwrap();
        let parsed: Hint = toml::from_str(&serialized).unwrap();
        assert_eq!(parsed.action, hint.action);
    }
}
