use clap::{Parser, ValueEnum};

use crate::state::ThemeName;

#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
#[clap(rename_all = "lower")]
pub enum ViewMode {
    Split,
    Unified,
}

#[derive(Parser, Debug)]
#[command(name = "shuire", version, about = "Vim-like TUI git diff viewer")]
pub struct Args {
    /// Tick rate (events per second).
    #[arg(long, default_value_t = 4.0)]
    pub tick_rate: f64,

    /// Frame rate (frames per second).
    #[arg(long, default_value_t = 60.0)]
    pub frame_rate: f64,

    /// Target commit-ish. Special: `.`, `working`, `staged`, `@` (=HEAD).
    #[arg(default_value = "@")]
    pub target: String,

    /// Optional base commit-ish to compare against.
    pub compare_with: Option<String>,

    /// Use merge-base of base and target for the diff range.
    #[arg(long)]
    pub merge_base: bool,

    /// Include untracked files in the diff (only with `.` or `working`).
    #[arg(long)]
    pub include_untracked: bool,

    /// Number of context lines around changes (default: git's default of 3).
    #[arg(long, short = 'U')]
    pub context: Option<u32>,

    /// Color theme: classic, washi, sumi (朱入レ themes), or legacy dark/light/deuteranopia.
    /// (Falls back to config file, then `classic`.)
    #[arg(long, value_enum)]
    pub theme: Option<ThemeName>,

    /// GitHub PR URL to review (requires `gh` CLI).
    #[arg(long)]
    pub pr: Option<String>,

    /// Read unified diff from a file instead of running git (useful for testdata).
    #[arg(long, value_name = "PATH")]
    pub from_file: Option<std::path::PathBuf>,

    /// Initial comments as JSON (repeatable). Format: {"file":"path","line":N,"body":"text"} or array.
    #[arg(long)]
    pub comment: Vec<String>,

    /// Glob patterns to auto-mark files as viewed (repeatable).
    #[arg(long)]
    pub auto_viewed: Vec<String>,

    /// Disable emoji in the file tree (use ASCII markers instead).
    #[arg(long)]
    pub no_emoji: bool,

    /// Initial diff view mode: `split` (side-by-side) or `unified`.
    #[arg(long, value_enum)]
    pub mode: Option<ViewMode>,

    /// Start with a clean slate by ignoring any previously saved comments for this range.
    #[arg(long)]
    pub clean: bool,

    /// Show shortcut hints row in the footer (overrides config).
    #[arg(long, overrides_with = "no_hints")]
    pub hints: bool,

    /// Hide the shortcut hints row in the footer (overrides config).
    #[arg(long, overrides_with = "hints")]
    pub no_hints: bool,

    /// Skip loading existing PR review comments (only fetch the diff).
    #[arg(long)]
    pub no_pr_comments: bool,
}

impl Args {
    /// Resolve the explicit CLI preference for the footer hints row.
    /// `None` means the user didn't ask — fall back to config.
    pub fn hints_cli(&self) -> Option<bool> {
        if self.no_hints {
            Some(false)
        } else if self.hints {
            Some(true)
        } else {
            None
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputSource {
    Git,
    Pr,
    FromFile,
    Stdin,
}

const SPECIAL_ARGS: [&str; 3] = [".", "working", "staged"];

fn is_special(s: &str) -> bool {
    SPECIAL_ARGS.contains(&s)
}

pub fn validate_commitish(commitish: &str) -> bool {
    let trimmed = commitish.trim();
    if trimmed.is_empty() {
        return false;
    }
    if is_special(trimmed) {
        return true;
    }
    let base = strip_revision_suffix(trimmed);
    if base.is_empty() {
        return false;
    }
    is_valid_commitish_base(base)
}

fn is_valid_commitish_base(base: &str) -> bool {
    // SHA (4-40 hex chars)
    if base.len() >= 4 && base.len() <= 40 && base.chars().all(|c| c.is_ascii_hexdigit()) {
        return true;
    }
    if base == "HEAD" || base == "@" {
        return true;
    }
    is_valid_branch_name(base)
}

/// Strip trailing `^`/`~` and `^N`/`~N` sequences.
fn strip_revision_suffix(s: &str) -> &str {
    let bytes = s.as_bytes();
    let mut end = bytes.len();
    loop {
        if end == 0 {
            break;
        }
        let last = bytes[end - 1];
        if last == b'^' || last == b'~' {
            end -= 1;
            continue;
        }
        if last.is_ascii_digit() {
            let mut digit_start = end - 1;
            while digit_start > 0 && bytes[digit_start - 1].is_ascii_digit() {
                digit_start -= 1;
            }
            if digit_start == 0 {
                break;
            }
            let op = bytes[digit_start - 1];
            if op != b'^' && op != b'~' {
                break;
            }
            end = digit_start - 1;
            continue;
        }
        break;
    }
    &s[..end]
}

fn is_valid_branch_name(name: &str) -> bool {
    if name.starts_with('-')
        || name.ends_with('.')
        || name.contains("..")
        || name.contains("@{")
        || name.contains("//")
        || name.starts_with('/')
        || name.ends_with('/')
        || name.ends_with(".lock")
    {
        return false;
    }
    for ch in name.chars() {
        let c = ch as u32;
        if matches!(ch, '~' | '^' | ':' | '?' | '*' | '[' | '\\') || c <= 0x20 || c == 0x7F {
            return false;
        }
    }
    for component in name.split('/') {
        if component.is_empty() || component.starts_with('.') || component.ends_with(".lock") {
            return false;
        }
    }
    true
}

pub fn validate(args: &Args, source: InputSource) -> Result<(), String> {
    let has_positional = args.target != "@" || args.compare_with.is_some();

    match source {
        InputSource::Pr => {
            if has_positional {
                return Err("--pr cannot be combined with positional arguments".into());
            }
            if args.merge_base {
                return Err("--pr cannot be combined with --merge-base".into());
            }
            if args.context.is_some() {
                return Err("--pr cannot be combined with --context".into());
            }
        }
        InputSource::FromFile | InputSource::Stdin => {
            let label = if source == InputSource::FromFile {
                "--from-file"
            } else {
                "stdin diff"
            };
            if args.merge_base {
                return Err(format!("--merge-base cannot be used with {label}"));
            }
            if args.context.is_some() {
                return Err(format!("--context cannot be used with {label}"));
            }
            if args.pr.is_some() {
                return Err(format!("--pr cannot be used with {label}"));
            }
        }
        InputSource::Git => {
            let target = &args.target;

            // `A...B` three-dot range: validate each side independently.
            if let Some((a, b)) = parse_three_dot(target) {
                if !validate_commitish(a) {
                    return Err(format!("Invalid commit-ish before `...`: {a}"));
                }
                if !validate_commitish(b) {
                    return Err(format!("Invalid commit-ish after `...`: {b}"));
                }
                if args.compare_with.is_some() {
                    return Err(
                        "`A...B` syntax cannot be combined with a second positional argument"
                            .into(),
                    );
                }
                if args.merge_base {
                    return Err("`A...B` syntax cannot be combined with `--merge-base`".into());
                }
                return Ok(());
            }

            if args.merge_base && args.compare_with.is_none() {
                return Err("--merge-base requires a <compare-with> base".into());
            }

            if !validate_commitish(target) {
                return Err(format!("Invalid target commit-ish: {target}"));
            }
            if let Some(base) = &args.compare_with
                && !validate_commitish(base)
            {
                return Err(format!("Invalid base commit-ish: {base}"));
            }
            if let Some(base) = &args.compare_with {
                // `working` vs `staged` is the one meaningful special-vs-special pairing.
                if is_special(base) && !(base == "staged" && target == "working") {
                    return Err(format!(
                        "Special arguments (., working, staged) are only allowed as target, not base. Got base: {base}"
                    ));
                }
                if target == base {
                    return Err(format!("Cannot compare {target} with itself"));
                }
                if target == "working" && base != "staged" {
                    return Err(
                        "\"working\" shows unstaged changes and cannot be compared with another commit. Use \".\" instead to compare all uncommitted changes with a specific commit.".into()
                    );
                }
            }
        }
    }

    Ok(())
}

#[derive(Debug, Clone)]
pub enum DiffRange {
    /// `git diff` (unstaged)
    Working,
    /// `git diff --cached` (HEAD vs index)
    Staged,
    /// `git diff --cached <base>` (staged changes on top of base)
    StagedAgainst { base: String },
    /// `git diff HEAD` (all uncommitted)
    Uncommitted,
    /// `git diff <base>` (all uncommitted on top of base)
    UncommittedAgainst { base: String },
    /// `git diff <base>..<target>`
    Range { base: String, target: String },
    /// `git diff $(git merge-base <base> <mergeBaseTarget>)..<target>`
    MergeBase { base: String, target: String },
    /// Input from stdin pipe
    Stdin,
    /// GitHub PR review. `head_sha` is `None` when the startup lookup failed,
    /// in which case fold expansion surfaces an error instead of silently
    /// falling back to the reviewer's local working tree.
    PullRequest {
        url: String,
        head_sha: Option<String>,
    },
}

pub fn resolve_range(args: &Args) -> DiffRange {
    // `A...B` three-dot range: equivalent to merge-base diff.
    if let Some((a, b)) = parse_three_dot(&args.target) {
        return DiffRange::MergeBase {
            base: normalize(a),
            target: normalize(b),
        };
    }

    let target = normalize(&args.target);

    if let Some(base) = &args.compare_with {
        let base = normalize(base);
        if args.merge_base {
            // First positional arg is the anchor; second is the subject —
            // matching the A...B three-dot convention. Local vars are named
            // for the non-merge-base case, so rename for clarity.
            let (anchor, subject) = (target, base);
            return DiffRange::MergeBase {
                base: anchor,
                target: subject,
            };
        }
        return match target.as_str() {
            "working" => DiffRange::Working, // validation guarantees base == "staged"
            "staged" => DiffRange::StagedAgainst { base },
            "." => DiffRange::UncommittedAgainst { base },
            _ => DiffRange::Range { base, target },
        };
    }

    match target.as_str() {
        "working" => DiffRange::Working,
        "staged" => DiffRange::Staged,
        "." => DiffRange::Uncommitted,
        // `git diff <commit>` semantics: working tree vs that commit.
        _ => DiffRange::UncommittedAgainst { base: target },
    }
}

fn normalize(s: &str) -> String {
    if s == "@" {
        "HEAD".to_string()
    } else {
        s.to_string()
    }
}

/// Split `"A...B"` into `("A", "B")`. Returns `None` if the pattern is not present
/// or either side is empty.
pub fn parse_three_dot(s: &str) -> Option<(&str, &str)> {
    let idx = s.find("...")?;
    let before = s[..idx].trim();
    let after = s[idx + 3..].trim();
    if after.starts_with('.') {
        return None;
    }
    if before.is_empty() || after.is_empty() {
        return None;
    }
    Some((before, after))
}

impl DiffRange {
    pub fn label(&self) -> String {
        match self {
            DiffRange::Working => "working".to_string(),
            DiffRange::Staged => "staged".to_string(),
            DiffRange::StagedAgainst { base } => format!("staged..{base}"),
            DiffRange::Uncommitted => "HEAD..".to_string(),
            DiffRange::UncommittedAgainst { base } => format!("{base}..(uncommitted)"),
            DiffRange::Range { base, target } => format!("{base}..{target}"),
            DiffRange::MergeBase { base, target } => {
                format!("merge-base({base},{target})..{target}")
            }
            DiffRange::Stdin => "stdin".to_string(),
            DiffRange::PullRequest { url, .. } => format!("PR: {url}"),
        }
    }
}

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

    fn default_args() -> Args {
        Args {
            tick_rate: 4.0,
            frame_rate: 60.0,
            target: "@".to_string(),
            compare_with: None,
            merge_base: false,
            include_untracked: false,
            context: None,
            theme: None,
            pr: None,
            from_file: None,
            comment: Vec::new(),
            auto_viewed: Vec::new(),
            no_emoji: false,
            mode: None,
            clean: false,
            hints: false,
            no_hints: false,
            no_pr_comments: false,
        }
    }

    #[test]
    fn validate_commitish_accepts_special() {
        assert!(validate_commitish("."));
        assert!(validate_commitish("working"));
        assert!(validate_commitish("staged"));
        assert!(validate_commitish("@"));
        assert!(validate_commitish("HEAD"));
    }

    #[test]
    fn validate_commitish_accepts_sha() {
        assert!(validate_commitish("abcd"));
        assert!(validate_commitish("abcdef1234567890"));
        assert!(validate_commitish(
            "abcdef1234567890abcdef1234567890abcdef12"
        ));
    }

    #[test]
    fn validate_commitish_accepts_branch_names() {
        assert!(validate_commitish("master"));
        assert!(validate_commitish("feature/foo"));
        assert!(validate_commitish("release-1.0"));
        assert!(validate_commitish("users/alice/wip"));
    }

    #[test]
    fn validate_commitish_accepts_revision_suffixes() {
        assert!(validate_commitish("HEAD~1"));
        assert!(validate_commitish("HEAD^"));
        assert!(validate_commitish("HEAD^^"));
        assert!(validate_commitish("HEAD~~1"));
        assert!(validate_commitish("HEAD~10"));
        assert!(validate_commitish("main^2~3"));
    }

    #[test]
    fn validate_commitish_rejects_invalid_forms() {
        assert!(!validate_commitish(""));
        assert!(!validate_commitish("   "));
        assert!(!validate_commitish("-foo"));
        assert!(!validate_commitish("foo.."));
        assert!(!validate_commitish("foo..bar"));
        assert!(!validate_commitish("foo/"));
        assert!(!validate_commitish("/foo"));
        assert!(!validate_commitish("foo//bar"));
        assert!(!validate_commitish("foo@{}"));
        assert!(!validate_commitish("foo.lock"));
        assert!(!validate_commitish("foo:bar"));
        assert!(!validate_commitish("foo*"));
        assert!(!validate_commitish("with space"));
    }

    #[test]
    fn validate_rejects_special_as_base() {
        let mut args = default_args();
        args.target = "HEAD".to_string();
        args.compare_with = Some("working".to_string());
        assert!(validate(&args, InputSource::Git).is_err());
    }

    #[test]
    fn validate_allows_staged_to_working_pairing() {
        let mut args = default_args();
        args.target = "working".to_string();
        args.compare_with = Some("staged".to_string());
        assert!(validate(&args, InputSource::Git).is_ok());
    }

    #[test]
    fn validate_rejects_target_eq_base() {
        let mut args = default_args();
        args.target = "main".to_string();
        args.compare_with = Some("main".to_string());
        assert!(validate(&args, InputSource::Git).is_err());
    }

    #[test]
    fn validate_rejects_working_vs_other_commit() {
        let mut args = default_args();
        args.target = "working".to_string();
        args.compare_with = Some("main".to_string());
        assert!(validate(&args, InputSource::Git).is_err());
    }

    #[test]
    fn validate_merge_base_requires_base() {
        let mut args = default_args();
        args.merge_base = true;
        assert!(validate(&args, InputSource::Git).is_err());
    }

    #[test]
    fn validate_pr_mode_rejects_combinations() {
        let mut args = default_args();
        args.pr = Some("https://github.com/a/b/pull/1".to_string());
        args.target = "main".to_string(); // a positional arg
        assert!(validate(&args, InputSource::Pr).is_err());

        let mut args = default_args();
        args.pr = Some("https://github.com/a/b/pull/1".to_string());
        args.merge_base = true;
        assert!(validate(&args, InputSource::Pr).is_err());

        let mut args = default_args();
        args.pr = Some("https://github.com/a/b/pull/1".to_string());
        args.context = Some(5);
        assert!(validate(&args, InputSource::Pr).is_err());
    }

    #[test]
    fn validate_from_file_rejects_combinations() {
        let mut args = default_args();
        args.from_file = Some("/tmp/x.diff".into());
        args.merge_base = true;
        assert!(validate(&args, InputSource::FromFile).is_err());

        let mut args = default_args();
        args.from_file = Some("/tmp/x.diff".into());
        args.pr = Some("https://github.com/a/b/pull/1".to_string());
        assert!(validate(&args, InputSource::FromFile).is_err());
    }

    #[test]
    fn resolve_range_defaults_to_working_vs_head() {
        let args = default_args();
        match resolve_range(&args) {
            DiffRange::UncommittedAgainst { base } => assert_eq!(base, "HEAD"),
            other => panic!("expected UncommittedAgainst(HEAD), got {other:?}"),
        }
    }

    #[test]
    fn resolve_range_specials() {
        let mut args = default_args();
        args.target = "working".to_string();
        assert!(matches!(resolve_range(&args), DiffRange::Working));

        let mut args = default_args();
        args.target = "staged".to_string();
        assert!(matches!(resolve_range(&args), DiffRange::Staged));

        let mut args = default_args();
        args.target = ".".to_string();
        assert!(matches!(resolve_range(&args), DiffRange::Uncommitted));
    }

    #[test]
    fn resolve_range_with_base() {
        let mut args = default_args();
        args.target = "feature".to_string();
        args.compare_with = Some("main".to_string());
        match resolve_range(&args) {
            DiffRange::Range { base, target } => {
                assert_eq!(base, "main");
                assert_eq!(target, "feature");
            }
            other => panic!("expected Range, got {other:?}"),
        }
    }

    #[test]
    fn resolve_range_merge_base() {
        // `shuire feature main --merge-base`: first arg ("feature") is the anchor,
        // second arg ("main") is the subject.
        let mut args = default_args();
        args.target = "feature".to_string();
        args.compare_with = Some("main".to_string());
        args.merge_base = true;
        match resolve_range(&args) {
            DiffRange::MergeBase { base, target } => {
                assert_eq!(base, "feature");
                assert_eq!(target, "main");
            }
            other => panic!("expected MergeBase, got {other:?}"),
        }
    }

    #[test]
    fn resolve_range_merge_base_flag_matches_three_dot() {
        // `shuire main feature --merge-base` must produce the same DiffRange as
        // `shuire main...feature` — the first arg is the anchor (base) and the
        // second is the subject (target), mirroring the three-dot convention.
        let mut flag_args = default_args();
        flag_args.target = "main".to_string();
        flag_args.compare_with = Some("feature".to_string());
        flag_args.merge_base = true;

        let mut dot_args = default_args();
        dot_args.target = "main...feature".to_string();

        let (flag_base, flag_target) = match resolve_range(&flag_args) {
            DiffRange::MergeBase { base, target } => (base, target),
            other => panic!("expected MergeBase, got {other:?}"),
        };
        let (dot_base, dot_target) = match resolve_range(&dot_args) {
            DiffRange::MergeBase { base, target } => (base, target),
            other => panic!("expected MergeBase, got {other:?}"),
        };

        assert_eq!(
            flag_base, dot_base,
            "base mismatch between --merge-base and ... syntax"
        );
        assert_eq!(
            flag_target, dot_target,
            "target mismatch between --merge-base and ... syntax"
        );
    }

    #[test]
    fn parse_three_dot_basic() {
        assert_eq!(parse_three_dot("main...feature"), Some(("main", "feature")));
        assert_eq!(parse_three_dot("HEAD...feat/x"), Some(("HEAD", "feat/x")));
        assert_eq!(parse_three_dot("a...b"), Some(("a", "b")));
    }

    #[test]
    fn parse_three_dot_rejects_invalid() {
        assert_eq!(parse_three_dot("a..b"), None); // two dots
        assert_eq!(parse_three_dot("a....b"), None); // four dots
        assert_eq!(parse_three_dot("...b"), None); // empty left
        assert_eq!(parse_three_dot("a..."), None); // empty right
        assert_eq!(parse_three_dot("main"), None); // no dots
    }

    #[test]
    fn resolve_range_three_dot_maps_to_merge_base() {
        let mut args = default_args();
        args.target = "main...feature".to_string();
        match resolve_range(&args) {
            DiffRange::MergeBase { base, target } => {
                assert_eq!(base, "main");
                assert_eq!(target, "feature");
            }
            other => panic!("expected MergeBase, got {other:?}"),
        }
    }

    #[test]
    fn validate_three_dot_rejects_extra_arg() {
        let mut args = default_args();
        args.target = "main...feature".to_string();
        args.compare_with = Some("other".to_string());
        assert!(validate(&args, InputSource::Git).is_err());
    }

    #[test]
    fn validate_three_dot_rejects_merge_base_flag() {
        let mut args = default_args();
        args.target = "main...feature".to_string();
        args.merge_base = true;
        assert!(validate(&args, InputSource::Git).is_err());
    }

    #[test]
    fn diff_range_labels_are_distinct() {
        // Labels feed storage keys and UI; regressions that collapse two distinct
        // ranges onto the same label would mix comments across scopes.
        let labels = [
            DiffRange::Working.label(),
            DiffRange::Staged.label(),
            DiffRange::StagedAgainst {
                base: "main".into(),
            }
            .label(),
            DiffRange::Uncommitted.label(),
            DiffRange::UncommittedAgainst {
                base: "main".into(),
            }
            .label(),
            DiffRange::Range {
                base: "a".into(),
                target: "b".into(),
            }
            .label(),
            DiffRange::MergeBase {
                base: "a".into(),
                target: "b".into(),
            }
            .label(),
            DiffRange::Stdin.label(),
            DiffRange::PullRequest {
                url: "https://github.com/a/b/pull/1".into(),
                head_sha: None,
            }
            .label(),
        ];
        let uniq: std::collections::HashSet<_> = labels.iter().collect();
        assert_eq!(uniq.len(), labels.len(), "labels collided: {labels:?}");
    }
}
