use crate::adapters;
use crate::index;
use crate::model::Role;
use crate::resume;
use crate::util::*;
use anyhow::{bail, Context, Result};

/// `index::search` wraps matches in \x02..\x03 (FTS5 snippet markers). For JSON
/// we strip color/control entirely. Returns (plain, marked): `plain` has the
/// markers removed, `marked` replaces them with the stable ASCII pair `[[`..`]]`
/// so an agent can still locate the match. Newlines collapse to spaces and any
/// other C0 control char is dropped so the JSON string is always clean.
pub fn clean_snippet(raw: &str) -> (String, String) {
    let mut plain = String::with_capacity(raw.len());
    let mut marked = String::with_capacity(raw.len() + 8);
    for c in raw.chars() {
        match c {
            '\u{2}' => marked.push_str("[["),
            '\u{3}' => marked.push_str("]]"),
            '\n' | '\t' => {
                plain.push(' ');
                marked.push(' ');
            }
            c if (c as u32) < 0x20 => {} // drop other C0 controls
            c => {
                plain.push(c);
                marked.push(c);
            }
        }
    }
    (plain, marked)
}

/// Strip control bytes from a search snippet before it is rendered to the
/// terminal, keeping the \x02/\x03 FTS markers (the caller swaps them to ANSI).
/// A message body is untrusted input, so an unstripped ESC could inject
/// ANSI/OSC escapes into the operator's terminal.
pub fn strip_snippet_controls(snippet: &str) -> String {
    snippet
        .chars()
        .filter_map(|c| match c {
            '\u{2}' | '\u{3}' => Some(c),
            '\n' | '\t' => Some(' '),
            c if (c as u32) < 0x20 || c == '\u{7f}' || ('\u{80}'..='\u{9f}').contains(&c) => None,
            c => Some(c),
        })
        .collect()
}

pub fn scan() -> Result<()> {
    let mut reports = Vec::new();
    for adapter in adapters::all() {
        if let Some(r) = adapters::report(adapter.as_ref()) {
            reports.push(r);
        }
    }
    if reports.is_empty() {
        println!("No session stores found on this machine.");
        return Ok(());
    }

    println!(
        "{}",
        bold(&format!(
            "{:<14} {:>9} {:>10}  {:<12} {:<12}  {}",
            "TOOL", "SESSIONS", "SIZE", "OLDEST", "NEWEST", "PATH"
        ))
    );
    let (mut files, mut bytes) = (0usize, 0u64);
    for r in &reports {
        files += r.files;
        bytes += r.bytes;
        println!(
            "{:<14} {:>9} {:>10}  {:<12} {:<12}  {}",
            cyan(r.tool),
            r.files,
            human_size(r.bytes),
            r.oldest
                .map(|t| t.format("%Y-%m-%d").to_string())
                .unwrap_or_else(|| "-".into()),
            r.newest
                .map(|t| t.format("%Y-%m-%d").to_string())
                .unwrap_or_else(|| "-".into()),
            dim(&r.root.display().to_string()),
        );
    }
    println!();
    println!(
        "{}",
        bold(&format!(
            "{} sessions across {} tools, {} on disk.",
            files,
            reports.len(),
            human_size(bytes)
        ))
    );
    println!("{}", dim("Try: sessionwiki search <query>"));
    Ok(())
}

pub fn list(
    limit: usize,
    tool: Option<&str>,
    project: Option<&str>,
    tag: Option<&str>,
    all: bool,
    json: bool,
    no_sync: bool,
) -> Result<()> {
    let mut conn = index::open()?;
    if !no_sync {
        index::sync(&mut conn, tool)?;
    }
    let rows = index::recent(&conn, limit, tool, project, tag, all)?;
    if json {
        println!("{}", serde_json::to_string(&rows)?);
        return Ok(());
    }
    if rows.is_empty() {
        println!("No sessions found.");
        return Ok(());
    }
    println!(
        "{}",
        bold(&format!(
            "{:<13} {:<12} {:<10} {:>5}  {:<24} {}",
            "ID", "TOOL", "WHEN", "MSGS", "PROJECT", "TITLE"
        ))
    );
    for r in rows {
        let when = r
            .started
            .as_deref()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|t| t.with_timezone(&chrono::Utc));
        let tags = r
            .tags
            .as_deref()
            .map(|t| format!("  {}", dim(&format!("#{}", t.replace(',', " #")))))
            .unwrap_or_default();
        let archived = if r.archived {
            format!("  {}", dim("[archived]"))
        } else {
            String::new()
        };
        let sub = if r.kind == "sub" {
            format!("  {}", dim("[subagent]"))
        } else {
            String::new()
        };
        println!(
            "{:<13} {:<12} {:<10} {:>5}  {:<24} {}{}{}{}",
            yellow(&r.session_id),
            cyan(&r.tool),
            rel_time(when),
            r.msg_count,
            truncate(&project_label(&r.project), 24),
            truncate(&r.title, 60),
            tags,
            archived,
            sub,
        );
    }
    Ok(())
}

pub fn search(
    query: &str,
    limit: usize,
    tool: Option<&str>,
    project: Option<&str>,
    json: bool,
    no_sync: bool,
) -> Result<()> {
    let trimmed = query.trim();
    if trimmed.is_empty() {
        bail!("empty query");
    }
    let mut conn = index::open()?;
    if !no_sync {
        index::sync(&mut conn, tool)?;
    }
    // Trigram FTS needs >=3 chars; shorter terms (1-2 chars, including 2-syllable
    // Korean like 회사/검색 - the most common Korean word length - and 2-char
    // latin fragments) fall back to a LIKE scan. Counted on the NFC form so
    // decomposed Korean counts by visible character, not by combining scalar.
    let hits = if crate::util::nfc(trimmed).chars().count() < 3 {
        index::search_like(&conn, trimmed, limit, tool, project)?
    } else {
        index::search(&conn, trimmed, limit, tool, project)?
    };
    if json {
        let out: Vec<serde_json::Value> = hits
            .iter()
            .map(|h| {
                let mut v = serde_json::to_value(&h.row).unwrap_or_else(|_| serde_json::json!({}));
                let (plain, marked) = clean_snippet(&h.snippet);
                v["snippet"] = serde_json::json!(plain);
                v["snippet_marked"] = serde_json::json!(marked);
                v["role"] = serde_json::json!(h.role);
                v
            })
            .collect();
        println!("{}", serde_json::to_string(&serde_json::Value::Array(out))?);
        return Ok(());
    }
    if hits.is_empty() {
        println!("No matches for \"{query}\".");
        return Ok(());
    }
    for h in &hits {
        let when = h
            .row
            .started
            .as_deref()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|t| t.with_timezone(&chrono::Utc));
        let marker = if h.row.kind == "sub" {
            " [subagent]"
        } else {
            ""
        };
        println!(
            "{} {} {} {} {}",
            yellow(&h.row.session_id),
            cyan(&h.row.tool),
            dim(&fmt_date(when)),
            truncate(&project_label(&h.row.project), 28),
            dim(&format!("[{}]{marker}", h.role)),
        );
        // snippet() wraps matches in \x02 .. \x03; swap for ANSI here. Strip
        // other control bytes first (the message body is untrusted input).
        let snip = strip_snippet_controls(&h.snippet);
        let snip = if color_enabled() {
            snip.replace('\u{2}', "\x1b[1;33m")
                .replace('\u{3}', "\x1b[0m")
        } else {
            snip.replace(['\u{2}', '\u{3}'], "")
        };
        println!("  {snip}");
        println!();
    }
    println!(
        "{}",
        dim(&format!(
            "{} sessions. Open one: sessionwiki show <id>",
            hits.len()
        ))
    );
    Ok(())
}

/// Recall in one step: search, list the candidates, and brief the top match.
/// Collapses the usual search -> eyeball id -> brief loop into one command.
pub fn recall(
    query: &str,
    limit: usize,
    tool: Option<&str>,
    project: Option<&str>,
    max_chars: usize,
    json: bool,
    no_sync: bool,
) -> Result<()> {
    let trimmed = query.trim();
    if trimmed.is_empty() {
        bail!("empty query");
    }
    let mut conn = index::open()?;
    if !no_sync {
        index::sync(&mut conn, tool)?;
    }
    let hits = if crate::util::nfc(trimmed).chars().count() < 3 {
        index::search_like(&conn, trimmed, limit, tool, project)?
    } else {
        index::search(&conn, trimmed, limit, tool, project)?
    };
    if hits.is_empty() {
        if json {
            let v = serde_json::json!({
                "query": query, "top": serde_json::Value::Null, "candidates": []
            });
            println!("{}", serde_json::to_string(&v)?);
        } else {
            println!("No sessions about \"{query}\".");
        }
        return Ok(());
    }

    // The top hit is briefed; the rest are listed so a wrong #1 is easy to spot
    // (ranking is lexical, not semantic).
    let top = &hits[0];
    let session = load_session(&conn, &top.row)?;
    let markdown = brief_text(&session, max_chars, false);

    if json {
        let candidates: Vec<serde_json::Value> = hits
            .iter()
            .map(|h| {
                let mut v = serde_json::to_value(&h.row).unwrap_or_else(|_| serde_json::json!({}));
                let (plain, marked) = clean_snippet(&h.snippet);
                v["snippet"] = serde_json::json!(plain);
                v["snippet_marked"] = serde_json::json!(marked);
                v
            })
            .collect();
        let v = serde_json::json!({
            "query": query,
            "top": {
                "id": session.id,
                "tool": session.tool,
                "project": session.project,
                "title": session.title,
                "started": session.started.map(|t| t.to_rfc3339()),
                "markdown": markdown,
            },
            "candidates": candidates,
        });
        println!("{}", serde_json::to_string(&v)?);
        return Ok(());
    }

    println!(
        "{}",
        dim(&format!("{} match(es) for \"{}\":", hits.len(), query))
    );
    for h in &hits {
        let when = h
            .row
            .started
            .as_deref()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|t| t.with_timezone(&chrono::Utc));
        let sub = if h.row.kind == "sub" {
            format!("  {}", dim("[subagent]"))
        } else {
            String::new()
        };
        println!(
            "  {} {} {} {}{}",
            yellow(&h.row.session_id),
            cyan(&h.row.tool),
            dim(&fmt_date(when)),
            truncate(&h.row.title, 50),
            sub,
        );
    }
    println!();
    println!(
        "{}",
        dim(&format!(
            "recalled {} - {}",
            &session.id,
            truncate(&session.title, 60)
        ))
    );
    print!("{markdown}");
    Ok(())
}

/// Build or refresh the index now, so later queries can pass `--no-sync`.
pub fn sync_cmd(tool: Option<&str>) -> Result<()> {
    let mut conn = index::open()?;
    index::sync(&mut conn, tool)?;
    // Count top-level sessions only (not subagent transcripts or archived rows)
    // so the number matches what `stats` and `list` report.
    let n: i64 = conn.query_row(
        "SELECT count(*) FROM files WHERE kind = 'main' AND archived_at IS NULL",
        [],
        |r| r.get(0),
    )?;
    println!("{}", dim(&format!("index synced - {n} sessions indexed")));
    Ok(())
}

/// Copy a session into another project directory so it can be resumed there.
/// Each tool keys sessions to a directory differently:
///   claude-code: resume is scoped to `~/.claude/projects/<encoded-cwd>/`,
///                so the transcript is copied into the target's folder.
///   codex:       resumes by id from any directory - nothing to copy, just
///                the command to run in the target.
///   gemini:      chats live under `~/.gemini/tmp/<sha256(dir)>/chats/`, so the
///                chat is copied there and its `projectHash` rewritten.
/// The original is always left untouched.
pub fn migrate_cmd(id: &str, target_dir: &str, no_sync: bool) -> Result<()> {
    let mut conn = index::open()?;
    let row = resolve_lazy(&mut conn, id, no_sync)?;

    let target = std::fs::canonicalize(target_dir).with_context(|| {
        format!("target directory not found: {target_dir} (it must exist - you resume by cd-ing into it)")
    })?;
    if !target.is_dir() {
        bail!("not a directory: {}", target.display());
    }
    let target_str = target.to_string_lossy().to_string();
    let src = std::path::PathBuf::from(&row.path);
    let home = dirs::home_dir().context("could not find your home directory")?;

    match row.tool.as_str() {
        "claude-code" => {
            if row.kind == "sub" {
                bail!("this is a subagent transcript - migrate its parent session instead");
            }
            if !src.exists() {
                bail!(
                    "the session file is gone ({}) - nothing to copy; try: sessionwiki brief {id}",
                    row.path
                );
            }
            let dest_dir = home
                .join(".claude")
                .join("projects")
                .join(crate::migrate::claude_project_folder(&target_str));
            let dest = dest_dir.join(src.file_name().context("bad session path")?);
            if dest.exists() {
                bail!("already migrated: {} already exists", dest.display());
            }
            std::fs::create_dir_all(&dest_dir)?;
            std::fs::copy(&src, &dest)?;
            report_migrated(&row.path, &dest);
            print_native_resume(&row.tool, &dest, &target);
        }
        "codex" => {
            // Codex stores sessions by date, not by project, and `codex resume
            // <id>` finds them from any directory - so there is nothing to copy.
            println!(
                "{}",
                green("Codex sessions resume by id from any directory - no copy needed.")
            );
            print_native_resume(&row.tool, &src, &target);
        }
        "gemini" => {
            if !src.exists() {
                bail!("the chat file is gone ({})", row.path);
            }
            let hash = crate::migrate::gemini_project_hash(&target_str);
            let dest_dir = home.join(".gemini").join("tmp").join(&hash).join("chats");
            let dest = dest_dir.join(src.file_name().context("bad chat path")?);
            if dest.exists() {
                bail!("already migrated: {} already exists", dest.display());
            }
            // Rewrite the chat's own projectHash so Gemini lists it under the
            // target project; everything else is copied verbatim.
            let raw = crate::util::read_to_string_capped(&src)?;
            let mut v: serde_json::Value =
                serde_json::from_str(&raw).with_context(|| format!("parse {}", src.display()))?;
            if let Some(obj) = v.as_object_mut() {
                obj.insert("projectHash".into(), serde_json::Value::String(hash));
            }
            std::fs::create_dir_all(&dest_dir)?;
            std::fs::write(&dest, serde_json::to_string(&v)?)?;
            report_migrated(&row.path, &dest);
            println!("resume it there (Gemini resume is interactive):");
            println!("  {}", cyan(&format!("cd {} && gemini", target.display())));
            println!("  {}", cyan("then run /chat resume and pick it"));
        }
        other => bail!(
            "migrate does not support {other} sessions yet (works for claude-code, codex, gemini)"
        ),
    }
    Ok(())
}

fn report_migrated(src: &str, dest: &std::path::Path) {
    println!("{}", green("migrated (copied - the original is untouched)"));
    println!("  {} {}", dim("from"), dim(src));
    println!("  {}   {}", dim("to"), dest.display());
    println!(
        "{}",
        dim("(the copy keeps the same id, so `sessionwiki show` will list both locations)")
    );
}

fn print_native_resume(tool: &str, path: &std::path::Path, target: &std::path::Path) {
    if let Some(info) = crate::resume::for_session(tool, path, &target.to_string_lossy()) {
        println!("resume it there:");
        println!(
            "  {}",
            cyan(&format!(
                "cd {} && {}",
                target.display(),
                info.command_line()
            ))
        );
    }
}

pub fn show(id: &str, full: bool, json: bool, outline: bool, no_sync: bool) -> Result<()> {
    let mut conn = index::open()?;
    let row = resolve_lazy(&mut conn, id, no_sync)?;

    let session = load_session(&conn, &row)?;

    if json {
        println!("{}", serde_json::to_string_pretty(&session)?);
        return Ok(());
    }

    // Buffer the transcript, then page it: a `show --full` of a multi-thousand-
    // message session is tens of thousands of lines and would otherwise flood
    // the terminal. `ln!` appends a line to the buffer.
    use std::fmt::Write as _;
    let mut out = String::new();
    macro_rules! ln {
        () => {{ let _ = writeln!(out); }};
        ($($a:tt)*) => {{ let _ = writeln!(out, $($a)*); }};
    }

    if outline {
        // A session's user turns are its table of contents; the last
        // assistant message is where it ended. No LLM required.
        ln!("{}", bold(&session.title));
        ln!(
            "{}",
            dim(&format!(
                "{} | {} | {} | {} messages",
                session.tool,
                project_label(&session.project),
                fmt_date(session.started),
                session.messages.len()
            ))
        );
        if let Some(s) = &row.summary {
            ln!("{}", s);
        }
        ln!();
        let mut n = 0;
        for m in &session.messages {
            if m.role == Role::User && !is_harness_noise(&m.text) {
                n += 1;
                ln!("{:>3}. {}", n, truncate(&m.text, 110));
            }
        }
        if let Some(last) = session
            .messages
            .iter()
            .rev()
            .find(|m| m.role == Role::Assistant)
        {
            ln!();
            ln!("{}", bold("ended with:"));
            ln!("{}", truncate(&last.text, 400));
        }
        return page_or_print(&out);
    }

    ln!("{}", bold(&session.title));
    ln!(
        "{}",
        dim(&format!(
            "{} | {} | {} | {} messages",
            session.tool,
            project_label(&session.project),
            fmt_date(session.started),
            session.messages.len()
        ))
    );
    ln!("{}", dim(&session.path.display().to_string()));
    if row.archived {
        ln!(
            "{}",
            yellow("[archived] the tool deleted the original; showing the copy sessionwiki kept")
        );
    }
    if let Some(s) = &row.summary {
        ln!("{}", s);
    }
    if let Some(t) = &row.tags {
        ln!("{}", cyan(&format!("#{}", t.replace(',', " #"))));
    }
    if let Some(note) = index::note_for(&conn, &row.session_id)? {
        ln!("{} {}", dim("note:"), note);
    }
    let files = index::files_for(&conn, &row.session_id)?;
    if !files.is_empty() {
        let shown = files.len().min(8);
        let more = files.len() - shown;
        let list = files[..shown]
            .iter()
            .map(|f| project_label(f))
            .collect::<Vec<_>>()
            .join(", ");
        let suffix = if more > 0 {
            format!(" (+{more} more)")
        } else {
            String::new()
        };
        ln!("{} {}{}", dim("touched:"), list, dim(&suffix));
    }
    ln!();

    for m in &session.messages {
        match m.role {
            Role::User => ln!("{}", bold(&cyan("[user]"))),
            Role::Assistant => ln!("{}", bold(&green("[assistant]"))),
            Role::Tool => {
                if !full {
                    ln!("{}", dim(&format!("[tool] {}", truncate(&m.text, 120))));
                    continue;
                }
                ln!("{}", dim("[tool]"));
            }
        }
        if full || m.role != Role::Tool {
            let text = if full {
                m.text.clone()
            } else {
                truncate(&m.text, 2000)
            };
            ln!("{text}");
        }
        ln!();
    }

    let rel = index::related(&conn, &row.session_id, 4)?;
    if !rel.is_empty() {
        ln!("{}", bold("see also:"));
        for r in rel {
            ln!(
                "  {} {} {}",
                yellow(&r.session_id),
                dim(&cyan(&r.tool)),
                truncate(&r.title, 64)
            );
        }
    }
    page_or_print(&out)
}

/// Print to stdout, or page through $PAGER (default `less -FRX`: short output
/// passes straight through, long transcripts page) when stdout is a terminal.
/// This keeps a big `show --full` from flooding the terminal while leaving
/// piped/redirected output untouched.
fn page_or_print(text: &str) -> Result<()> {
    use std::io::IsTerminal;
    if std::io::stdout().is_terminal() {
        let pager = std::env::var("SESSIONWIKI_PAGER")
            .or_else(|_| std::env::var("PAGER"))
            .unwrap_or_else(|_| "less -FRX".to_string());
        use std::process::{Command, Stdio};
        if let Ok(mut child) = Command::new("sh")
            .arg("-c")
            .arg(&pager)
            .stdin(Stdio::piped())
            .spawn()
        {
            if let Some(mut sin) = child.stdin.take() {
                use std::io::Write;
                let _ = sin.write_all(text.as_bytes()); // ignore broken pipe (quit pager)
            }
            // Only treat the pager as having handled the output if it ran. If
            // the pager isn't installed (`sh -c "less ..."` exits non-zero), the
            // output would otherwise be lost - fall through and print it.
            if matches!(child.wait(), Ok(s) if s.success()) {
                return Ok(());
            }
        }
    }
    print!("{text}");
    Ok(())
}

/// Slash-command echoes and interruption markers are not conversation.
fn is_harness_noise(text: &str) -> bool {
    let t = text.trim_start();
    t.starts_with('<') || t.starts_with("[Request interrupted")
}

/// Load a session for reading: re-parse the original file when it still exists
/// (full fidelity), otherwise reconstruct it from the index. The latter is how
/// archived sessions - those the tool deleted - stay readable.
fn load_session(
    conn: &rusqlite::Connection,
    row: &index::SessionRow,
) -> Result<crate::model::Session> {
    let path = std::path::Path::new(&row.path);
    if path.exists() {
        let adapter = adapters::by_name(&row.tool).context("unknown tool in index")?;
        adapter.parse(path)
    } else {
        index::session_from_index(conn, row)
    }
}

/// Resolve an id prefix to exactly one indexed session.
/// Resolve a session id, syncing once only if it is not already indexed. This
/// skips the all-tools walk for ids already in the index (the common case: you
/// got the id from search/list/recall). With `no_sync` it never syncs - it just
/// surfaces the not-found error if the id is not indexed yet.
fn resolve_lazy(
    conn: &mut rusqlite::Connection,
    id: &str,
    no_sync: bool,
) -> Result<index::SessionRow> {
    // Resolve against the existing index first. Only a genuinely unknown id (no
    // prefix match at all) is worth a full store walk - an *ambiguous* prefix is
    // already in the index, so a sync cannot disambiguate it and would just pay
    // for a needless walk of every store (notably the large Codex one).
    let matches = index::resolve(conn, id)?;
    if matches.is_empty() && !no_sync {
        index::sync(conn, None)?;
        return resolve_one(conn, id);
    }
    pick_one(matches, id)
}

fn resolve_one(conn: &rusqlite::Connection, id: &str) -> Result<index::SessionRow> {
    pick_one(index::resolve(conn, id)?, id)
}

fn pick_one(matches: Vec<index::SessionRow>, id: &str) -> Result<index::SessionRow> {
    match matches.len() {
        0 => bail!("no session with id starting \"{id}\" (try: sessionwiki list)"),
        1 => Ok(matches.into_iter().next().unwrap()),
        _ => {
            eprintln!("ambiguous id, candidates:");
            for m in &matches {
                eprintln!("  {} {} {}", m.session_id, m.tool, truncate(&m.title, 60));
            }
            bail!("be more specific");
        }
    }
}

pub fn resume_cmd(id: &str, print_only: bool, no_sync: bool) -> Result<()> {
    let mut conn = index::open()?;
    let row = resolve_lazy(&mut conn, id, no_sync)?;

    let path = std::path::Path::new(&row.path);
    if !path.exists() {
        bail!(
            "the session file is gone ({}) - the tool's own cleanup likely deleted it,\n\
             so a native resume is not possible. Try: sessionwiki brief {id}",
            row.path
        );
    }
    let Some(info) = resume::for_session(&row.tool, path, &row.project) else {
        bail!(
            "{} sessions cannot be resumed headlessly. For Gemini CLI, open `gemini` in\n\
             the project and use /chat resume. You can still carry the context over:\n\
             sessionwiki brief {id}",
            row.tool
        );
    };

    println!("{}", bold(&truncate(&row.title, 80)));
    if let Some(note) = &info.note {
        println!("{}", dim(&format!("note: {note}")));
    }
    let cwd_display = info.cwd.as_ref().map(|c| c.display().to_string());
    match (&info.cwd, cwd_display.as_deref()) {
        (Some(c), Some(d)) if !c.exists() => {
            println!(
                "{}",
                dim(&format!("project dir not found on this machine: {d}"))
            );
            println!("run it where the project lives:");
            println!("  {}", cyan(&info.command_line()));
            return Ok(());
        }
        (Some(_), Some(d)) => println!("{} {}", dim("in"), d),
        _ => {}
    }
    println!("  {}", cyan(&info.command_line()));
    if print_only {
        return Ok(());
    }

    // The session's recorded directory is untrusted input (a planted or
    // prompt-poisoned session can claim any path). If we could not verify it
    // belongs to this session, do not auto-launch the tool there - that would
    // load the directory's CLAUDE.md/.mcp.json/settings into the resumed agent.
    // Print the command and let the user run it after a look.
    if info.cwd.is_some() && !info.verified_cwd {
        eprintln!(
            "{}",
            dim("note: could not confirm this session's recorded directory is its own")
        );
        eprintln!(
            "{}",
            dim("not launching automatically - run the command above yourself if it looks right")
        );
        return Ok(());
    }

    let mut cmd = std::process::Command::new(info.program);
    cmd.args(&info.args);
    if let Some(c) = &info.cwd {
        cmd.current_dir(c);
    }
    match cmd.status() {
        Ok(status) => {
            if !status.success() {
                bail!("{} exited with {status}", info.program);
            }
            Ok(())
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            bail!(
                "`{}` is not installed or not on PATH - run the command above manually",
                info.program
            )
        }
        Err(e) => Err(e.into()),
    }
}

pub fn brief(
    id: &str,
    max_chars: usize,
    include_tools: bool,
    json: bool,
    no_sync: bool,
) -> Result<()> {
    let mut conn = index::open()?;
    let row = resolve_lazy(&mut conn, id, no_sync)?;
    let session = load_session(&conn, &row)?;
    let markdown = brief_text(&session, max_chars, include_tools);
    if json {
        let v = serde_json::json!({
            "id": session.id,
            "tool": session.tool,
            "project": session.project,
            "title": session.title,
            "started": session.started.map(|t| t.to_rfc3339()),
            "source": session.path.display().to_string(),
            "markdown": markdown,
        });
        println!("{}", serde_json::to_string(&v)?);
        return Ok(());
    }
    print!("{markdown}");
    Ok(())
}

/// The markdown briefing used by `brief` and as LLM input for `summarize`.
fn brief_text(session: &crate::model::Session, max_chars: usize, include_tools: bool) -> String {
    let mut blocks: Vec<String> = Vec::new();
    for m in &session.messages {
        match m.role {
            Role::User => blocks.push(format!("**User:**\n{}", m.text.trim())),
            Role::Assistant => blocks.push(format!("**Assistant:**\n{}", m.text.trim())),
            Role::Tool => {
                if include_tools {
                    blocks.push(format!("> [tool] {}", truncate(&m.text, 200)));
                }
            }
        }
    }

    // Budgeting: keep the head and the tail, drop the middle. The opening
    // frames the task and the tail holds the latest state - both matter
    // more than the middle of a long session. Cap individual blocks first,
    // or a single giant message starves both ends.
    let block_cap = (max_chars / 4).max(400);
    let blocks: Vec<String> = blocks
        .into_iter()
        .map(|b| {
            if b.chars().count() > block_cap {
                let cut: String = b.chars().take(block_cap).collect();
                format!("{cut}\n*[... message truncated ...]*")
            } else {
                b
            }
        })
        .collect();
    let total: usize = blocks.iter().map(|b| b.len() + 2).sum();
    let body = if total <= max_chars {
        blocks.join("\n\n")
    } else {
        let half = max_chars / 2;
        let mut head: Vec<&String> = Vec::new();
        let mut used = 0;
        for b in &blocks {
            if used + b.len() > half {
                break;
            }
            used += b.len() + 2;
            head.push(b);
        }
        let mut tail: Vec<&String> = Vec::new();
        let mut used_tail = 0;
        for b in blocks.iter().rev() {
            if used_tail + b.len() > half || head.len() + tail.len() >= blocks.len() {
                break;
            }
            used_tail += b.len() + 2;
            tail.push(b);
        }
        tail.reverse();
        let omitted = blocks.len() - head.len() - tail.len();
        let mut parts: Vec<String> = head.into_iter().cloned().collect();
        if omitted > 0 {
            parts.push(format!("*[... {omitted} messages omitted ...]*"));
        }
        parts.extend(tail.into_iter().cloned());
        parts.join("\n\n")
    };

    format!(
        "# Previous session: {}\n\n- Tool: {} | Project: {} | Date: {}\n- Source: {}\n\n{}\n",
        session.title,
        session.tool,
        session.project,
        fmt_date(session.started),
        session.path.display(),
        body
    )
}

const SUMMARIZE_INSTRUCTION: &str = "You are summarizing a transcript of an AI coding session. \
Reply with ONLY the summary, 1-2 sentences: what was asked and what the outcome was. \
Write it in the same language the session is in.";

pub fn summarize(
    id: Option<&str>,
    recent: usize,
    tool: Option<&str>,
    cmd: Option<&str>,
    force: bool,
) -> Result<()> {
    let mut conn = index::open()?;
    index::sync(&mut conn, tool)?;

    let targets = match id {
        Some(id) => vec![resolve_one(&conn, id)?],
        None => index::unsummarized(&conn, recent, tool)?,
    };
    if targets.is_empty() {
        println!("Nothing to summarize - the most recent sessions already have summaries.");
        return Ok(());
    }

    let cmd = cmd
        .map(String::from)
        .or_else(|| std::env::var("SESSIONWIKI_SUMMARIZER").ok())
        .unwrap_or_else(|| "claude -p".to_string());
    // Be explicit: this pipes each session's transcript into the summarizer.
    // The default `claude -p` sends it to the Anthropic API - the only thing in
    // sessionwiki that leaves the machine, and only when you run `summarize`.
    eprintln!(
        "{}",
        dim(&format!(
            "summarizer: `{cmd}` - pipes each transcript to this command \
             ({} session(s); your cost). The default `claude -p` sends them to \
             the Anthropic API; set --cmd or SESSIONWIKI_SUMMARIZER to change.",
            targets.len()
        ))
    );

    let total = targets.len();
    for (i, row) in targets.iter().enumerate() {
        if row.summary.is_some() && !force {
            println!(
                "{} already summarized (use --force to redo)",
                yellow(&row.session_id)
            );
            continue;
        }
        let session = match load_session(&conn, row) {
            Ok(s) => s,
            Err(e) => {
                eprintln!("{} parse failed: {e:#}", yellow(&row.session_id));
                continue;
            }
        };
        eprintln!(
            "{}",
            dim(&format!(
                "[{}/{}] {}",
                i + 1,
                total,
                truncate(&row.title, 70)
            ))
        );
        let input = format!(
            "{SUMMARIZE_INSTRUCTION}\n\n{}",
            brief_text(&session, 16000, false)
        );
        match run_summarizer(&cmd, &input) {
            Ok(summary) => {
                index::set_summary(&conn, &row.session_id, &summary)?;
                println!("{} {}", yellow(&row.session_id), summary);
            }
            Err(e) => eprintln!("{} summarizer failed: {e:#}", yellow(&row.session_id)),
        }
    }
    Ok(())
}

fn run_summarizer(cmd: &str, input: &str) -> Result<String> {
    use std::io::Write;
    use std::process::{Command, Stdio};
    let mut child = Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::inherit())
        .spawn()
        .context("spawn summarizer")?;
    child
        .stdin
        .take()
        .context("summarizer stdin")?
        .write_all(input.as_bytes())?;
    let out = child.wait_with_output()?;
    if !out.status.success() {
        bail!("exited with {}", out.status);
    }
    let summary = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if summary.is_empty() {
        bail!("summarizer printed nothing");
    }
    Ok(truncate(&summary, 600))
}

pub fn tag(id: &str, add: &[String], remove: &[String]) -> Result<()> {
    // Reads/writes the index only; no filesystem sync, so it is instant.
    // The session id comes from list/search, which already indexed it.
    let conn = index::open()?;

    // No id and no edits: list all tags in use (the wiki tag cloud).
    if id.is_empty() {
        let counts = index::tag_counts(&conn)?;
        if counts.is_empty() {
            println!("No tags yet. Add one: sessionwiki tag <id> <tag>");
            return Ok(());
        }
        for (t, n) in counts {
            println!("{:>4}  {}", n, cyan(&format!("#{t}")));
        }
        return Ok(());
    }

    let row = resolve_one(&conn, id)?;
    for t in remove {
        index::remove_tag(&conn, &row.session_id, t)?;
    }
    for t in add {
        index::add_tag(&conn, &row.session_id, t)?;
    }
    let tags = index::resolve(&conn, &row.session_id)?
        .into_iter()
        .next()
        .and_then(|r| r.tags)
        .unwrap_or_else(|| "(none)".into());
    println!(
        "{} {}",
        yellow(&row.session_id),
        cyan(&format!("#{}", tags.replace(',', " #")))
    );
    Ok(())
}

pub fn note(id: &str, text: Option<&str>) -> Result<()> {
    let conn = index::open()?;
    let row = resolve_one(&conn, id)?;
    match text {
        Some(t) => {
            index::set_note(&conn, &row.session_id, t)?;
            println!("{} note saved", yellow(&row.session_id));
        }
        None => match index::note_for(&conn, &row.session_id)? {
            Some(n) => println!("{n}"),
            None => println!(
                "{}",
                dim("(no note; add one: sessionwiki note <id> \"...\")")
            ),
        },
    }
    Ok(())
}

/// Permanently drop a session from the index and archive. The escape hatch for
/// archive mode: when the tool deleted a session and you actually want it gone,
/// not kept. Does not touch the tool's own store (the original is already gone).
pub fn forget(id: &str) -> Result<()> {
    let mut conn = index::open()?;
    let row = resolve_one(&conn, id)?;
    index::forget(&mut conn, &row.session_id)?;
    println!(
        "{} forgotten ({})",
        yellow(&row.session_id),
        truncate(&row.title, 60)
    );
    Ok(())
}

pub fn related(id: &str, limit: usize, json: bool) -> Result<()> {
    let conn = index::open()?;
    let row = resolve_one(&conn, id)?;
    let rel = index::related(&conn, &row.session_id, limit)?;
    if json {
        println!("{}", serde_json::to_string(&rel)?);
        return Ok(());
    }
    println!(
        "{}",
        dim(&format!("related to: {}", truncate(&row.title, 70)))
    );
    if rel.is_empty() {
        println!("No related sessions found.");
        return Ok(());
    }
    for r in rel {
        let when = r
            .started
            .as_deref()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|t| t.with_timezone(&chrono::Utc));
        println!(
            "{} {} {} {}",
            yellow(&r.session_id),
            cyan(&r.tool),
            dim(&fmt_date(when)),
            truncate(&r.title, 64),
        );
    }
    Ok(())
}

/// Files a session edited or created (its side of the provenance link).
pub fn files(id: &str, json: bool) -> Result<()> {
    let conn = index::open()?;
    let row = resolve_one(&conn, id)?;
    let files = index::files_for(&conn, &row.session_id)?;
    if json {
        println!("{}", serde_json::to_string(&files)?);
        return Ok(());
    }
    println!(
        "{}",
        dim(&format!("files touched by: {}", truncate(&row.title, 70)))
    );
    if files.is_empty() {
        println!(
            "{}",
            dim("No file edits recorded (Gemini chats, or a read-only session).")
        );
        return Ok(());
    }
    for f in files {
        println!("  {f}");
    }
    Ok(())
}

/// Parse a time window like `7d`, `2w`, `24h`, `90m` (a bare number is days).
fn parse_duration(s: &str) -> Result<chrono::Duration> {
    let s = s.trim();
    let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
    let (num, unit) = s.split_at(split);
    let n: i64 = match num.parse() {
        Ok(n) if n >= 0 => n,
        _ => bail!("invalid --since '{s}' (try 7d, 2w, 24h, 90m)"),
    };
    Ok(match unit {
        "" | "d" => chrono::Duration::days(n),
        "w" => chrono::Duration::weeks(n),
        "h" => chrono::Duration::hours(n),
        "m" => chrono::Duration::minutes(n),
        other => bail!("unknown --since unit '{other}' (use d, w, h, or m)"),
    })
}

/// A markdown rollup of recent sessions grouped by project: what you worked on,
/// the files each session touched, and any cached synopsis. Composes the
/// timeline, provenance, and summaries the index already has, over a window.
pub fn digest(
    since: &str,
    tool: Option<&str>,
    project: Option<&str>,
    json: bool,
    no_sync: bool,
) -> Result<()> {
    let cutoff = chrono::Utc::now() - parse_duration(since)?;
    let mut conn = index::open()?;
    if !no_sync {
        index::sync(&mut conn, tool)?;
    }
    // recent() returns newest-first main sessions with the tool/project filters;
    // keep the ones inside the window.
    let rows = index::recent(&conn, 5000, tool, project, None, false)?;
    let in_window: Vec<index::SessionRow> = rows
        .into_iter()
        .filter(|r| {
            r.started
                .as_deref()
                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
                .is_some_and(|t| t.with_timezone(&chrono::Utc) >= cutoff)
        })
        .collect();

    // Group by project, preserving newest-activity-first order.
    let mut order: Vec<String> = Vec::new();
    let mut groups: std::collections::HashMap<String, Vec<&index::SessionRow>> =
        std::collections::HashMap::new();
    for r in &in_window {
        let key = r.project.clone();
        if !groups.contains_key(&key) {
            order.push(key.clone());
        }
        groups.entry(key).or_default().push(r);
    }

    let day = |r: &index::SessionRow| {
        r.started
            .as_deref()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|t| t.format("%Y-%m-%d").to_string())
            .unwrap_or_else(|| "?".into())
    };

    if json {
        let projects: Vec<serde_json::Value> = order
            .iter()
            .map(|p| {
                let sessions: Vec<serde_json::Value> = groups[p]
                    .iter()
                    .map(|r| {
                        let files = index::files_for(&conn, &r.session_id).unwrap_or_default();
                        serde_json::json!({
                            "id": r.session_id,
                            "tool": r.tool,
                            "title": r.title,
                            "started": r.started,
                            "msgs": r.msg_count,
                            "files": files,
                            "summary": r.summary,
                        })
                    })
                    .collect();
                serde_json::json!({ "project": p, "sessions": sessions })
            })
            .collect();
        let v = serde_json::json!({
            "since": since,
            "sessions": in_window.len(),
            "projects": order.len(),
            "generated_at": chrono::Utc::now().to_rfc3339(),
            "by_project": projects,
        });
        println!("{}", serde_json::to_string(&v)?);
        return Ok(());
    }

    println!("{}", bold(&format!("# Digest - last {since}")));
    println!();
    if in_window.is_empty() {
        println!("No sessions in this window.");
        return Ok(());
    }
    println!(
        "{} session(s) across {} project(s).",
        in_window.len(),
        order.len()
    );
    for p in &order {
        let sessions = &groups[p];
        println!();
        println!("## {} ({} session(s))", project_label(p), sessions.len());
        for r in sessions {
            println!(
                "- **{}**  {}  {}",
                day(r),
                truncate(&r.title, 80),
                dim(&format!("[{}]", r.tool))
            );
            if let Some(s) = &r.summary {
                println!("  {}", dim(s));
            }
            let files = index::files_for(&conn, &r.session_id).unwrap_or_default();
            if !files.is_empty() {
                let shown: Vec<&str> = files.iter().take(8).map(String::as_str).collect();
                let more = files.len().saturating_sub(shown.len());
                let suffix = if more > 0 {
                    format!(", +{more} more")
                } else {
                    String::new()
                };
                println!(
                    "  {}",
                    dim(&format!("touched: {}{}", shown.join(", "), suffix))
                );
            }
        }
    }
    Ok(())
}

/// Reverse lookup: which AI sessions touched a file, newest first. This is the
/// provenance link read from the code side - trace a file back to the
/// conversations that edited it, across every tool, with no setup or hooks.
/// It reports sessions that *touched* the file, not line-level authorship: a
/// later edit may have replaced the code, so this points you at the relevant
/// conversations rather than claiming any line came from one.
pub fn trace(path: &str, json: bool, no_sync: bool) -> Result<()> {
    let mut conn = index::open()?;
    if !no_sync {
        index::sync(&mut conn, None)?;
    }
    let hits = index::sessions_for_file(&conn, path, 20)?;
    if json {
        let out: Vec<serde_json::Value> = hits
            .iter()
            .map(|(r, matched)| {
                let mut v = serde_json::to_value(r).unwrap_or_else(|_| serde_json::json!({}));
                v["matched"] = serde_json::json!(matched);
                v
            })
            .collect();
        println!("{}", serde_json::to_string(&serde_json::Value::Array(out))?);
        return Ok(());
    }
    if hits.is_empty() {
        println!(
            "No session touched a file matching \"{path}\".\n{}",
            dim("Pass a path as it appears in the editor, e.g. src/auth.rs")
        );
        return Ok(());
    }
    println!(
        "{}",
        dim(&format!(
            "{} session(s) touched \"{path}\", newest first:",
            hits.len()
        ))
    );
    for (r, matched) in hits {
        let when = r
            .started
            .as_deref()
            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
            .map(|t| t.with_timezone(&chrono::Utc));
        println!(
            "{} {} {} {}",
            yellow(&r.session_id),
            cyan(&r.tool),
            dim(&fmt_date(when)),
            truncate(&r.title, 64),
        );
        println!("  {}", dim(&matched));
    }
    Ok(())
}

/// One contiguous line range with its commit and the session attributed to it.
pub struct BlameRun {
    pub start: usize,
    pub end: usize,
    pub commit: String,
    pub author_time: i64,
    pub attribution: crate::blame::Attribution,
}

/// Attribute each run to a session, looking up the touching sessions once and
/// memoizing the per-commit attribution (one resolution per distinct commit).
pub fn blame_runs(
    conn: &rusqlite::Connection,
    file_query: &str,
    repo_path: &str,
    runs: Vec<crate::blame::Run>,
) -> Result<Vec<BlameRun>> {
    use std::collections::HashMap;
    let candidates = index::sessions_touching(conn, file_query)?;
    let mut memo: HashMap<String, crate::blame::Attribution> = HashMap::new();
    let mut out = Vec::new();
    for r in runs {
        let attr = memo
            .entry(r.commit.clone())
            .or_insert_with(|| {
                crate::blame::attribute_commit(r.author_time, repo_path, &candidates)
            })
            .clone();
        out.push(BlameRun {
            start: r.start,
            end: r.end,
            commit: r.commit,
            author_time: r.author_time,
            attribution: attr,
        });
    }
    Ok(out)
}

/// git blame for the AI era: attribute each line of a file to the AI session
/// most likely behind the commit that last changed it. Best-effort - falls back
/// to file-level `trace` whenever git can't carry the weight.
pub fn blame(file: &str, range: Option<(usize, usize)>, json: bool, no_sync: bool) -> Result<()> {
    let path = std::path::Path::new(file);
    let repo = match crate::blame::repo_root(path) {
        Ok(r) => r,
        Err(e) => return blame_fallback(file, json, no_sync, &e.to_string()),
    };
    let raw = match crate::blame::run_git_blame(&repo, path, range) {
        Ok(o) => o,
        Err(e) => return blame_fallback(file, json, no_sync, &e.to_string()),
    };
    let mut conn = index::open()?;
    if !no_sync {
        index::sync(&mut conn, None)?;
    }
    // Query the index with the repo-relative path: its suffix match then catches
    // both Claude Code's absolute touched paths and Codex's relative ones.
    let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
    let rel = canon
        .strip_prefix(&repo)
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|_| file.to_string());
    let runs = crate::blame::group_runs(&crate::blame::parse_line_porcelain(&raw));
    let repo_path = repo.to_string_lossy().into_owned();
    let results = blame_runs(&conn, &rel, &repo_path, runs)?;
    if json {
        print_blame_json(&results)?;
    } else {
        print_blame_human(file, &rel, &results, &conn)?;
    }
    Ok(())
}

fn blame_fallback(file: &str, json: bool, no_sync: bool, reason: &str) -> Result<()> {
    if !json {
        eprintln!(
            "{}",
            dim(&format!("blame fell back to file-level trace: {reason}"))
        );
    }
    trace(file, json, no_sync)
}

fn sess_json(s: &crate::blame::TouchingSession) -> serde_json::Value {
    serde_json::json!({
        "session_id": s.session_id,
        "tool": s.tool,
        "title": s.title,
        "project": s.project,
        "archived": s.archived,
    })
}

fn print_blame_json(runs: &[BlameRun]) -> Result<()> {
    use crate::blame::Attribution;
    let arr: Vec<serde_json::Value> = runs
        .iter()
        .map(|r| {
            let (status, session, candidates) = match &r.attribution {
                Attribution::Confident(s) => ("confident", Some(sess_json(s)), vec![]),
                Attribution::Ambiguous(v) => ("ambiguous", None, v.iter().map(sess_json).collect()),
                Attribution::Unattributed => ("unattributed", None, vec![]),
            };
            serde_json::json!({
                "start": r.start,
                "end": r.end,
                "commit": r.commit,
                "author_time": r.author_time,
                "status": status,
                "session": session,
                "candidates": candidates,
            })
        })
        .collect();
    println!("{}", serde_json::to_string(&serde_json::Value::Array(arr))?);
    Ok(())
}

fn print_blame_human(
    file: &str,
    rel: &str,
    runs: &[BlameRun],
    conn: &rusqlite::Connection,
) -> Result<()> {
    use crate::blame::Attribution;
    println!(
        "{}",
        dim(&format!(
            "blame {file}: the session most likely behind the commit that last changed each line - not proof of authorship (git show <sha> to verify)."
        ))
    );
    if runs.is_empty() {
        println!("{}", dim("No committed lines to blame."));
    }
    for r in runs {
        let when = chrono::DateTime::from_timestamp(r.author_time, 0);
        let short = &r.commit[..r.commit.len().min(8)];
        let loc = yellow(&format!("L{}-{}", r.start, r.end));
        let date = dim(&fmt_date(when));
        match &r.attribution {
            Attribution::Confident(s) => {
                let arch = if s.archived { " [archived]" } else { "" };
                println!(
                    "{loc}  {date}  {} {}{arch}  {}",
                    cyan(&s.tool),
                    truncate(&s.title, 50),
                    dim(short)
                );
            }
            Attribution::Ambiguous(v) => {
                let ids: Vec<&str> = v.iter().map(|s| s.session_id.as_str()).collect();
                println!(
                    "{loc}  {date}  {}  {}",
                    yellow(&format!("ambiguous ({} sessions)", v.len())),
                    dim(&format!("{} [{short}]", ids.join(", ")))
                );
            }
            Attribution::Unattributed => {
                println!("{loc}  {date}  {}  {}", dim("unattributed"), dim(short));
            }
        }
    }
    // File-level floor: the sessions that touched this file, always shown so
    // unattributed/ambiguous lines still have a way back.
    let hits = index::sessions_for_file(conn, rel, 20)?;
    if !hits.is_empty() {
        println!(
            "\n{}",
            dim(&format!(
                "Sessions that touched this file ({}):",
                hits.len()
            ))
        );
        for (s, _matched) in hits {
            let when = s
                .started
                .as_deref()
                .and_then(|x| chrono::DateTime::parse_from_rfc3339(x).ok())
                .map(|t| t.with_timezone(&chrono::Utc));
            println!(
                "  {} {} {} {}",
                yellow(&s.session_id),
                cyan(&s.tool),
                dim(&fmt_date(when)),
                truncate(&s.title, 50)
            );
        }
    }
    Ok(())
}

pub fn projects() -> Result<()> {
    let conn = index::open()?;
    let rows = index::projects(&conn)?;
    if rows.is_empty() {
        println!("No projects indexed yet.");
        return Ok(());
    }
    println!(
        "{}",
        bold(&format!(
            "{:>5} {:>7}  {:<11} {}",
            "SESS", "MSGS", "LAST", "PROJECT"
        ))
    );
    for p in rows {
        let last = p
            .newest
            .as_deref()
            .map(|s| s.get(0..10).unwrap_or(s).to_string())
            .unwrap_or_else(|| "-".into());
        println!(
            "{:>5} {:>7}  {:<11} {}",
            p.sessions,
            p.messages,
            dim(&last),
            project_label(&p.project)
        );
    }
    Ok(())
}

pub fn stats() -> Result<()> {
    let conn = index::open()?;
    let s = index::stats(&conn)?;

    println!(
        "{}",
        bold(&format!(
            "{} sessions · {} messages · {} projects · {} files · {} tags · {} summarized",
            s.total_sessions, s.total_messages, s.projects, s.files, s.tags, s.summarized
        ))
    );
    if s.archived > 0 {
        println!(
            "{}",
            dim(&format!(
                "{} kept after your tools deleted them",
                s.archived
            ))
        );
    }
    println!();
    println!("{}", bold("by tool"));
    for (tool, sess, msgs) in &s.per_tool {
        println!(
            "  {:<14} {:>6} sessions  {:>8} messages",
            cyan(tool),
            sess,
            msgs
        );
    }
    if !s.per_month.is_empty() {
        println!();
        println!("{}", bold("by month"));
        let max = s
            .per_month
            .iter()
            .map(|(_, n)| *n)
            .max()
            .unwrap_or(1)
            .max(1);
        for (ym, n) in &s.per_month {
            let bar = "\u{2588}".repeat(((*n as f64 / max as f64) * 24.0).round() as usize);
            println!("  {}  {:>5}  {}", ym, n, cyan(&bar));
        }
    }
    Ok(())
}

/// Long absolute paths make poor labels; keep the tail.
fn project_label(p: &str) -> String {
    if p.len() > 28 && p.contains('/') {
        let tail: Vec<&str> = p.rsplit('/').take(2).collect();
        format!(
            "\u{2026}/{}",
            tail.into_iter().rev().collect::<Vec<_>>().join("/")
        )
    } else {
        p.to_string()
    }
}

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

    #[test]
    fn parse_duration_units() {
        assert_eq!(parse_duration("7d").unwrap(), chrono::Duration::days(7));
        assert_eq!(parse_duration("2w").unwrap(), chrono::Duration::weeks(2));
        assert_eq!(parse_duration("24h").unwrap(), chrono::Duration::hours(24));
        assert_eq!(
            parse_duration("90m").unwrap(),
            chrono::Duration::minutes(90)
        );
        assert_eq!(parse_duration("5").unwrap(), chrono::Duration::days(5));
        assert!(parse_duration("7x").is_err());
        assert!(parse_duration("abc").is_err());
        assert!(parse_duration("-3d").is_err());
    }
}
