//! Security helpers for the skim CLI.
//!
//! Centralises credential scrubbing and safe-display sanitization so that
//! these concerns are not scattered across the `cmd` subtree.

use crate::cmd::git::shared::scrub_credential_url;

/// Flags whose *immediately following* space-separated token is a credential.
///
/// **DB-family semantics**: `-h` maps to `--host` for psql/mysql.  This list
/// is intentionally scoped to the DB family; do NOT reuse it for infra tools
/// where `-h` means `--help`, not `--host`.
///
/// Note: `-P` (port) intentionally omitted — it is not a credential.
const SENSITIVE_FLAGS: &[&str] = &[
    "-p",
    "-U",
    "-u",
    "-h",
    "-W",
    "--password",
    "--user",
    "--username",
    "--host",
];
/// Short flags that may have their value *attached* with no space (e.g. `-pS3cret`).
const ATTACHED_PREFIXES: &[&str] = &["-p", "-u", "-U"];
/// MySQL config-file flags whose value (path) must also be redacted.
const CONFIG_FILE_FLAGS: &[&str] = &["--defaults-file", "--defaults-extra-file"];

/// Classification of a single DB argument token for credential scrubbing.
///
/// Each variant encodes how the token (and possibly the next token) should be
/// handled when building the redacted output string.  `classify_db_token` maps a
/// raw token to one of these actions; `scrub_db_args` drives the state machine.
///
/// The lifetime `'a` is tied to the input token slice so that `flag` and
/// `prefix` can borrow directly from the original argument string rather than
/// allocating new `String` values.  `ATTACHED_PREFIXES` entries are
/// `&'static str`, which coerces into `'a` on assignment.
#[derive(Debug)]
enum DbTokenAction<'a> {
    /// Token is a connection-string URI containing embedded credentials.
    /// Replace the entire token with `[REDACTED_URI]`.
    RedactUri,
    /// Token is `--flag=value` where `flag` is sensitive.
    /// Replace with `{flag}=[REDACTED]`; the `flag` field carries the prefix.
    RedactEqualsValue { flag: &'a str },
    /// Token is an attached short flag (`-pSecret`).
    /// Replace with `{prefix}[REDACTED]`; the `prefix` field carries the short flag.
    RedactAttached { prefix: &'a str },
    /// Token is a standalone sensitive flag (`-p`, `--password`, `--defaults-file`, …).
    /// Keep the flag token as-is, then redact the *next* token.
    RedactNext,
    /// Token carries no credential information; emit it verbatim.
    Preserve,
}

/// Classify a single whitespace-split token for credential scrubbing.
///
/// Returns the [`DbTokenAction`] that `scrub_db_args` should apply to this token.
/// All five classification branches from the original while-loop are preserved
/// exactly, now expressed as a pure function without let-chains.
fn classify_db_token<'a>(tok: &'a str) -> DbTokenAction<'a> {
    // 1. Connection string URIs: postgresql://…@…, postgres://…@…, mysql://…@…
    if (tok.starts_with("postgresql://")
        || tok.starts_with("postgres://")
        || tok.starts_with("mysql://"))
        && tok.contains('@')
    {
        return DbTokenAction::RedactUri;
    }

    // 2. `--flag=value` form (sensitive flags and config-file flags)
    if let Some(eq_pos) = tok.find('=') {
        let flag = &tok[..eq_pos];
        if SENSITIVE_FLAGS.contains(&flag) || CONFIG_FILE_FLAGS.contains(&flag) {
            return DbTokenAction::RedactEqualsValue { flag };
        }
    }

    // 3. Attached short flags: -pPassword, -uroot, -Uadmin (no space, single-dash only)
    if !tok.starts_with("--")
        && let Some(&prefix) = ATTACHED_PREFIXES
            .iter()
            .find(|&&p| tok.starts_with(p) && tok.len() > p.len())
    {
        return DbTokenAction::RedactAttached { prefix };
    }

    // 4. Space-separated sensitive flags and config-file flags
    if SENSITIVE_FLAGS.contains(&tok) || CONFIG_FILE_FLAGS.contains(&tok) {
        return DbTokenAction::RedactNext;
    }

    // 5. Non-sensitive token — preserve verbatim
    DbTokenAction::Preserve
}

/// Scrub credential values from a DB tool argument string.
///
/// DB CLIs accept credentials as flag-value pairs.  This function replaces the
/// value of every sensitive flag with `[REDACTED]` so that analytics labels
/// never persist passwords, usernames, or hostnames to disk.
///
/// # Flags redacted
///
/// | Short form  | Long form        | Tools      |
/// |-------------|------------------|------------|
/// | `-p`        | `--password`     | mysql      |
/// | `-P`        | (none)           | mysql port |
/// | `-U`        | `--username`     | psql       |
/// | `-u`        | `--user`         | mysql      |
/// | `-h`        | `--host`         | psql/mysql |
/// | `-W`        | `--password`     | psql       |
///
/// Both space-separated (`-p S3cret`) and equals-joined (`--password=S3cret`)
/// forms are redacted.
///
/// # Design
///
/// Operates on the pre-joined argument string (one token at a time after
/// splitting on whitespace) because that is what the call site produces.
/// This avoids a separate allocation path for every DB command invocation.
///
/// SQL query arguments (positional, no flag prefix) are preserved verbatim —
/// only known sensitive flag values are redacted.
///
/// Handles:
/// 1. Connection string URIs (`postgresql://user:pass@host`, `mysql://user:pass@host`)
/// 2. `--flag=value` form for sensitive and config-file flags
/// 3. Attached short flags with no space: `-pPassword`, `-uroot`, `-Uadmin`
/// 4. Space-separated sensitive flags: `-p secret`, `--password secret`
/// 5. `--defaults-file` / `--defaults-extra-file` MySQL config file flags
/// 6. `-P` (port) is NOT redacted — it is not a credential
pub(crate) fn scrub_db_args(args: &str) -> String {
    let tokens: Vec<&str> = args.split_whitespace().collect();
    let mut out: Vec<String> = Vec::with_capacity(tokens.len());
    let mut i = 0;

    while i < tokens.len() {
        let tok = tokens[i];
        match classify_db_token(tok) {
            DbTokenAction::RedactUri => {
                out.push("[REDACTED_URI]".to_string());
                i += 1;
            }
            DbTokenAction::RedactEqualsValue { flag } => {
                out.push(format!("{flag}=[REDACTED]"));
                i += 1;
            }
            DbTokenAction::RedactAttached { prefix } => {
                out.push(format!("{prefix}[REDACTED]"));
                i += 1;
            }
            DbTokenAction::RedactNext => {
                out.push(tok.to_string());
                i += 1;
                // Redact the following value token if present.
                if i < tokens.len() {
                    out.push("[REDACTED]".to_string());
                    i += 1;
                }
            }
            DbTokenAction::Preserve => {
                out.push(tok.to_string());
                i += 1;
            }
        }
    }

    out.join(" ")
}

/// Sensitive flags for infra tools: value (space-separated or equals-joined)
/// must be redacted. `-H` / `--header` are handled separately via
/// [`InfraTokenAction::RedactAuthHeader`] because they require inspecting the
/// *following* token to determine whether it is an auth header.
const INFRA_SENSITIVE_FLAGS: &[&str] = &[
    "--token",
    "--password",
    "--secret",
    "--api-key",
    "--access-key",
    "--private-key",
    "--aws-secret-access-key",
];

/// Header flags that introduce a value which may be an auth header.
const INFRA_HEADER_FLAGS: &[&str] = &["-H", "--header"];

/// Classification of a single infra argument token for credential scrubbing.
///
/// Mirrors the [`DbTokenAction`] enum used by `scrub_db_args`, applying the same
/// pure-function decomposition to `scrub_infra_args`.  Each variant encodes
/// what `scrub_infra_args` should emit for this token (and possibly the next).
#[derive(Debug)]
enum InfraTokenAction<'a> {
    /// Token is a credential-bearing URL (`https://TOKEN@host/...`).
    /// Replace the entire token with the scheme + host, stripping the auth part.
    RedactCredentialUrl,
    /// Token is `--flag=value` where `flag` is a sensitive infra flag.
    /// Replace with `{flag}=[REDACTED]`; `flag` carries the prefix.
    RedactEqualsValue { flag: &'a str },
    /// Token is a header flag (`-H` / `--header`).
    /// The *next* token is the header value; if it starts with `Authorization:`
    /// or `Proxy-Authorization:`, redact it and all continuation tokens.
    RedactAuthHeader,
    /// Token is a standalone sensitive flag (`--token`, `--password`, …).
    /// Preserve the flag, then redact the *next* token.
    RedactNext,
    /// Token carries no credential information; emit it verbatim.
    Preserve,
}

/// Classify a single whitespace-split infra token for credential scrubbing.
///
/// Returns the [`InfraTokenAction`] that `scrub_infra_args` should apply.
/// This is a pure function with no side effects — all state transitions live
/// in the caller's while-loop, keeping nesting depth at one level.
fn classify_infra_token<'a>(tok: &'a str) -> InfraTokenAction<'a> {
    // 1. Credential-bearing URLs: https://TOKEN@host/..., git://user@host/...
    //    Delegate detection to the shared regex from git/shared.rs so the two
    //    code paths cannot drift.
    if scrub_credential_url(tok).as_ref() != tok {
        return InfraTokenAction::RedactCredentialUrl;
    }

    // 2. `--flag=value` form: the flag is before `=`.
    if let Some(eq_pos) = tok.find('=') {
        let flag = &tok[..eq_pos];
        if INFRA_SENSITIVE_FLAGS.contains(&flag) {
            return InfraTokenAction::RedactEqualsValue { flag };
        }
    }

    // 3. Header flags: `-H` / `--header`.
    if INFRA_HEADER_FLAGS.contains(&tok) {
        return InfraTokenAction::RedactAuthHeader;
    }

    // 4. Space-separated sensitive flags: `--token TOKEN`.
    if INFRA_SENSITIVE_FLAGS.contains(&tok) {
        return InfraTokenAction::RedactNext;
    }

    // 5. Non-sensitive token — preserve verbatim.
    InfraTokenAction::Preserve
}

/// Scrub credential values from an infra tool argument string.
///
/// Infra tools (`curl`, `wget`, `aws`, `gh`, `kubectl`, `terraform`, `docker`)
/// frequently receive sensitive data as flags or credential URLs that would
/// otherwise persist verbatim to the analytics SQLite database.
///
/// # Flags and patterns redacted
///
/// | Pattern                                     | Tools              |
/// |---------------------------------------------|--------------------|
/// | `https://TOKEN@host/...` credential URLs    | curl, wget, docker |
/// | `-H "Authorization: Bearer TOKEN"`          | curl               |
/// | `-H "Proxy-Authorization: Basic CREDS"`     | curl               |
/// | `--token TOKEN` / `--token=TOKEN`           | gh, kubectl, many  |
/// | `--password TOKEN` / `--password=TOKEN`     | aws, docker        |
/// | `--secret TOKEN` / `--secret=TOKEN`         | terraform, docker  |
/// | `--api-key TOKEN` / `--api-key=TOKEN`       | aws, general       |
/// | `--access-key TOKEN` / `--access-key=TOKEN` | aws                |
/// | `--private-key TOKEN` / `--private-key=TOKEN` | general          |
/// | `--aws-secret-access-key TOKEN/=TOKEN`      | aws                |
///
/// Only the flag *values* are redacted; the flag names are preserved in the
/// label so analytics can still identify which flags were used.
///
/// # Design
///
/// Uses [`InfraTokenAction`] + [`classify_infra_token`] decomposition, matching
/// the [`DbTokenAction`] + `classify_db_token` pattern from `scrub_db_args`.  The
/// state machine in this function's while-loop is kept at one nesting level;
/// all classification logic lives in the pure [`classify_infra_token`] function.
pub(crate) fn scrub_infra_args(args: &str) -> String {
    let tokens: Vec<&str> = args.split_whitespace().collect();
    let mut out: Vec<String> = Vec::with_capacity(tokens.len());
    let mut i = 0;

    while i < tokens.len() {
        let tok = tokens[i];
        match classify_infra_token(tok) {
            InfraTokenAction::RedactCredentialUrl => {
                // Replace only the `<auth>@` portion via scrub_credential_url,
                // preserving the scheme and host for analytics legibility.
                out.push(scrub_credential_url(tok).into_owned());
                i += 1;
            }
            InfraTokenAction::RedactEqualsValue { flag } => {
                out.push(format!("{flag}=[REDACTED]"));
                i += 1;
            }
            InfraTokenAction::RedactAuthHeader => {
                // Emit the header flag itself (`-H` or `--header`), then inspect
                // the following token.  When the shell passes
                // `-H "Authorization: Bearer TOKEN"` the entire value is a single
                // CLI argument; after `args.join(" ")` it splits into multiple
                // tokens: `Authorization:`, `Bearer`, `TOKEN`.  We redact the
                // first token and skip continuation tokens until the next flag
                // or URL-like boundary so the full header value is suppressed.
                out.push(tok.to_string());
                i += 1;
                if i < tokens.len() {
                    let lower = tokens[i].to_lowercase();
                    if lower.starts_with("authorization:")
                        || lower.starts_with("proxy-authorization:")
                    {
                        out.push("[REDACTED]".to_string());
                        i += 1;
                        // Consume continuation tokens (e.g. `Bearer`, `TOKEN`).
                        while i < tokens.len() {
                            let cont = tokens[i];
                            if cont.starts_with('-')
                                || cont.starts_with("http://")
                                || cont.starts_with("https://")
                            {
                                break;
                            }
                            i += 1;
                        }
                    } else {
                        out.push(tokens[i].to_string());
                        i += 1;
                    }
                }
            }
            InfraTokenAction::RedactNext => {
                out.push(tok.to_string());
                i += 1;
                if i < tokens.len() {
                    out.push("[REDACTED]".to_string());
                    i += 1;
                }
            }
            InfraTokenAction::Preserve => {
                out.push(tok.to_string());
                i += 1;
            }
        }
    }

    out.join(" ")
}

/// Sanitize user input for safe display in error messages.
///
/// Filters to printable ASCII characters to prevent terminal escape
/// injection attacks. Non-printable and non-ASCII bytes are replaced
/// with `?`, and the string is truncated to 64 characters.
pub(crate) fn sanitize_for_display(input: &str) -> String {
    input
        .chars()
        .take(64)
        .map(|c| {
            if c.is_ascii_graphic() || c == ' ' {
                c
            } else {
                '?'
            }
        })
        .collect()
}

// ============================================================================
// Tests
// ============================================================================

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

    // ========================================================================
    // classify_db_token tests
    // ========================================================================

    #[test]
    fn test_classify_db_token_uri_with_at_sign() {
        match classify_db_token("postgresql://admin:hunter2@db.prod:5432/myapp") {
            DbTokenAction::RedactUri => {}
            other => panic!("expected RedactUri, got {other:?}"),
        }
        match classify_db_token("mysql://root:password@localhost/db") {
            DbTokenAction::RedactUri => {}
            other => panic!("expected RedactUri, got {other:?}"),
        }
        match classify_db_token("postgres://user:pass@host/db") {
            DbTokenAction::RedactUri => {}
            other => panic!("expected RedactUri, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_db_token_uri_without_at_sign_preserved() {
        match classify_db_token("postgresql://localhost/mydb") {
            DbTokenAction::Preserve => {}
            other => panic!("expected Preserve, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_db_token_equals_password() {
        match classify_db_token("--password=S3cret") {
            DbTokenAction::RedactEqualsValue { flag } => {
                assert_eq!(flag, "--password");
            }
            other => panic!("expected RedactEqualsValue, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_db_token_equals_defaults_file() {
        match classify_db_token("--defaults-file=/home/user/.my.cnf") {
            DbTokenAction::RedactEqualsValue { flag } => {
                assert_eq!(flag, "--defaults-file");
            }
            other => panic!("expected RedactEqualsValue, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_db_token_attached_password() {
        match classify_db_token("-pS3cret") {
            DbTokenAction::RedactAttached { prefix } => {
                assert_eq!(prefix, "-p");
            }
            other => panic!("expected RedactAttached, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_db_token_standalone_sensitive_flag() {
        match classify_db_token("-p") {
            DbTokenAction::RedactNext => {}
            other => panic!("expected RedactNext, got {other:?}"),
        }
        match classify_db_token("--password") {
            DbTokenAction::RedactNext => {}
            other => panic!("expected RedactNext, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_db_token_port_not_redacted() {
        match classify_db_token("-P") {
            DbTokenAction::Preserve => {}
            other => panic!("expected Preserve, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_db_token_non_sensitive_preserved() {
        for tok in &["-e", "SELECT", "1", "--host-name", "localhost"] {
            match classify_db_token(tok) {
                DbTokenAction::Preserve => {}
                other => panic!("expected Preserve for {tok:?}, got {other:?}"),
            }
        }
    }

    #[test]
    fn test_classify_db_token_empty_string() {
        match classify_db_token("") {
            DbTokenAction::Preserve => {}
            other => panic!("expected Preserve for empty string, got {other:?}"),
        }
    }

    // ========================================================================
    // scrub_db_args tests
    // ========================================================================

    #[test]
    fn test_scrub_db_args_mysql_short_password() {
        let input = "-u root -p S3cret -e SELECT 1";
        let result = scrub_db_args(input);
        assert!(
            !result.contains("root"),
            "username after -u must be redacted: {result}"
        );
        assert!(
            !result.contains("S3cret"),
            "password after -p must be redacted: {result}"
        );
        assert!(
            result.contains("[REDACTED]"),
            "redaction marker must appear: {result}"
        );
        assert!(result.contains("SELECT"), "SQL must be preserved: {result}");
    }

    #[test]
    fn test_scrub_db_args_psql_equals_form() {
        let input = "--host=myhost --username=admin -c SELECT 1";
        let result = scrub_db_args(input);
        assert!(
            !result.contains("myhost"),
            "--host=value must be redacted: {result}"
        );
        assert!(
            !result.contains("admin"),
            "--username=value must be redacted: {result}"
        );
        assert!(
            result.contains("--host="),
            "flag name --host must be retained: {result}"
        );
        assert!(
            result.contains("--username="),
            "flag name --username must be retained: {result}"
        );
        assert!(
            result.contains("-c"),
            "non-sensitive flag preserved: {result}"
        );
        assert!(result.contains("SELECT"), "SQL must be preserved: {result}");
    }

    #[test]
    fn test_scrub_db_args_no_credentials_unchanged() {
        let input = "-e SELECT 1 FROM users";
        let result = scrub_db_args(input);
        assert_eq!(result, input, "args with no credentials must be unchanged");
    }

    #[test]
    fn test_scrub_db_args_empty_string() {
        assert_eq!(scrub_db_args(""), "");
    }

    #[test]
    fn test_scrub_db_args_dangling_sensitive_flag() {
        let input = "-c SELECT 1 -p";
        let result = scrub_db_args(input);
        assert!(result.contains("-p"), "dangling flag kept: {result}");
        assert!(
            !result.contains("[REDACTED]"),
            "no token to redact: {result}"
        );
    }

    #[test]
    fn test_scrub_db_args_mysql_attached_password() {
        let input = "-pS3cret -e SELECT 1";
        let result = scrub_db_args(input);
        assert!(
            !result.contains("S3cret"),
            "attached password must be redacted: {result}"
        );
        assert!(
            result.contains("-p[REDACTED]"),
            "redacted form must preserve flag name: {result}"
        );
        assert!(result.contains("SELECT"), "SQL must be preserved: {result}");
    }

    #[test]
    fn test_scrub_db_args_attached_user() {
        let input = "-uroot -pS3cret -e SELECT 1";
        let result = scrub_db_args(input);
        assert!(
            !result.contains("root"),
            "attached username must be redacted: {result}"
        );
        assert!(
            !result.contains("S3cret"),
            "attached password must be redacted: {result}"
        );
        assert!(result.contains("SELECT"), "SQL must be preserved: {result}");
    }

    #[test]
    fn test_scrub_db_args_connection_uri_psql() {
        let input = "postgresql://admin:hunter2@db.prod:5432/myapp -c SELECT 1";
        let result = scrub_db_args(input);
        assert!(
            !result.contains("admin"),
            "username in URI must be redacted: {result}"
        );
        assert!(
            !result.contains("hunter2"),
            "password in URI must be redacted: {result}"
        );
        assert!(
            result.contains("[REDACTED_URI]"),
            "URI redaction marker must appear: {result}"
        );
        assert!(result.contains("SELECT"), "SQL must be preserved: {result}");
    }

    #[test]
    fn test_scrub_db_args_connection_uri_mysql() {
        let input = "mysql://root:password@localhost/db -e SHOW TABLES";
        let result = scrub_db_args(input);
        assert!(
            !result.contains("password"),
            "password in URI must be redacted: {result}"
        );
        assert!(
            result.contains("[REDACTED_URI]"),
            "URI redaction marker must appear: {result}"
        );
        assert!(
            result.contains("SHOW TABLES"),
            "SQL must be preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_db_args_defaults_file_equals() {
        let input = "--defaults-file=/home/user/.my.cnf -e SELECT 1";
        let result = scrub_db_args(input);
        assert!(
            !result.contains("/home/user/.my.cnf"),
            "config file path must be redacted: {result}"
        );
        assert!(
            result.contains("--defaults-file=[REDACTED]"),
            "flag name must be preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_db_args_defaults_file_space() {
        let input = "--defaults-file /home/user/.my.cnf -e SELECT 1";
        let result = scrub_db_args(input);
        assert!(
            !result.contains("/home/user/.my.cnf"),
            "config file path in space-sep form must be redacted: {result}"
        );
        assert!(
            result.contains("--defaults-file"),
            "flag name must be preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_db_args_port_not_redacted() {
        let input = "-P 3306 -e SELECT 1";
        let result = scrub_db_args(input);
        assert!(
            result.contains("3306"),
            "port number must NOT be redacted: {result}"
        );
        assert!(
            !result.contains("[REDACTED]"),
            "no redaction should occur for port: {result}"
        );
    }

    // ========================================================================
    // scrub_infra_args tests
    // ========================================================================

    #[test]
    fn test_scrub_infra_args_token_space_separated() {
        let result = scrub_infra_args("--token mysecrettoken repo list");
        assert!(
            !result.contains("mysecrettoken"),
            "--token value must be redacted: {result}"
        );
        assert!(
            result.contains("--token"),
            "flag name must be preserved: {result}"
        );
        assert!(
            result.contains("[REDACTED]"),
            "redaction marker must appear: {result}"
        );
        assert!(
            result.contains("repo list"),
            "non-sensitive args preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_infra_args_token_equals_form() {
        let result = scrub_infra_args("--token=mysecrettoken repo list");
        assert!(
            !result.contains("mysecrettoken"),
            "--token=value must be redacted: {result}"
        );
        assert!(
            result.contains("--token="),
            "flag name must be preserved: {result}"
        );
        assert!(
            result.contains("[REDACTED]"),
            "redaction marker must appear: {result}"
        );
    }

    #[test]
    fn test_scrub_infra_args_authorization_header() {
        let result =
            scrub_infra_args("-H authorization: Bearer secrettoken https://api.example.com");
        assert!(
            !result.contains("secrettoken"),
            "auth header value must be redacted: {result}"
        );
        assert!(result.contains("-H"), "flag -H must be preserved: {result}");
        assert!(
            result.contains("[REDACTED]"),
            "redaction marker must appear: {result}"
        );
        assert!(
            result.contains("https://api.example.com"),
            "URL must be preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_infra_args_non_auth_header_preserved() {
        let result = scrub_infra_args("-H content-type: application/json https://api.example.com");
        assert!(
            result.contains("content-type: application/json"),
            "non-auth header must NOT be redacted: {result}"
        );
    }

    #[test]
    fn test_scrub_infra_args_password_equals_form() {
        let result = scrub_infra_args("--password=S3cret123 --region us-east-1");
        assert!(
            !result.contains("S3cret123"),
            "--password=value must be redacted: {result}"
        );
        assert!(
            result.contains("us-east-1"),
            "non-sensitive args preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_infra_args_no_sensitive_flags_unchanged() {
        let input = "get pods -n myns --output json";
        let result = scrub_infra_args(input);
        assert_eq!(
            result, input,
            "args with no sensitive flags must be unchanged"
        );
    }

    #[test]
    fn test_scrub_infra_args_empty_string() {
        assert_eq!(scrub_infra_args(""), "");
    }

    // ========================================================================
    // classify_infra_token tests
    // ========================================================================

    #[test]
    fn test_classify_infra_token_credential_url_https() {
        match classify_infra_token("https://ghp_secret@github.com/org/repo.git") {
            InfraTokenAction::RedactCredentialUrl => {}
            other => panic!("expected RedactCredentialUrl, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_infra_token_credential_url_user_password() {
        match classify_infra_token("https://user:hunter2@gitlab.com/org/repo") {
            InfraTokenAction::RedactCredentialUrl => {}
            other => panic!("expected RedactCredentialUrl, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_infra_token_clean_url_preserved() {
        match classify_infra_token("https://api.example.com/v1/resource") {
            InfraTokenAction::Preserve => {}
            other => panic!("expected Preserve for clean URL, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_infra_token_equals_form() {
        match classify_infra_token("--token=mysecret") {
            InfraTokenAction::RedactEqualsValue { flag } => {
                assert_eq!(flag, "--token");
            }
            other => panic!("expected RedactEqualsValue, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_infra_token_header_flag_short() {
        match classify_infra_token("-H") {
            InfraTokenAction::RedactAuthHeader => {}
            other => panic!("expected RedactAuthHeader for -H, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_infra_token_header_flag_long() {
        match classify_infra_token("--header") {
            InfraTokenAction::RedactAuthHeader => {}
            other => panic!("expected RedactAuthHeader for --header, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_infra_token_standalone_sensitive() {
        match classify_infra_token("--token") {
            InfraTokenAction::RedactNext => {}
            other => panic!("expected RedactNext for --token, got {other:?}"),
        }
        match classify_infra_token("--aws-secret-access-key") {
            InfraTokenAction::RedactNext => {}
            other => panic!("expected RedactNext for --aws-secret-access-key, got {other:?}"),
        }
    }

    #[test]
    fn test_classify_infra_token_non_sensitive_preserved() {
        for tok in &["--output", "-n", "myns", "get", "pods"] {
            match classify_infra_token(tok) {
                InfraTokenAction::Preserve => {}
                other => panic!("expected Preserve for {tok:?}, got {other:?}"),
            }
        }
    }

    // ========================================================================
    // scrub_infra_args credential URL tests
    // ========================================================================

    #[test]
    fn test_scrub_infra_args_credential_url_https_token() {
        let result = scrub_infra_args("https://ghp_supersecret@github.com/org/repo.git");
        assert!(
            !result.contains("ghp_supersecret"),
            "token in URL must be scrubbed: {result}"
        );
        assert!(
            result.contains("github.com/org/repo.git"),
            "host+path must be preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_infra_args_credential_url_user_password() {
        let result =
            scrub_infra_args("curl https://user:hunter2@api.example.com/upload --data @file.json");
        assert!(
            !result.contains("hunter2"),
            "password in URL must be scrubbed: {result}"
        );
        assert!(
            !result.contains("user:"),
            "username in URL must be scrubbed: {result}"
        );
        assert!(
            result.contains("api.example.com/upload"),
            "host+path must be preserved: {result}"
        );
        assert!(
            result.contains("--data"),
            "non-sensitive flags must be preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_infra_args_clean_url_not_scrubbed() {
        let input = "curl https://api.example.com/v1/resource -X GET";
        let result = scrub_infra_args(input);
        assert_eq!(result, input, "clean URL must not be modified");
    }

    #[test]
    fn test_scrub_infra_args_credential_url_alongside_token_flag() {
        let result = scrub_infra_args("--token mysecret https://user:pass@api.example.com/v1");
        assert!(
            !result.contains("mysecret"),
            "flag credential must be scrubbed: {result}"
        );
        assert!(
            !result.contains("user:pass"),
            "URL credential must be scrubbed: {result}"
        );
        assert!(
            result.contains("api.example.com/v1"),
            "host+path must be preserved: {result}"
        );
    }

    #[test]
    fn test_scrub_infra_args_dangling_sensitive_flag() {
        // A sensitive flag with no following token must not panic or redact extra tokens.
        let result = scrub_infra_args("kubectl get pods --token");
        assert!(result.contains("--token"), "dangling flag kept: {result}");
        assert!(
            !result.contains("[REDACTED]"),
            "no value to redact: {result}"
        );
    }

    // ========================================================================
    // sanitize_for_display tests
    // ========================================================================

    #[test]
    fn test_sanitize_for_display_clean_input() {
        assert_eq!(sanitize_for_display("hello-world"), "hello-world");
    }

    #[test]
    fn test_sanitize_for_display_rejects_non_ascii() {
        let input = "tool\x1b[31mred\x1b[0m";
        let sanitized = sanitize_for_display(input);
        assert!(!sanitized.contains('\x1b'));
    }

    #[test]
    fn test_sanitize_for_display_truncates_at_64() {
        let long_input = "a".repeat(100);
        let sanitized = sanitize_for_display(&long_input);
        assert_eq!(sanitized.len(), 64);
    }
}
