use super::*;
use proptest::prelude::*;

const SHELL_KEYWORDS: &[&str] = &[
    "do", "done", "for", "while", "until", "if", "then", "elif", "else", "fi", "in", "case",
    "esac", "select",
];

fn starts_with_keyword(s: &str) -> bool {
    SHELL_KEYWORDS.iter().any(|kw| {
        s.starts_with(kw)
            && !s
                .as_bytes()
                .get(kw.len())
                .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_')
    })
}

fn arb_shell_word() -> impl Strategy<Value = String> {
    prop::string::string_regex("[a-zA-Z0-9_./-]+")
        .expect("valid regex")
        .prop_filter("must not look like a shell keyword", |s| !starts_with_keyword(s))
}

fn arb_env_name() -> impl Strategy<Value = String> {
    prop::string::string_regex("[A-Z_][A-Z0-9_]{0,5}")
        .expect("valid regex")
}

fn arb_word(depth: u32) -> BoxedStrategy<Word> {
    prop::collection::vec(arb_word_part(depth), 1..3)
        .prop_map(Word)
        .boxed()
}

fn arb_word_part(depth: u32) -> BoxedStrategy<WordPart> {
    let leaf = prop_oneof![
        arb_shell_word().prop_map(WordPart::Lit),
        arb_shell_word().prop_map(WordPart::SQuote),
        prop::char::range('a', 'z').prop_map(WordPart::Escape),
    ];

    if depth == 0 {
        return leaf.boxed();
    }

    prop_oneof![
        3 => arb_shell_word().prop_map(WordPart::Lit),
        2 => arb_shell_word().prop_map(WordPart::SQuote),
        1 => arb_dq_word(depth - 1).prop_map(WordPart::DQuote),
        1 => arb_script(depth - 1).prop_map(WordPart::CmdSub),
    ]
    .boxed()
}

fn arb_dq_word(depth: u32) -> BoxedStrategy<Word> {
    prop::collection::vec(arb_dq_part(depth), 1..3)
        .prop_map(Word)
        .boxed()
}

fn arb_dq_part(depth: u32) -> BoxedStrategy<WordPart> {
    let leaf = prop_oneof![
        arb_shell_word().prop_map(WordPart::Lit),
        prop_oneof![Just('"'), Just('\\'), Just('$'), Just('`')].prop_map(WordPart::Escape),
    ];

    if depth == 0 {
        return leaf.boxed();
    }

    prop_oneof![
        3 => arb_shell_word().prop_map(WordPart::Lit),
        1 => arb_script(depth - 1).prop_map(WordPart::CmdSub),
    ]
    .boxed()
}

fn arb_heredoc_delimiter() -> impl Strategy<Value = String> {
    prop::string::string_regex("[A-Z_][A-Z0-9_]{0,5}").expect("valid regex")
}

fn arb_redir() -> BoxedStrategy<Redir> {
    prop_oneof![
        (0..3u32, arb_word(0), any::<bool>()).prop_map(|(fd, target, append)| {
            Redir::Write { fd, target, append }
        }),
        (0..3u32, arb_word(0)).prop_map(|(fd, target)| Redir::Read { fd, target }),
        arb_word(0).prop_map(Redir::HereStr),
        (arb_heredoc_delimiter(), any::<bool>()).prop_map(|(delimiter, strip_tabs)| {
            Redir::HereDoc { delimiter, strip_tabs }
        }),
        (0..3u32, prop_oneof!["0", "1", "2", "-"].prop_map(String::from))
            .prop_map(|(src, dst)| Redir::DupFd { src, dst }),
    ]
    .boxed()
}

fn arb_simple_cmd(depth: u32) -> BoxedStrategy<SimpleCmd> {
    let word_strat = arb_word(depth);
    (
        prop::collection::vec((arb_env_name(), arb_word(0)), 0..2),
        prop::collection::vec(word_strat, 1..4),
        prop::collection::vec(arb_redir(), 0..2),
    )
        .prop_map(|(env, words, redirs)| SimpleCmd { env, words, redirs })
        .boxed()
}

fn arb_cmd(depth: u32) -> BoxedStrategy<Cmd> {
    if depth == 0 {
        return arb_simple_cmd(0).prop_map(Cmd::Simple).boxed();
    }

    prop_oneof![
        4 => arb_simple_cmd(depth).prop_map(Cmd::Simple),
        1 => arb_script(depth - 1).prop_map(Cmd::Subshell),
        1 => (
            arb_env_name(),
            prop::collection::vec(arb_word(0), 1..3),
            arb_script(depth - 1),
        ).prop_map(|(var, items, body)| Cmd::For { var, items, body }),
        1 => (arb_script(depth - 1), arb_script(depth - 1))
            .prop_map(|(cond, body)| Cmd::While { cond, body }),
        1 => (
            prop::collection::vec(
                (arb_script(depth - 1), arb_script(depth - 1))
                    .prop_map(|(cond, body)| Branch { cond, body }),
                1..3,
            ),
            prop::option::of(arb_script(depth - 1)),
        ).prop_map(|(branches, else_body)| Cmd::If { branches, else_body }),
    ]
    .boxed()
}

fn arb_pipeline(depth: u32) -> BoxedStrategy<Pipeline> {
    (any::<bool>(), prop::collection::vec(arb_cmd(depth), 1..3))
        .prop_map(|(bang, commands)| Pipeline { bang, commands })
        .boxed()
}

fn arb_list_op() -> impl Strategy<Value = ListOp> {
    prop_oneof![
        Just(ListOp::And),
        Just(ListOp::Or),
        Just(ListOp::Semi),
        Just(ListOp::Amp),
    ]
}

fn arb_stmt(depth: u32) -> BoxedStrategy<Stmt> {
    (arb_pipeline(depth), prop::option::of(arb_list_op()))
        .prop_map(|(pipeline, op)| Stmt { pipeline, op })
        .boxed()
}

fn arb_script(depth: u32) -> BoxedStrategy<Script> {
    prop::collection::vec(arb_stmt(depth), 1..3)
        .prop_map(|mut stmts| {
            let len = stmts.len();
            for (i, stmt) in stmts.iter_mut().enumerate() {
                if i == len - 1 {
                    stmt.op = None;
                } else if stmt.op.is_none() {
                    stmt.op = Some(ListOp::Semi);
                }
            }
            Script(stmts)
        })
        .boxed()
}

fn arb_dev_null_word() -> impl Strategy<Value = Word> {
    Just(Word(vec![WordPart::Lit("/dev/null".to_string())]))
}

fn arb_safe_redir() -> BoxedStrategy<Redir> {
    prop_oneof![
        (0..3u32, arb_dev_null_word(), any::<bool>()).prop_map(|(fd, target, append)| {
            Redir::Write { fd, target, append }
        }),
        (0..3u32, arb_dev_null_word()).prop_map(|(fd, target)| Redir::Read { fd, target }),
        arb_word(0).prop_map(Redir::HereStr),
        (arb_heredoc_delimiter(), any::<bool>()).prop_map(|(delimiter, strip_tabs)| {
            Redir::HereDoc { delimiter, strip_tabs }
        }),
        (0..3u32, prop_oneof!["0", "1", "2"].prop_map(String::from))
            .prop_map(|(src, dst)| Redir::DupFd { src, dst }),
    ]
    .boxed()
}

fn unsafe_rm() -> Cmd {
    Cmd::Simple(SimpleCmd {
        env: vec![],
        words: vec![
            Word(vec![WordPart::Lit("rm".into())]),
            Word(vec![WordPart::Lit("-rf".into())]),
            Word(vec![WordPart::Lit("/".into())]),
        ],
        redirs: vec![],
    })
}

fn unsafe_script() -> Script {
    Script(vec![Stmt {
        pipeline: Pipeline {
            bang: false,
            commands: vec![unsafe_rm()],
        },
        op: None,
    }])
}

fn inject_unsafe_into_pipeline(pipeline: &Pipeline, pos: usize) -> Pipeline {
    let mut commands = pipeline.commands.clone();
    let idx = pos % (commands.len() + 1);
    commands.insert(idx, unsafe_rm());
    Pipeline {
        bang: pipeline.bang,
        commands,
    }
}

fn inject_unsafe_into_script(script: &Script, pos: usize) -> Script {
    if script.0.is_empty() {
        return Script(vec![Stmt {
            pipeline: Pipeline {
                bang: false,
                commands: vec![unsafe_rm()],
            },
            op: None,
        }]);
    }
    let stmt_idx = pos % script.0.len();
    let mut stmts = script.0.clone();
    stmts[stmt_idx].pipeline = inject_unsafe_into_pipeline(&stmts[stmt_idx].pipeline, pos / 2);
    Script(stmts)
}

proptest! {
    #[test]
    fn roundtrip(script in arb_script(2)) {
        let normalized = script.normalize();
        let rendered = normalized.to_string();
        let parsed = parse(&rendered);
        prop_assert!(
            parsed.is_some(),
            "failed to parse rendered script: {rendered}"
        );
        prop_assert_eq!(parsed.unwrap(), normalized);
    }

    #[test]
    fn eval_determinism(word in arb_word(2)) {
        let a = word.eval();
        let b = word.eval();
        prop_assert_eq!(a, b);
    }

    #[test]
    fn redirect_safety(redirs in prop::collection::vec(arb_redir(), 1..4)) {
        let result = check::check_redirects(&redirs);
        let expected = redirs.iter().all(|r| match r {
            Redir::Write { target, .. } => target.eval() == "/dev/null",
            _ => true,
        });
        prop_assert_eq!(result, expected);
    }

    #[test]
    fn safe_redirects_always_pass(redirs in prop::collection::vec(arb_safe_redir(), 1..4)) {
        prop_assert!(check::check_redirects(&redirs));
    }

    #[test]
    fn unsafe_sub_detected_in_word(safe_word in arb_shell_word()) {
        let word_with_sub = Word(vec![
            WordPart::Lit(safe_word),
            WordPart::CmdSub(unsafe_script()),
        ]);
        prop_assert!(!check::word_subs_safe(&word_with_sub));
    }

    #[test]
    fn unsafe_sub_in_dquote_detected(safe_word in arb_shell_word()) {
        let word_nested = Word(vec![
            WordPart::DQuote(Word(vec![
                WordPart::Lit(safe_word),
                WordPart::CmdSub(unsafe_script()),
            ])),
        ]);
        prop_assert!(!check::word_subs_safe(&word_nested));
    }

    #[test]
    fn safe_word_no_subs(parts in prop::collection::vec(
        prop_oneof![
            arb_shell_word().prop_map(WordPart::Lit),
            arb_shell_word().prop_map(WordPart::SQuote),
            prop::char::range('a', 'z').prop_map(WordPart::Escape),
        ],
        1..5,
    )) {
        let word = Word(parts);
        prop_assert!(check::word_subs_safe(&word));
    }

    #[test]
    fn unsafe_injected_into_pipeline(
        script in arb_script(1),
        pos in 0..20usize,
    ) {
        let injected = inject_unsafe_into_script(&script, pos);
        prop_assert!(
            !check::is_safe_script(&injected),
            "unsafe command not detected after injection into: {}",
            injected.to_string()
        );
    }

    #[test]
    fn unsafe_in_subshell_detected(script in arb_script(0)) {
        let with_unsafe = Script(vec![
            Stmt {
                pipeline: Pipeline {
                    bang: false,
                    commands: vec![Cmd::Subshell(inject_unsafe_into_script(&script, 0))],
                },
                op: None,
            },
        ]);
        prop_assert!(!check::is_safe_script(&with_unsafe));
    }

    #[test]
    fn unsafe_in_for_body_detected(
        var in arb_env_name(),
        items in prop::collection::vec(arb_word(0), 1..3),
    ) {
        let cmd = Cmd::For {
            var,
            items,
            body: unsafe_script(),
        };
        prop_assert!(!check::is_safe_cmd(&cmd));
    }

    #[test]
    fn unsafe_in_while_body_detected(safe_cond in arb_script(0)) {
        let cmd = Cmd::While {
            cond: safe_cond,
            body: unsafe_script(),
        };
        prop_assert!(!check::is_safe_cmd(&cmd));
    }

    #[test]
    fn unsafe_in_while_cond_detected(safe_body in arb_script(0)) {
        let cmd = Cmd::While {
            cond: unsafe_script(),
            body: safe_body,
        };
        prop_assert!(!check::is_safe_cmd(&cmd));
    }

    #[test]
    fn unsafe_in_if_body_detected(safe_cond in arb_script(0)) {
        let cmd = Cmd::If {
            branches: vec![Branch {
                cond: safe_cond,
                body: unsafe_script(),
            }],
            else_body: None,
        };
        prop_assert!(!check::is_safe_cmd(&cmd));
    }

    #[test]
    fn unsafe_in_if_cond_detected(safe_body in arb_script(0)) {
        let cmd = Cmd::If {
            branches: vec![Branch {
                cond: unsafe_script(),
                body: safe_body,
            }],
            else_body: None,
        };
        prop_assert!(!check::is_safe_cmd(&cmd));
    }

    #[test]
    fn unsafe_in_else_detected(safe_cond in arb_script(0), safe_body in arb_script(0)) {
        let cmd = Cmd::If {
            branches: vec![Branch {
                cond: safe_cond,
                body: safe_body,
            }],
            else_body: Some(unsafe_script()),
        };
        prop_assert!(!check::is_safe_cmd(&cmd));
    }

    #[test]
    fn unsafe_in_for_items_sub_detected(
        var in arb_env_name(),
        safe_body in arb_script(0),
    ) {
        let cmd = Cmd::For {
            var,
            items: vec![Word(vec![WordPart::CmdSub(unsafe_script())])],
            body: safe_body,
        };
        prop_assert!(!check::is_safe_cmd(&cmd));
    }

    #[test]
    fn file_redirect_promotes_to_safewrite(
        cmd_word in arb_shell_word(),
        target in arb_shell_word().prop_filter("not /dev/null", |s| s != "/dev/null"),
        fd in 0..3u32,
        append in any::<bool>(),
    ) {
        let cmd = SimpleCmd {
            env: vec![],
            words: vec![Word(vec![WordPart::Lit(cmd_word)])],
            redirs: vec![Redir::Write {
                fd,
                target: Word(vec![WordPart::Lit(target)]),
                append,
            }],
        };
        prop_assert!(!check::check_redirects(&cmd.redirs));
        prop_assert_eq!(
            check::redirect_verdict(&cmd.redirs),
            crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeWrite),
        );
    }

    #[test]
    fn parse_never_panics(input in "[ -~]{0,200}") {
        let _ = parse(&input);
    }

    #[test]
    fn heredoc_always_safe(
        cmd_word in arb_shell_word(),
        delimiter in arb_heredoc_delimiter(),
        strip_tabs in any::<bool>(),
    ) {
        let redir = Redir::HereDoc { delimiter, strip_tabs };
        prop_assert!(check::check_redirects(&[redir]));

        let cmd = SimpleCmd {
            env: vec![],
            words: vec![Word(vec![WordPart::Lit(cmd_word)])],
            redirs: vec![Redir::HereDoc {
                delimiter: "EOF".to_string(),
                strip_tabs: false,
            }],
        };
        prop_assert!(check::check_redirects(&cmd.redirs));
    }

    #[test]
    fn unicode_prefix_never_matches_allowlist(
        prefix in "[\\u{0080}-\\u{FFFF}]{1,3}",
        cmd in "(git|cat|ls|grep)"
    ) {
        let mangled = format!("{prefix}{cmd} --version");
        prop_assert!(!crate::is_safe_command(&mangled),
            "Unicode-prefixed command was approved: {}", mangled);
    }

    #[test]
    fn unicode_suffix_never_matches_allowlist(
        cmd in "(git|cat|ls|grep)",
        suffix in "[\\u{0080}-\\u{FFFF}]{1,3}"
    ) {
        let mangled = format!("{cmd}{suffix} --version");
        prop_assert!(!crate::is_safe_command(&mangled),
            "Unicode-suffixed command was approved: {}", mangled);
    }
}
