//! CLI subcommand implementations (RFC 075, v0.62.0).
//!
//! Entry-point routing and startup paths (`serve`, `serve_dev`) live in
//! `main.rs`; this module contains only the handler functions for each
//! subcommand so that `main.rs` stays below the 500-line threshold.

use anyhow::{bail, Context, Result};
use std::path::PathBuf;
use sui_id::{backup, config::Config};
pub(crate) fn run_backup_subcommand(args: &[String]) -> Result<()> {
    let dest = parse_named_path(args, "--to")
        .context("backup requires --to PATH")?;
    let config_path = parse_config_path(args).unwrap_or_else(|| PathBuf::from("./sui-id.toml"));
    let cfg = Config::load(&config_path)
        .with_context(|| format!("loading config from {}", config_path.display()))?;
    let opts = if args.iter().any(|a| a == "--encrypt") {
        let pass = read_passphrase("Encryption passphrase", true)?;
        backup::BackupOptions {
            passphrase: Some(pass),
        }
    } else {
        backup::BackupOptions::default()
    };
    backup::run_backup(&cfg, &dest, &opts)?;
    if opts.passphrase.is_some() {
        eprintln!(
            "encrypted backup written to {} (mode 0600). Store the passphrase \
             separately from the file — losing it makes the backup unrecoverable.",
            dest.display()
        );
    } else {
        eprintln!(
            "backup written to {} (mode 0600). The archive contains the master key; \
             treat it as a secret. For transport over an untrusted channel, use --encrypt.",
            dest.display()
        );
    }
    Ok(())
}

pub(crate) fn run_restore_subcommand(args: &[String]) -> Result<()> {
    let src = parse_named_path(args, "--from")
        .context("restore requires --from PATH")?;
    let config_path = parse_config_path(args).unwrap_or_else(|| PathBuf::from("./sui-id.toml"));
    let cfg = Config::load(&config_path)
        .with_context(|| format!("loading config from {}", config_path.display()))?;
    let force = args.iter().any(|a| a == "--force");
    let passphrase = if args.iter().any(|a| a == "--decrypt") {
        Some(read_passphrase("Decryption passphrase", false)?)
    } else {
        None
    };
    backup::run_restore(
        &cfg,
        &src,
        &backup::RestoreOptions { force, passphrase },
    )?;
    eprintln!(
        "restored from {} into {} and {}",
        src.display(),
        cfg.storage.db_path.display(),
        cfg.storage.key_file.display()
    );
    Ok(())
}

pub(crate) fn run_verify_backup_subcommand(args: &[String]) -> Result<()> {
    let src = parse_named_path(args, "--from")
        .context("verify-backup requires --from PATH")?;
    let passphrase = if args.iter().any(|a| a == "--decrypt") {
        Some(read_passphrase("Decryption passphrase", false)?)
    } else {
        None
    };
    let report = backup::run_verify(&src, passphrase.as_deref())?;
    println!("Format version: {}", report.manifest.format_version);
    println!("sui-id version: {}", report.manifest.sui_id_version);
    println!("Schema version: {}", report.manifest.schema_version);
    println!("Created at:     {}", report.manifest.created_at);
    println!("Hostname:       {}", report.manifest.hostname);
    println!("Issuer:         {}", report.manifest.issuer);
    println!("Encrypted:      {}", report.encrypted);
    println!("Tar size:       {} bytes", report.tar_bytes);
    println!("Database size:  {} bytes", report.db_bytes);
    println!(
        "Master key:     {}",
        if report.key_present { "present" } else { "MISSING" }
    );
    println!();
    println!("✓ SQLite integrity check passed");
    if report.encrypted {
        println!("✓ Decrypted with provided passphrase");
    }
    Ok(())
}

/// Dispatcher for `sui-id admin <subaction> ...`. Currently the only
/// admin subaction is `unlock-user`, but the surface is shaped to grow.
/// NOTE: async because `main` is `#[tokio::main]` — constructing a second
/// runtime here (`Runtime::new().block_on`) panics with "Cannot start a
/// runtime from within a runtime". Surfaced during RFC 077 smoke testing;
/// the same fix is applied to all async subcommands.
pub(crate) async fn run_admin_subcommand(args: &[String]) -> Result<()> {
    let action = args.get(2).map(String::as_str);
    match action {
        Some("unlock-user") => run_admin_unlock_user(args).await,
        Some("rotate-key") => run_admin_rotate_key(args).await,
        Some(other) => bail!(
            "unknown admin subaction `{other}`. Known subactions: unlock-user, rotate-key"
        ),
        None => bail!(
            "admin requires a subaction. Try: sui-id admin unlock-user --username NAME"
        ),
    }
}

/// `sui-id admin unlock-user --username NAME [--config PATH]`
///
/// Clears `failed_login_count` and `locked_until` on a user without
/// requiring a password verification — the operator's own
/// authority, witnessed by their access to the host's master key
/// material, is what authorises this. Used to recover a real user
/// who's been locked out by a typo storm or by a brute-force
/// attempt that exceeded the auto-unlock window.
pub(crate) async fn run_admin_unlock_user(args: &[String]) -> Result<()> {
    let username = args
        .iter()
        .position(|a| a == "--username")
        .and_then(|i| args.get(i + 1))
        .context("admin unlock-user requires --username NAME")?;
    let config_path = parse_config_path(args).unwrap_or_else(|| PathBuf::from("./sui-id.toml"));
    let cfg = Config::load(&config_path)
        .with_context(|| format!("loading config from {}", config_path.display()))?;

    // Open the database using the same key-resolution logic the
    // server uses (env var > file). No need to spin up the HTTP
    // layer or the clock; we read one row, write one row, exit.
    let resolved = sui_id::keyring::resolve(&cfg.storage.key_file)
        .context("loading master key")?;
    let db = sui_id_store::Database::open(&cfg.storage.db_path, resolved.key)
        .context("opening database")?;

    let user = sui_id_store::repos::users::find_by_username(&db, username).await
        .with_context(|| format!("looking up user {username:?}"))?;
    sui_id_store::repos::users::admin_unlock(&db, user.id).await
        .context("clearing lockout")?;
    // Mirror the operator-facing audit-log entry the live admin UI
    // would write for this action.
    let _ = sui_id_store::repos::audit::append(
        &db,
        &sui_id_store::models::AuditLogRow {
            at: chrono::Utc::now(),
            actor: None, // command-line operator; not a sui-id user
            action: "admin.user.unlock".into(),
            target: Some(user.id.to_string()),
            result: "ok".into(),
            note: Some(format!("unlocked via command line for username={username}")),
        },
    ).await;
    eprintln!("unlocked {username} (id={})", user.id);
    Ok(())
}

/// `sui-id admin rotate-key [--new-key PATH | --generate-new-key] [--yes]
///                          [--config PATH]`
///
/// Re-seal every encrypted column in the database under a new
/// 32-byte XChaCha20-Poly1305 master key. Runs offline: the
/// caller is expected to have stopped the server first, taken
/// a backup of both the DB and the current key file, and only
/// then to invoke this. After completion the operator restarts
/// the server, which picks up the new key from the configured
/// path.
///
/// New key sources:
///   - `--generate-new-key` (default if neither flag is given):
///     CLI generates a fresh 32-byte key from the OS RNG and
///     writes it as base64 to the configured key file path.
///   - `--new-key PATH`: the operator has already prepared a
///     new key file (e.g. via an HSM-backed workflow) and
///     points us at it. The contents are validated as base64-
///     encoded 32 bytes and replace the configured key file.
///
/// In both cases the *previous* file at the configured path is
/// renamed to `<original>.bak.<RFC3339-Z timestamp>` and kept
/// in the same directory. Old files are not auto-deleted; the
/// operator decides when (or whether) to remove them.
///
/// Default flow:
///   1. Print a summary of what will happen and prompt
///      "type yes to continue:". Skip with `--yes` for non-
///      interactive use.
///   2. Open DB under the OLD key.
///   3. Re-seal every encrypted column under the new key in
///      a single SQLite transaction. Failure: rollback, no
///      file rename.
///   4. Rename old key file to `.bak.<timestamp>`.
///   5. Write new key file.
///   6. Print a report of how many rows were re-sealed in each
///      table.
pub(crate) async fn run_admin_rotate_key(args: &[String]) -> Result<()> {
    let config_path = parse_config_path(args).unwrap_or_else(|| PathBuf::from("./sui-id.toml"));
    let cfg = Config::load(&config_path)
        .with_context(|| format!("loading config from {}", config_path.display()))?;

    // Source-of-new-key flag handling. Mutually exclusive: at
    // most one of `--new-key` / `--generate-new-key`. Default is
    // generate.
    let generate_flag = args.iter().any(|a| a == "--generate-new-key");
    let new_key_arg = args
        .iter()
        .position(|a| a == "--new-key")
        .and_then(|i| args.get(i + 1));
    if generate_flag && new_key_arg.is_some() {
        bail!("pass at most one of --generate-new-key, --new-key PATH");
    }
    let provided_new_key_path: Option<PathBuf> = new_key_arg.map(PathBuf::from);

    let yes = args.iter().any(|a| a == "--yes" || a == "-y");

    // Warn the operator and request confirmation unless --yes
    // was passed. Even on --yes we print the summary so it's in
    // the operator's terminal scrollback if anything goes wrong.
    eprintln!(
        "About to rotate the master key for the database at {}.",
        cfg.storage.db_path.display()
    );
    eprintln!("Old key file: {}", cfg.storage.key_file.display());
    if let Some(p) = &provided_new_key_path {
        eprintln!("New key:      {} (provided)", p.display());
    } else {
        eprintln!("New key:      generated by this command");
    }
    eprintln!(
        "After completion the old key file will be renamed to \
        \"<path>.bak.<timestamp>\" and kept in place."
    );
    eprintln!(
        "Make sure the server is stopped and that you have a fresh \
        backup of both the DB and the old key file before continuing."
    );

    if !yes {
        // Confirm via TTY.
        use std::io::{BufRead, Write};
        let mut stderr = std::io::stderr();
        write!(stderr, "Type 'yes' to continue: ").ok();
        stderr.flush().ok();
        let mut line = String::new();
        std::io::stdin()
            .lock()
            .read_line(&mut line)
            .context("reading confirmation from stdin")?;
        if line.trim() != "yes" {
            bail!("aborted (no 'yes' confirmation)");
        }
    }

    // Resolve OLD key from the configured path (or env, same as
    // the server's startup logic).
    let resolved_old = sui_id::keyring::resolve(&cfg.storage.key_file)
        .context("loading old master key")?;
    let db = sui_id_store::Database::open(&cfg.storage.db_path, resolved_old.key)
        .context("opening database with old key")?;

    // Construct the NEW key. Either generate one or read it
    // from the operator-provided file.
    let (new_key, new_key_b64) = if let Some(path) = &provided_new_key_path {
        let raw = std::fs::read_to_string(path)
            .with_context(|| format!("reading --new-key file at {}", path.display()))?;
        let key = sui_id_store::crypto::MasterKey::from_base64(&raw)
            .context("--new-key file must contain a base64-encoded 32-byte key")?;
        let b64 = raw.trim().to_owned();
        (key, b64)
    } else {
        let key = sui_id_store::crypto::MasterKey::generate();
        let b64 = key.to_base64();
        (key, b64)
    };

    // Re-seal everything atomically.
    let report = sui_id_core::key_rotation::rotate_master_key(&db, &new_key).await
        .context("rotating sealed columns under the new key")?;

    // Rename the old key file. Done AFTER the transaction
    // commits so a failure in re-sealing does not leave the
    // operator with a renamed file and an un-rotated DB.
    let now = chrono::Utc::now();
    // Filename-safe RFC3339 (replace ':' which is illegal on
    // Windows and irritating on macOS).
    let stamp = now.format("%Y-%m-%dT%H-%M-%SZ").to_string();
    let old_path = &cfg.storage.key_file;
    let bak_path = {
        let mut p = old_path.clone();
        let mut new_name = p
            .file_name()
            .map(|s| s.to_owned())
            .unwrap_or_else(|| std::ffi::OsString::from("master.key"));
        new_name.push(format!(".bak.{stamp}"));
        p.set_file_name(new_name);
        p
    };
    std::fs::rename(old_path, &bak_path)
        .with_context(|| format!("renaming {} -> {}", old_path.display(), bak_path.display()))?;

    // Write the new key file at the configured path. Done last
    // so a failure here is a clear "old key is .bak'd, new key
    // not yet at expected path" state — the operator can move
    // the .bak file back manually if needed.
    std::fs::write(old_path, format!("{new_key_b64}\n"))
        .with_context(|| format!("writing new key to {}", old_path.display()))?;

    // Match the permission posture on the new file. Best-effort —
    // not all platforms support 0600, so a failure here is
    // a warning rather than a hard error.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(old_path, std::fs::Permissions::from_mode(0o600));
    }

    let _ = sui_id_store::repos::audit::append(
        &db,
        &sui_id_store::models::AuditLogRow {
            at: now,
            actor: None,
            action: "admin.master_key.rotated".into(),
            target: None,
            result: "ok".into(),
            note: Some(format!(
                "re-sealed {} rows; old key archived at {}",
                report.total(),
                bak_path.display()
            )),
        },
    ).await;

    eprintln!();
    eprintln!("Rotation complete.");
    eprintln!("  signing_keys:               {}", report.signing_keys);
    eprintln!("  refresh_tokens:             {}", report.refresh_tokens);
    eprintln!(
        "  user_totp (secrets):        {}",
        report.user_totp_secrets
    );
    eprintln!(
        "  user_totp (recovery codes): {}",
        report.user_totp_recovery_codes
    );
    eprintln!(
        "  user_webauthn_credentials:  {}",
        report.user_webauthn_credentials
    );
    eprintln!("  smtp_config:                {}", report.smtp_config);
    eprintln!("  total:                      {}", report.total());
    eprintln!();
    eprintln!("Old key file archived at {}", bak_path.display());
    eprintln!("New key file written to  {}", old_path.display());
    eprintln!();
    eprintln!("Restart the server. The new key is in place.");
    Ok(())
}

/// `sui-id setup --admin-username NAME [--admin-email EMAIL]
///               [--admin-display-name NAME] [--config PATH]`
///
/// RFC 077: headless initialization. Creates the first administrator
/// and bootstraps a signing key without the GUI wizard, enabling
/// provisioning via Ansible, cloud-init, Docker entrypoints, etc.
///
/// Password resolution (deliberately no `--admin-password` flag —
/// command-line arguments leak into shell history and `ps` output):
///
///   1. `SUI_ID_ADMIN_PASSWORD` env var, if set — validated at
///      Standard level (12+ chars).
///   2. Otherwise a random 24-char password is generated, stored with
///      `must_change = true`, and printed ONCE to stdout.
///
/// stdout carries only the credential block (machine-capturable);
/// diagnostics go to stderr.
///
/// No setup token: this path requires filesystem access to the
/// database and master key, which already implies control of the
/// instance (same trust model as `admin unlock-user`). The web
/// wizard keeps its boot-time token because it is network-reachable.
pub(crate) async fn run_setup_subcommand(args: &[String]) -> Result<()> {
    run_setup(args).await
}

async fn run_setup(args: &[String]) -> Result<()> {
    fn flag_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
        args.iter()
            .position(|a| a == name)
            .and_then(|i| args.get(i + 1))
            .map(String::as_str)
    }

    let username = flag_value(args, "--admin-username")
        .filter(|s| !s.trim().is_empty())
        .context("setup requires --admin-username NAME")?;
    let email = flag_value(args, "--admin-email");
    let display_name = flag_value(args, "--admin-display-name");

    let config_path = parse_config_path(args).unwrap_or_else(|| PathBuf::from("./sui-id.toml"));
    let cfg = Config::load(&config_path)
        .with_context(|| format!("loading config from {}", config_path.display()))?;

    let resolved = sui_id::keyring::resolve(&cfg.storage.key_file)
        .context("loading master key")?;
    let db = sui_id_store::Database::open(&cfg.storage.db_path, resolved.key)
        .context("opening database")?;

    let clock = sui_id_core::time::system_clock();

    // Password: env var wins; otherwise generate and flag for rotation.
    let env_password = std::env::var("SUI_ID_ADMIN_PASSWORD")
        .ok()
        .filter(|s| !s.is_empty());
    let (password, generated): (zeroize::Zeroizing<String>, bool) = match env_password {
        Some(p) => (zeroize::Zeroizing::new(p), false),
        None => (sui_id_core::setup::generate_admin_password(), true),
    };

    let created = sui_id_core::setup::create_initial_admin_headless(
        &db,
        &clock,
        username,
        &password,
        display_name,
        email,
        /* must_change */ generated,
    )
    .await
    .map_err(|e| match e {
        sui_id_core::errors::CoreError::AlreadyInitialized => {
            anyhow::anyhow!("this instance is already initialized; setup can only run once")
        }
        other => anyhow::anyhow!("{other}"),
    })?;

    let login_url = format!(
        "{}/admin/login",
        cfg.server.issuer.trim_end_matches('/')
    );

    eprintln!("sui-id initialized (admin id={}).", created.user_id);
    println!();
    println!("============ INITIAL ADMIN CREDENTIALS ============");
    println!("  username: {}", created.username);
    if generated {
        println!("  password: {}", password.as_str());
    }
    println!("  login:    {}", login_url);
    println!("===================================================");
    println!();
    if generated {
        println!("This password is shown only once. Change it after first");
        println!("login at /me/security/password.");
    } else {
        println!("Password taken from SUI_ID_ADMIN_PASSWORD.");
    }
    Ok(())
}

/// Read a passphrase from stdin (interactive TTY) or from the
/// `SUI_ID_BACKUP_PASSPHRASE` environment variable (for cron and
/// scripted use). When `confirm` is true and we're on a TTY, the
/// passphrase is asked twice and rejected on mismatch.
pub(crate) fn read_passphrase(prompt: &str, confirm: bool) -> Result<String> {
    if let Ok(env) = std::env::var("SUI_ID_BACKUP_PASSPHRASE") {
        if !env.is_empty() {
            return Ok(env);
        }
    }
    use std::io::{BufRead, Write};
    let stdin = std::io::stdin();
    let mut stderr = std::io::stderr();
    write!(stderr, "{prompt}: ").ok();
    stderr.flush().ok();
    let mut first = String::new();
    stdin.lock().read_line(&mut first).context("reading passphrase")?;
    let first = first.trim_end_matches(['\r', '\n']).to_string();
    if first.is_empty() {
        bail!("passphrase must not be empty");
    }
    if confirm {
        write!(stderr, "{prompt} (again): ").ok();
        stderr.flush().ok();
        let mut second = String::new();
        stdin.lock().read_line(&mut second).context("reading passphrase confirmation")?;
        let second = second.trim_end_matches(['\r', '\n']).to_string();
        if first != second {
            bail!("passphrases did not match");
        }
    }
    Ok(first)
}

pub(crate) fn parse_config_path(args: &[String]) -> Option<PathBuf> {
    parse_named_path(args, "--config")
}

pub(crate) fn parse_named_path(args: &[String], flag: &str) -> Option<PathBuf> {
    let mut iter = args.iter();
    while let Some(a) = iter.next() {
        if a == flag {
            return iter.next().map(PathBuf::from);
        }
        let prefix = format!("{flag}=");
        if let Some(rest) = a.strip_prefix(&prefix) {
            return Some(PathBuf::from(rest));
        }
    }
    None
}

pub(crate) fn print_help() {
    println!(
        "sui-id {ver}

Self-hosted OpenID Connect provider.

USAGE:
    sui-id [--config PATH]
    sui-id backup --to PATH [--config PATH] [--encrypt]
    sui-id restore --from PATH [--config PATH] [--force] [--decrypt]
    sui-id verify-backup --from PATH [--decrypt]
    sui-id admin unlock-user --username NAME [--config PATH]
    sui-id admin rotate-key [--generate-new-key | --new-key PATH] [--yes] [--config PATH]
    sui-id --dev [--dev-bind ADDR] [--dev-db PATH] [--dev-seed PATH]
                 [--dev-admin-password STR] [--dev-client-secret STR]
    sui-id --print-sample-config
    sui-id --version
    sui-id --help

SUBCOMMANDS:
    (none)                   Run the HTTP server.
    backup                   Write a tarball containing a SQLite-consistent
                             snapshot of the database, a copy of the master
                             key file, and a manifest describing the
                             provenance. The output file is mode 0600.
                             With --encrypt, the tarball is wrapped in an
                             XChaCha20-Poly1305 envelope keyed by an
                             Argon2id derivation of a passphrase you supply.
                             Use --encrypt for backups that will leave the
                             trust boundary of the host.
    restore                  Restore a backup tarball into the configured
                             storage paths. Refuses to overwrite existing
                             files unless --force is supplied. Use --decrypt
                             when restoring an encrypted backup.
    verify-backup            Read a backup file and report what it contains
                             without writing anything. Runs a SQLite
                             integrity check on the inner snapshot. Use
                             before a real restore to catch a corrupted or
                             mismatched backup.
    admin unlock-user        Clear an account-lockout state for the given
                             user. Resets the failed-login counter and
                             removes any active lock. Use to recover a real
                             user who's been locked out by a typo storm or
                             whose lockout window hasn't expired yet.
    admin rotate-key         Re-seal every encrypted column under a new
                             32-byte master key. Runs OFFLINE: stop the
                             server first, take a fresh backup, then run
                             this. The previous key file is renamed to
                             '<original>.bak.<timestamp>' beside it; the
                             new key is written to the configured path.
    setup                    Headless initialization (RFC 077). Creates the
                             first administrator and bootstraps a signing
                             key without the GUI wizard. Requires
                             --admin-username; accepts --admin-email and
                             --admin-display-name. The password comes from
                             SUI_ID_ADMIN_PASSWORD if set; otherwise a
                             random one is generated and printed ONCE to
                             stdout (change it after first login). Fails
                             if the instance is already initialized.
                             Use --generate-new-key (default) for the
                             CLI to mint a fresh key, or --new-key PATH
                             to provide one you prepared yourself. Use
                             --yes to skip the interactive confirmation
                             prompt for non-interactive use.
    --dev                    Start sui-id in dev mode for local testing.
                             Skips the setup wizard, opens an in-memory
                             SQLite database under a freshly generated
                             master key, and seeds an admin, two test
                             users, and one OIDC test client. Plaintext
                             credentials are printed to stderr at
                             startup. MUST NOT be used in production.
                             Bind defaults to 127.0.0.1; --dev-bind to
                             a non-loopback address requires explicit
                             'yes' confirmation on stdin.

OPTIONS:
    --config PATH            Path to the TOML configuration file
                             (default: ./sui-id.toml)
    --to PATH                Output path for `backup`.
    --from PATH              Input path for `restore` / `verify-backup`.
    --username NAME          Target username for `admin unlock-user`.
    --new-key PATH           Pre-prepared new key file for `admin rotate-key`.
    --generate-new-key       Have `admin rotate-key` mint a fresh key.
    --yes, -y                Skip the confirmation prompt.
    --force                  Allow `restore` to overwrite existing files.
    --encrypt                Encrypt the backup with a passphrase.
    --decrypt                Treat the input as an encrypted backup.
    --dev-bind ADDR          Listen address for `--dev`
                             (default: 127.0.0.1:8801).
    --dev-db PATH            Pin the dev-mode database to a file path
                             (default: in-memory). The file is
                             truncated each restart.
    --dev-seed PATH          TOML file describing dev-mode admin /
                             users / OIDC clients to seed. See
                             examples/dev-seed.toml.
    --dev-admin-password STR Override the dev-mode admin password
                             (default: 'admin').
    --dev-client-secret STR  Override the first dev-mode client's
                             secret (default: 'test-secret').
    --print-sample-config    Print a sample configuration and exit.
    --version, -V            Print version information and exit.
    --help, -h               Print this help and exit.

ENVIRONMENT:
    SUI_ID_MASTER_KEY        Base64-encoded 32-byte master key.
                             Overrides the key file if set.
    SUI_ID_BACKUP_PASSPHRASE Passphrase for `--encrypt` / `--decrypt`,
                             when running non-interactively (cron, scripts).
    SUI_ID_ADMIN_PASSWORD    Initial admin password for `setup`. If unset,
                             a random password is generated and printed.

DOCUMENTATION:
    See README.md and docs/operators.md for the operator's guide.
",
        ver = env!("CARGO_PKG_VERSION")
    );
}

