//! tidalctl -- command-line inspector for embedded tidalDB instances.
//!
//! Reports WAL state, checkpoint status, and directory layout for a
//! tidalDB data directory without opening or locking any files.
//!
//! Usage:
//!   tidalctl <command> --path <dir> [--pretty]
//!
//! Commands:
//!   status       Report WAL state, checkpoint, and directory layout
//!   paths        Report resolved directory paths and existence
//!   recover      Diagnose WAL state for crash recovery (--verify-only)
//!   diagnostics  Print health summary (human-readable or JSON)
//!   scope-stats  Tally WAL signal events by governance scope (M9)
//!
//! ## Exit-code contract
//!
//! Every command shares one convention so a script can branch on the result:
//!
//! - **0** — ok, or a cleanly empty/absent store (nothing to report is not an
//!   error). A `status`/`diagnostics`/`paths` run against a fresh or missing WAL
//!   directory exits 0.
//! - **1** — usage error (bad command, missing `--path`, unsupported mode) or an
//!   internal serialization failure. Emitted on stderr as an `{"error": ...}`
//!   envelope.
//! - **2** — degraded/unreadable: the store exists but its WAL, checkpoint, or a
//!   derived index could not be read (corruption, partial write, lock). The body
//!   still renders what could be gathered; the non-zero code is the signal that
//!   `tidalctl status --path X && deploy` must NOT treat as success.
//!
//! `status` and `diagnostics` both honor 2 for an unreadable WAL so the two
//! surfaces agree; `recover`/`scope-stats` surface integrity in-band (the report
//! body carries `inconsistency_count` / `complete`).

#![forbid(unsafe_code)]
// tidalctl is a binary split across private submodules; `pub(crate)` on the
// cross-module command/helper items is the correct visibility, so clippy's
// "redundant in a private module" suggestion does not apply here.
#![allow(clippy::redundant_pub_crate)]

use std::{path::PathBuf, process};

mod commands;
mod json;
mod wal_state;

/// Process exit code: store exists but could not be fully read (degraded).
pub(crate) const EXIT_DEGRADED: i32 = 2;

fn main() {
    let args: Vec<String> = std::env::args().collect();

    match run(&args) {
        Ok((output, code)) => {
            println!("{output}");
            if code != 0 {
                process::exit(code);
            }
        }
        Err(e) => {
            eprintln!("{}", e.to_json());
            process::exit(1);
        }
    }
}

// ---------------------------------------------------------------------------
// CLI parsing
// ---------------------------------------------------------------------------

struct CliArgs {
    command: Command,
    path: PathBuf,
    pretty: bool,
    verify_only: bool,
}

enum Command {
    Status,
    Paths,
    Recover,
    Diagnostics,
    ScopeStats,
}

/// A user-facing CLI failure, rendered as an `{"error": ...}` JSON envelope on
/// stderr and mapped to process exit code 1.
pub(crate) struct CliError {
    message: String,
}

impl CliError {
    pub(crate) fn new(message: impl Into<String>) -> Self {
        Self {
            message: message.into(),
        }
    }

    fn to_json(&self) -> String {
        // serde_json owns escaping (control chars, unicode, quotes). If the
        // (infallible-in-practice) serialize of a single String field ever
        // failed, fall back to a minimal hard-coded envelope so the binary
        // still emits *some* valid JSON rather than panicking in the error path.
        #[derive(serde::Serialize)]
        struct ErrorEnvelope<'a> {
            error: &'a str,
        }
        serde_json::to_string(&ErrorEnvelope {
            error: &self.message,
        })
        .unwrap_or_else(|_| r#"{"error":"failed to serialize error message"}"#.to_string())
    }
}

fn parse_args(args: &[String]) -> Result<CliArgs, CliError> {
    if args.len() < 2 {
        return Err(CliError::new(usage()));
    }

    let command = match args[1].as_str() {
        "status" => Command::Status,
        "paths" => Command::Paths,
        "recover" => Command::Recover,
        "diagnostics" => Command::Diagnostics,
        "scope-stats" => Command::ScopeStats,
        "--help" | "-h" | "help" => return Err(CliError::new(usage())),
        other => {
            return Err(CliError::new(format!(
                "unknown command: '{other}'\n{}",
                usage()
            )));
        }
    };

    let mut path: Option<PathBuf> = None;
    let mut pretty = false;
    let mut verify_only = false;
    let mut i = 2;

    while i < args.len() {
        match args[i].as_str() {
            "--path" => {
                i += 1;
                if i >= args.len() {
                    return Err(CliError::new("--path requires a value"));
                }
                path = Some(PathBuf::from(&args[i]));
            }
            "--pretty" => {
                pretty = true;
            }
            "--verify-only" => {
                verify_only = true;
            }
            other => {
                return Err(CliError::new(format!("unknown flag: '{other}'")));
            }
        }
        i += 1;
    }

    let path = path.ok_or_else(|| CliError::new("--path is required"))?;

    Ok(CliArgs {
        command,
        path,
        pretty,
        verify_only,
    })
}

fn usage() -> String {
    "Usage: tidalctl <command> --path <dir> [--pretty]\n\n\
     Commands:\n  \
       status       Report WAL state, checkpoint, and directory layout\n  \
       paths        Report resolved directory paths and existence\n  \
       recover      Diagnose WAL state for crash recovery (--verify-only)\n  \
       diagnostics  Print health summary (human-readable or JSON)\n  \
       scope-stats  Tally WAL signal events by governance scope (M9)\n\n\
     Exit codes: 0 = ok/empty, 1 = usage/internal error, 2 = degraded/unreadable\n  \
       (WAL, checkpoint, or a derived index exists but could not be read)."
        .to_string()
}

// ---------------------------------------------------------------------------
// Command dispatch
// ---------------------------------------------------------------------------

fn run(args: &[String]) -> Result<(String, i32), CliError> {
    let cli = parse_args(args)?;

    match cli.command {
        Command::Status => commands::status::run(&cli.path, cli.pretty),
        Command::Paths => commands::paths::run(&cli.path, cli.pretty),
        Command::Recover => commands::recover::run(&cli.path, cli.pretty, cli.verify_only),
        Command::Diagnostics => commands::diagnostics::run(&cli.path, cli.pretty),
        Command::ScopeStats => commands::scope_stats::run(&cli.path, cli.pretty),
    }
}
