//! `claudette workspace …` — workspace lifecycle subcommands.
//!
//! Each action maps to one IPC call. The CLI doesn't open the SQLite
//! database directly so the GUI's tray, notifications, and event
//! subscribers stay consistent — every change flows through the
//! `claudette::ops::*` surface the GUI itself dispatches into.

use std::error::Error;

use clap::Subcommand;

use crate::{discovery, ipc, output};

#[derive(Subcommand)]
pub enum Action {
    /// List active workspaces. Pair with `--json` for scripting.
    List,
    /// Create a new workspace in the named repository.
    Create {
        /// Repository ID. Get one from `claudette rpc list_repositories`.
        repo: String,
        /// Workspace name. Letters, numbers, and hyphens only.
        name: String,
    },
    /// Archive a workspace. Worktree is removed; chat history is preserved.
    Archive {
        /// Workspace ID.
        id: String,
        /// Force delete the workspace's branch on archive (overrides the
        /// GUI's `git_delete_branch_on_archive` setting). Pair: pass
        /// `--no-delete-branch` to force the branch to stay even when
        /// the GUI setting is on. Omit both to defer to the GUI setting.
        #[arg(long, overrides_with = "no_delete_branch")]
        delete_branch: bool,
        #[arg(
            long = "no-delete-branch",
            overrides_with = "delete_branch",
            hide = true
        )]
        no_delete_branch: bool,
    },
    /// Hard-delete archived workspaces in bulk. Removes worktrees,
    /// branches, and chat history; lifetime metrics are preserved in
    /// `deleted_workspace_summaries`. Active workspaces are never
    /// touched — the IPC handler rejects any non-Archived ID.
    Purge {
        /// Restrict the purge to workspaces in this repository ID. Get
        /// one from `claudette repo list`.
        #[arg(long)]
        repo: String,
        /// Only purge workspaces older than N days (measured from
        /// `created_at`). Omit to purge every archived workspace in
        /// the repo.
        #[arg(long)]
        older_than_days: Option<u32>,
        /// Print the resolved ID list without deleting anything.
        #[arg(long)]
        dry_run: bool,
    },
}

pub async fn run(action: Action, json: bool) -> Result<(), Box<dyn Error>> {
    let info = discovery::read_app_info()?;
    match action {
        Action::List => {
            // `list_workspaces` returns every row regardless of status,
            // but `workspace list` is documented as listing *active*
            // workspaces — filter here so the command matches its
            // contract. The shared IPC method is intentionally left
            // untouched: `purge` (below) and `pr` resolution both rely
            // on seeing archived / all rows. See issue #896.
            let value = ipc::call(&info, "list_workspaces", serde_json::json!({})).await?;
            let value = active_workspaces(value);
            if json {
                output::print_json(&value)?;
            } else {
                render_workspace_table(&value);
            }
        }
        Action::Create { repo, name } => {
            let value = ipc::call(
                &info,
                "create_workspace",
                serde_json::json!({
                    "repo_id": repo,
                    "name": name,
                    "preserve_name": true,
                }),
            )
            .await?;
            output::print_json(&value)?;
        }
        Action::Archive {
            id,
            delete_branch,
            no_delete_branch,
        } => {
            // Tri-state: only forward an override when the user
            // explicitly asked. The IPC handler treats presence as an
            // override of `git_delete_branch_on_archive`, so omitting
            // the field defers to the GUI setting.
            let override_value = if delete_branch {
                Some(true)
            } else if no_delete_branch {
                Some(false)
            } else {
                None
            };
            let mut params = serde_json::json!({ "workspace_id": id });
            if let Some(v) = override_value {
                params["delete_branch"] = serde_json::Value::Bool(v);
            }
            let value = ipc::call(&info, "archive_workspace", params).await?;
            output::print_json(&value)?;
        }
        Action::Purge {
            repo,
            older_than_days,
            dry_run,
        } => {
            let workspaces = ipc::call(&info, "list_workspaces", serde_json::json!({})).await?;
            let ids = resolve_purge_ids(&workspaces, &repo, older_than_days)?;

            if dry_run {
                if json {
                    output::print_json(&serde_json::json!({
                        "dry_run": true,
                        "ids": ids,
                    }))?;
                } else if ids.is_empty() {
                    println!("no archived workspaces match");
                } else {
                    println!("would delete {} workspace(s):", ids.len());
                    for id in &ids {
                        println!("  {id}");
                    }
                }
                return Ok(());
            }

            if ids.is_empty() {
                if json {
                    output::print_json(&serde_json::json!({
                        "deleted": [],
                        "failed": [],
                    }))?;
                } else {
                    println!("no archived workspaces match");
                }
                return Ok(());
            }

            let value = ipc::call(
                &info,
                "delete_workspaces_bulk",
                serde_json::json!({ "ids": ids }),
            )
            .await?;

            if json {
                output::print_json(&value)?;
            } else {
                let deleted = value
                    .get("deleted")
                    .and_then(|v| v.as_array())
                    .map(|arr| arr.len())
                    .unwrap_or(0);
                let failures = value
                    .get("failed")
                    .and_then(|v| v.as_array())
                    .cloned()
                    .unwrap_or_default();
                if failures.is_empty() {
                    println!("deleted {deleted} workspace(s)");
                } else {
                    println!("deleted {deleted} workspace(s), {} failed:", failures.len(),);
                    for f in &failures {
                        let id = f.get("id").and_then(|v| v.as_str()).unwrap_or("?");
                        let err = f.get("error").and_then(|v| v.as_str()).unwrap_or("?");
                        println!("  {id}: {err}");
                    }
                }
            }
        }
    }
    Ok(())
}

/// Filter the `list_workspaces` response to archived rows matching the
/// CLI's `--repo` (required) and `--older-than-days` (optional) filters.
///
/// `workspaces.created_at` is stored in one of two formats:
///
/// 1. **SQLite `datetime('now')`** — `"YYYY-MM-DD HH:MM:SS"` (UTC).
///    The DB column has `DEFAULT (datetime('now'))` and
///    `Database::insert_workspace` omits `created_at` from its INSERT,
///    so the DEFAULT wins for every workspace today.
/// 2. **Unix-seconds-as-string** — `"1700000000"`. What
///    `ops::workspace::now_iso()` produces. Currently unused for
///    workspace rows but supported here so a future migration that
///    switches the INSERT to set `created_at = now_iso()` doesn't
///    require a CLI change.
///
/// Rows with malformed timestamps are dropped from age filtering when
/// `--older-than-days` is set, kept otherwise.
fn resolve_purge_ids(
    value: &serde_json::Value,
    repo_id: &str,
    older_than_days: Option<u32>,
) -> Result<Vec<String>, Box<dyn Error>> {
    let items = value
        .as_array()
        .ok_or("unexpected list_workspaces response shape")?;

    let cutoff_secs = older_than_days.map(|days| {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        now.saturating_sub(u64::from(days) * 86_400)
    });

    let mut out = Vec::new();
    for item in items {
        let status = item.get("status").and_then(|v| v.as_str()).unwrap_or("");
        if status != "Archived" {
            continue;
        }
        let row_repo = item
            .get("repository_id")
            .and_then(|v| v.as_str())
            .unwrap_or("");
        if row_repo != repo_id {
            continue;
        }
        if let Some(cutoff) = cutoff_secs {
            // "Older than N days" is exclusive: a row whose age is
            // exactly N days is NOT eligible. Matches the modal's
            // `filterByAge` semantics.
            let created_secs = item
                .get("created_at")
                .and_then(|v| v.as_str())
                .and_then(parse_created_at);
            match created_secs {
                Some(c) if c < cutoff => {}
                _ => continue,
            }
        }
        let Some(id) = item.get("id").and_then(|v| v.as_str()) else {
            continue;
        };
        out.push(id.to_string());
    }
    Ok(out)
}

/// Parse a `workspace.created_at` value into Unix seconds. Mirrors the
/// frontend's `parseCreatedAt` in
/// `src/ui/src/components/modals/BulkCleanupArchivedModal.helpers.ts`
/// — see that file for the format rationale.
///
/// Accepts:
/// - All-digits → Unix-seconds-as-string.
/// - SQLite `datetime('now')` (`"YYYY-MM-DD HH:MM:SS"`, treated as UTC).
/// - ISO 8601 with `T` separator: only the first 19 characters
///   (`YYYY-MM-DDTHH:MM:SS`) are parsed, and the value is treated as
///   UTC regardless of any trailing `Z` or `±HH:MM` offset. A
///   non-UTC timezone suffix is therefore tolerated but ignored —
///   acceptable for a day-granularity filter, where a few hours of
///   timezone slop never crosses a "30 days" cutoff in practice.
///   Don't use this parser for sub-day-precision work.
fn parse_created_at(value: &str) -> Option<u64> {
    if value.is_empty() {
        return None;
    }
    if value.chars().all(|c| c.is_ascii_digit()) {
        return value.parse::<u64>().ok();
    }
    // Hand-rolled UTC parser for `YYYY-MM-DD[T ]HH:MM:SS[.fff][Z|±HH:MM]`.
    // We don't pull `chrono` in for one date — every observed format
    // has the same first-19-char shape, and the timezone suffix
    // (currently always missing for SQLite datetime, optional for ISO)
    // doesn't affect a day-granularity filter beyond a few hours of
    // edge cases.
    let bytes = value.as_bytes();
    if bytes.len() < 19 {
        return None;
    }
    let part = |start: usize, len: usize| -> Option<u32> {
        std::str::from_utf8(&bytes[start..start + len])
            .ok()?
            .parse::<u32>()
            .ok()
    };
    let year = part(0, 4)? as i32;
    let month = part(5, 2)?;
    let day = part(8, 2)?;
    let hour = part(11, 2)?;
    let minute = part(14, 2)?;
    let second = part(17, 2)?;
    if !(1..=12).contains(&month)
        || !(1..=31).contains(&day)
        || hour >= 24
        || minute >= 60
        || second >= 60
    {
        return None;
    }
    // Days-from-epoch for the UTC midnight of (year, month, day).
    // Howard Hinnant's civil-from-days inverse, adapted to be const-friendly.
    let y = if month <= 2 { year - 1 } else { year };
    let era = y.div_euclid(400);
    let yoe = y.rem_euclid(400) as u32;
    let m = month as i32;
    let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) as u32 / 5 + (day - 1);
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    let days_from_epoch = era as i64 * 146097 + doe as i64 - 719468;
    let secs = days_from_epoch * 86_400
        + i64::from(hour) * 3600
        + i64::from(minute) * 60
        + i64::from(second);
    u64::try_from(secs).ok()
}

/// Keep only `Active` rows from a `list_workspaces` response.
///
/// `claudette workspace list` is documented as listing *active*
/// workspaces, but the underlying `list_workspaces` IPC method has no
/// `WHERE status` clause and returns archived rows too. Filtering in the
/// CLI keeps the command faithful to its contract without changing the
/// shared IPC method — `workspace purge` and `pr` resolution both depend
/// on it returning every row. `WorkspaceStatus` serializes capitalized
/// (`"Active"` / `"Archived"`), matching `resolve_purge_ids` above.
/// See issue #896.
///
/// A non-array value (unexpected shape) is returned untouched so the
/// caller's existing "unexpected response shape" handling still fires.
fn active_workspaces(value: serde_json::Value) -> serde_json::Value {
    match value {
        serde_json::Value::Array(rows) => serde_json::Value::Array(
            rows.into_iter()
                .filter(|row| row.get("status").and_then(|v| v.as_str()) == Some("Active"))
                .collect(),
        ),
        other => other,
    }
}

/// Minimal table renderer for `workspace list`. Format is intentionally
/// simple — we'll switch to a real table crate (`tabled`?) when more
/// commands need column-aware rendering.
fn render_workspace_table(value: &serde_json::Value) {
    let Some(items) = value.as_array() else {
        eprintln!("(unexpected response shape — try --json)");
        return;
    };
    if items.is_empty() {
        println!("no workspaces");
        return;
    }
    println!("{:<36}  {:<24}  {:<10}  BRANCH", "ID", "NAME", "STATUS");
    for item in items {
        let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("?");
        let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("?");
        let status = item.get("status").and_then(|v| v.as_str()).unwrap_or("?");
        let branch = item
            .get("branch_name")
            .and_then(|v| v.as_str())
            .unwrap_or("?");
        println!("{id:<36}  {name:<24}  {status:<10}  {branch}");
    }
}

#[cfg(test)]
mod tests {
    use super::{active_workspaces, parse_created_at};

    #[test]
    fn parses_unix_seconds_string() {
        assert_eq!(parse_created_at("1700000000"), Some(1_700_000_000));
    }

    #[test]
    fn parses_sqlite_datetime_now_as_utc() {
        // `datetime('now')` output for the same instant as 1700000000.
        assert_eq!(parse_created_at("2023-11-14 22:13:20"), Some(1_700_000_000));
    }

    #[test]
    fn parses_iso_with_t_separator() {
        assert_eq!(
            parse_created_at("2023-11-14T22:13:20Z"),
            Some(1_700_000_000),
        );
    }

    #[test]
    fn rejects_garbage() {
        assert_eq!(parse_created_at(""), None);
        assert_eq!(parse_created_at("nope"), None);
        assert_eq!(parse_created_at("2023-13-01 00:00:00"), None);
    }

    #[test]
    fn active_workspaces_drops_archived_rows() {
        // `workspace list` is documented active-only; the IPC method is
        // not — this filter is what bridges the gap (issue #896).
        let input = serde_json::json!([
            { "id": "w1", "status": "Active" },
            { "id": "w2", "status": "Archived" },
            { "id": "w3", "status": "Active" },
        ]);
        let out = active_workspaces(input);
        let ids: Vec<&str> = out
            .as_array()
            .unwrap()
            .iter()
            .filter_map(|r| r.get("id").and_then(|v| v.as_str()))
            .collect();
        assert_eq!(ids, ["w1", "w3"]);
    }

    #[test]
    fn active_workspaces_empty_when_all_archived() {
        let input = serde_json::json!([
            { "id": "w1", "status": "Archived" },
            { "id": "w2", "status": "Archived" },
        ]);
        let out = active_workspaces(input);
        assert!(out.as_array().unwrap().is_empty());
    }

    #[test]
    fn active_workspaces_passes_through_non_array() {
        // An unexpected (non-array) shape is returned untouched so the
        // caller's "unexpected response shape" handling still triggers.
        let input = serde_json::json!({ "error": "boom" });
        assert_eq!(active_workspaces(input.clone()), input);
    }
}
