123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

mod send;

#[derive(Parser)]
#[command(name = "adom-gchat", version, about = "Post to Google Chat from any Adom container")]
struct Cli {
    #[command(subcommand)]
    command: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message to a Google Chat space.
    Send {
        /// Space name (as configured in webhooks.json).
        #[arg(long)]
        space: String,
        /// Thread key for threaded replies.
        #[arg(long)]
        thread: Option<String>,
        /// Path to a Cards v2 JSON file (instead of plain text).
        #[arg(long)]
        card: Option<PathBuf>,
        /// Print the message to stderr instead of posting. No network call.
        #[arg(long)]
        dry_run: bool,
        /// Disable attribution prefix.
        #[arg(long)]
        no_attribution: bool,
        /// Append "via <repo>" to attribution.
        #[arg(long)]
        with_repo: bool,
        /// REQUIRED to actually post. The AI must first run `send` WITHOUT this
        /// flag to get a preview, show it to the user, get explicit approval,
        /// then re-run WITH this flag. Without it, send prints the preview and
        /// exits with code 2. This is a programmatic gate — the CLI refuses to
        /// post unless the caller declares a human reviewed the message.
        #[arg(long)]
        permission_to_post_given: bool,
        /// Message text (omit if using --card or piping stdin).
        message: Option<String>,
    },
    /// List configured spaces.
    Spaces,
    /// Check webhook connectivity for all spaces.
    Health,
    /// Interactive setup: configure webhook URLs and org identity.
    Setup,
    /// Preview a Cards v2 card in a Hydrogen webview before posting.
    /// Saves a hash so `send --card` can verify the card was previewed.
    Preview {
        /// Path to a Cards v2 JSON file.
        #[arg(long)]
        card: PathBuf,
        /// Port for the preview server.
        #[arg(long, default_value = "9199")]
        port: u16,
        /// Tab name in Hydrogen.
        #[arg(long, default_value = "GChat Card Preview")]
        tab_name: String,
    },
    /// Deploy skill + bash completions.
    Install,
    /// Publish this container's gchat config to the org's private wiki page
    /// (slug `adom-gchat-<org>`) so teammates auto-import it on `install`.
    /// Webhook URLs are STRIPPED by default (they're secrets — the wiki repo
    /// is git). Pass --include-webhook-urls to store the shared URLs too.
    Publish {
        /// Org name (defaults to the org from your local config / setup).
        #[arg(long)]
        org: Option<String>,
        /// Also store the shared webhook URLs in the (private) org page.
        /// WARNING: this commits the URLs to the page's git repo. Only do this
        /// for a private, org-scoped page you trust.
        #[arg(long)]
        include_webhook_urls: bool,
    },
    /// Edit a message you already posted. Webhooks are POST-ONLY, so this isn't
    /// possible here — prints how to do it with `adom-google` (the Chat API).
    Edit {
        #[arg(long)]
        space: Option<String>,
        /// The message id / name (spaces/.../messages/...).
        message: Option<String>,
    },
    /// Delete a message you already posted. Webhooks are POST-ONLY, so this
    /// isn't possible here — prints how to do it with `adom-google`.
    Delete {
        #[arg(long)]
        space: Option<String>,
        /// The message id / name (spaces/.../messages/...).
        message: Option<String>,
    },
}

#[derive(Serialize, Deserialize, Clone)]
struct WebhookConfig {
    #[serde(default)]
    org: Option<String>,
    #[serde(default)]
    attribution: Option<String>,
    #[serde(default)]
    spaces: HashMap<String, SpaceEntry>,
}

#[derive(Serialize, Deserialize, Clone)]
struct SpaceEntry {
    url: String,
    #[serde(default)]
    description: Option<String>,
}

#[derive(Serialize, Deserialize, Default)]
struct Identity {
    #[serde(default)]
    user: Option<String>,
    #[serde(default)]
    display_name: Option<String>,
    #[serde(default)]
    repo: Option<String>,
    #[serde(default)]
    org: Option<String>,
}

fn fetch_or_fallback(url: &str, fallback: &str, label: &str) -> String {
    let client = reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(5))
        .build();
    let client = match client {
        Ok(c) => c,
        Err(_) => {
            eprintln!("WARN: HTTP client init failed, using bundled {label}");
            return fallback.to_string();
        }
    };
    match client.get(url).send() {
        Ok(r) if r.status().is_success() => match r.text() {
            Ok(body) if body.len() >= 100 => body,
            _ => {
                eprintln!("WARN: wiki returned short {label} — using bundled fallback");
                fallback.to_string()
            }
        },
        _ => {
            eprintln!("WARN: wiki unreachable, using bundled {label} (re-run install once network is up)");
            fallback.to_string()
        }
    }
}

fn config_dir() -> PathBuf {
    dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))
}

fn load_webhooks() -> Result<WebhookConfig> {
    let path = config_dir().join("gchat-webhooks.json");
    if !path.exists() {
        return Ok(WebhookConfig {
            org: None,
            attribution: None,
            spaces: HashMap::new(),
        });
    }
    let data = std::fs::read_to_string(&path)
        .with_context(|| format!("reading {}", path.display()))?;
    serde_json::from_str(&data)
        .with_context(|| format!("parsing {}", path.display()))
}

fn save_webhooks(cfg: &WebhookConfig) -> Result<()> {
    let path = config_dir().join("gchat-webhooks.json");
    std::fs::create_dir_all(path.parent().unwrap())?;
    let data = serde_json::to_string_pretty(cfg)? + "\n";
    std::fs::write(&path, data)?;
    Ok(())
}

fn load_identity() -> Identity {
    let path = config_dir().join("adom-identity.json");
    std::fs::read_to_string(&path)
        .ok()
        .and_then(|d| serde_json::from_str(&d).ok())
        .unwrap_or_default()
}

/// Wiki base URL (override with ADOM_WIKI_BASE for self-hosters).
fn wiki_base() -> String {
    std::env::var("ADOM_WIKI_BASE")
        .ok()
        .filter(|s| !s.trim().is_empty())
        .unwrap_or_else(|| "https://wiki.adom.inc".to_string())
}

/// Bearer token for the wiki: the container Carbon key, else a saved wiki token.
fn wiki_token() -> String {
    if let Ok(t) = std::fs::read_to_string("/var/run/adom/api-key") {
        let t = t.trim().to_string();
        if !t.is_empty() {
            return t;
        }
    }
    std::fs::read_to_string(config_dir().join("adom-wiki/token"))
        .unwrap_or_default()
        .trim()
        .to_string()
}

/// Mask the secret parts (`key`, `token`) of a Google Chat webhook URL.
fn mask_url(u: &str) -> String {
    let mut s = u.to_string();
    for p in ["key", "token"] {
        let pat = format!("{p}=");
        if let Some(i) = s.find(&pat) {
            let vs = i + pat.len();
            let ve = s[vs..].find('&').map(|j| vs + j).unwrap_or(s.len());
            s.replace_range(vs..ve, "***");
        }
    }
    s
}

const CFG_START: &str =
    "<!-- adom-gchat:config START — auto-generated by `adom-gchat publish`; edits between markers are overwritten -->";
const CFG_END: &str = "<!-- adom-gchat:config END -->";

/// The auto-generated config section (spaces table + masked config) for an org
/// page README. Built from the LOCAL config so URLs can be shown masked even
/// when they are not stored in the page itself.
fn org_config_block(cfg: &WebhookConfig) -> String {
    let mut names: Vec<&String> = cfg.spaces.keys().collect();
    names.sort();
    let mut t = String::new();
    t.push_str(CFG_START);
    t.push_str("\n## Spaces\n\n");
    t.push_str("**Webhooks are per-space** — each Google Chat space has its own incoming webhook, and a webhook can only post to the space it was created in.\n\n");
    t.push_str("| Space | Google Chat space ID | Webhook (masked) | Purpose |\n|---|---|---|---|\n");
    for name in &names {
        let sp = &cfg.spaces[*name];
        let sid = sp.url.split("/spaces/").nth(1)
            .and_then(|s| s.split('/').next()).unwrap_or("—");
        let url = if sp.url.is_empty() {
            "_(set locally via `setup`)_".to_string()
        } else {
            format!("`…/messages?key=***&token=***`")
        };
        let desc = sp.description.as_deref().unwrap_or("");
        t.push_str(&format!("| `{}` | `{}` | {} | {} |\n", name, sid, url, desc));
    }
    t.push_str("\nPost with `adom-gchat send --space <name> \"message\"`.\n\n");
    t.push_str("## Webhook config (`~/.config/gchat-webhooks.json`)\n\n");
    t.push_str("The exact shape `adom-gchat` reads locally. **URLs are masked** — the `key`/`token` are live secrets and are never committed to this git-backed page:\n\n```json\n");
    let mut masked = cfg.clone();
    for sp in masked.spaces.values_mut() {
        if !sp.url.is_empty() {
            sp.url = mask_url(&sp.url);
        }
    }
    t.push_str(&serde_json::to_string_pretty(&masked).unwrap_or_default());
    t.push_str("\n```\n\nTo get the **real** URLs: each space's webhook lives in Google Chat → *Space → Apps & integrations → Webhooks*, or copy from a teammate, then run `adom-gchat setup`.\n");
    t.push_str(CFG_END);
    t
}

/// A full org-page README template, used on first publish. The config block is
/// re-generated on every publish; everything else is yours to edit.
fn org_readme_full(org: &str, block: &str) -> String {
    format!(
        "# adom-gchat — {org} Org Config\n\n\
{org}'s Google Chat setup for the [adom-gchat](/adom/adom-gchat) CLI — auto-imported by teammates when they run `adom-gchat install`.\n\n\
> 🔒 **Internal / org-private.** The public, open-source tool lives at [adom/adom-gchat](/adom/adom-gchat).\n\n\
## How this page is used\n\n\
1. One person ran `adom-gchat publish --org {org}` to create/update this page.\n\
2. Every teammate whose container identity is `{org}` runs `adom-gchat install`, which looks up `adom-gchat-{org}` and imports these spaces automatically — no AI search.\n\n\
{block}\n\n\
## House rules\n\n\
- No emojis in messages — use Google Chat markdown.\n\
- Never leak a container slug or hostname in a message.\n\
- Preview with `--dry-run` before posting to human-facing spaces.\n\n\
_(Yours to edit — this is outside the auto-generated block, so `publish` won't overwrite it.)_\n\n\
## Need more than posting? → adom-google\n\n\
adom-gchat posts via a webhook (post-only, attributed as the bot). For **edit, delete, read, reactions, threads, or posting as yourself**, use [adom-google](/john/adom-google) — full Google Chat API access. Run `adom-gchat edit`/`delete` for the exact commands.\n"
    )
}

/// Printed by `edit`/`delete` and referenced in hints: webhooks are post-only,
/// so these need adom-google (the full Chat API).
fn deeper_features_hint(action: &str) {
    eprintln!("adom-gchat posts via a Google Chat *incoming webhook*, which is POST-ONLY —");
    eprintln!("it cannot {action} messages (or read / react / list them).");
    eprintln!();
    eprintln!("Use `adom-google` for that — it has full Google Chat API access:");
    eprintln!("  adom-google chat send --to <space|email> \"...\"    # post AS YOU (not via webhook)");
    eprintln!("  adom-google chat read --from <space|email>          # read recent messages");
    if action == "edit" {
        eprintln!("  adom-google api -X PATCH 'https://chat.googleapis.com/v1/<message-name>?updateMask=text' \\");
        eprintln!("    -d '{{\"text\":\"updated text\"}}'                 # edit a message");
    } else {
        eprintln!("  adom-google api -X DELETE 'https://chat.googleapis.com/v1/<message-name>'   # delete a message");
    }
    eprintln!();
    eprintln!("  Note: <message-name> looks like spaces/AAA.../messages/BBB..., and Google Chat only");
    eprintln!("  lets you {action} messages posted under the SAME identity. Webhook posts are owned by");
    eprintln!("  the bot, so to {action} later, post with `adom-google chat send` (as yourself) instead.");
    eprintln!();
    eprintln!("  Don't have it? Tell your AI \"install adom-google\" (full Google Workspace CLI).");
}

fn card_hash(json: &str) -> String {
    use std::hash::{Hash, Hasher};
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    json.trim().hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

fn preview_hash_path() -> PathBuf {
    std::env::temp_dir().join("adom-gchat-preview-hash")
}

fn generate_cards_v2_preview(card_json: &str) -> String {
    format!(r##"<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>Google Chat Card Preview (strict Cards v2)</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#202124;color:#e8eaed;font-family:'Google Sans',Roboto,Arial,sans-serif;font-size:14px;padding:24px;display:flex;justify-content:center}}
.w{{max-width:600px;width:100%}}
.lbl{{text-align:center;color:#5f6368;font-size:11px;margin-bottom:16px;letter-spacing:.5px}}
.msg{{display:flex;gap:12px}}
.av{{width:36px;height:36px;border-radius:50%;flex-shrink:0}}
.mb{{flex:1;min-width:0}}
.sn{{font-weight:500;color:#8ab4f8;font-size:13px;margin-bottom:4px}}
.cd{{background:#292a2d;border:1px solid #3c4043;border-radius:8px;overflow:hidden}}
.ch{{display:flex;align-items:center;gap:12px;padding:16px;border-bottom:1px solid #3c4043}}
.ch img{{width:40px;height:40px;border-radius:50%}}
.cht{{flex:1}}.cht h3{{font-size:16px;font-weight:500;color:#e8eaed;margin:0}}.cht p{{font-size:13px;color:#9aa0a6;margin:2px 0 0}}
.sc{{border-bottom:1px solid #3c4043}}.sc:last-child{{border-bottom:none}}
.sh{{font-size:13px;font-weight:500;color:#8ab4f8;padding:12px 16px 4px}}
.wg{{padding:4px 16px 8px}}
.wg img{{width:100%;border-radius:4px;margin:4px 0}}
.wt{{color:#bdc1c6;font-size:13px;line-height:1.6}}
.wt a{{color:#8ab4f8;text-decoration:none}}.wt a:hover{{text-decoration:underline}}
.wt b{{color:#e8eaed}}
.bl{{display:flex;gap:8px;padding:8px 16px 12px;flex-wrap:wrap}}
.bl a{{display:inline-block;padding:6px 16px;border:1px solid #8ab4f8;border-radius:4px;color:#8ab4f8;text-decoration:none;font-size:13px;font-weight:500}}
.bl a:hover{{background:rgba(138,180,248,.1)}}
</style>
</head><body>
<div class="w">
<div class="lbl">STRICT Cards v2 PREVIEW — what you see here is what Google Chat will render</div>
<div class="msg" id="m"></div>
</div>
<script>
var J={card_json};
var c=J.cardsV2[0].card,m=document.getElementById('m');
m.innerHTML='<img class="av" src="'+(c.header&&c.header.imageUrl||'')+'">';
var b=document.createElement('div');b.className='mb';
b.innerHTML='<div class="sn">Kel</div>';
var d=document.createElement('div');d.className='cd';
if(c.header)d.innerHTML+='<div class="ch">'+(c.header.imageUrl?'<img src="'+c.header.imageUrl+'">':'')+'<div class="cht"><h3>'+c.header.title+'</h3><p>'+(c.header.subtitle||'')+'</p></div></div>';
(c.sections||[]).forEach(function(s){{
  var e=document.createElement('div');e.className='sc';
  if(s.header)e.innerHTML+='<div class="sh">'+s.header+'</div>';
  (s.widgets||[]).forEach(function(w){{
    if(w.image){{var iu=w.image.imageUrl,ol=w.image.onClick&&w.image.onClick.openLink?w.image.onClick.openLink.url:'';e.innerHTML+=ol?'<div class="wg"><a href="'+ol+'" target="_blank" style="display:block;position:relative"><img src="'+iu+'"><span style="position:absolute;bottom:8px;right:8px;background:rgba(0,0,0,.7);color:#8ab4f8;font-size:11px;padding:3px 8px;border-radius:4px">Click to open</span></a></div>':'<div class="wg"><img src="'+iu+'"></div>'}}
    if(w.textParagraph)e.innerHTML+='<div class="wg"><div class="wt">'+w.textParagraph.text+'</div></div>';
    if(w.decoratedText)e.innerHTML+='<div class="wg"><div class="wt">'+w.decoratedText.text+'</div></div>';
    if(w.buttonList){{var h='<div class="bl">';(w.buttonList.buttons||[]).forEach(function(bt){{var u=bt.onClick&&bt.onClick.openLink?bt.onClick.openLink.url:'#';h+='<a href="'+u+'" target="_blank">'+bt.text+'</a>'}});h+='</div>';e.innerHTML+=h}}
  }});
  d.appendChild(e);
}});
b.appendChild(d);m.appendChild(b);
</script>
</body></html>"##, card_json = card_json)
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Cmd::Send {
            space,
            thread,
            card,
            dry_run,
            no_attribution,
            with_repo,
            permission_to_post_given,
            message,
        } => {
            let cfg = load_webhooks()?;
            let identity = load_identity();

            let entry = cfg.spaces.get(&space).ok_or_else(|| {
                let available: Vec<_> = cfg.spaces.keys().collect();
                anyhow!(
                    "space '{}' not configured. Available: {:?}\nRun `adom-gchat setup` to add it.",
                    space,
                    available
                )
            })?;

            let body = if let Some(card_path) = card {
                let card_json = std::fs::read_to_string(&card_path)
                    .with_context(|| format!("reading card file {}", card_path.display()))?;

                if !dry_run {
                    let hash = card_hash(&card_json);
                    let saved = std::fs::read_to_string(preview_hash_path()).unwrap_or_default();
                    if saved.trim() != hash {
                        eprintln!("ERROR: This card has not been previewed (or was modified after preview).");
                        eprintln!("HINT: Run `adom-gchat preview --card {}` first.", card_path.display());
                        std::process::exit(1);
                    }
                }

                card_json
            } else {
                let text = match message {
                    Some(m) => m,
                    None => {
                        let mut buf = String::new();
                        std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
                        buf.trim().to_string()
                    }
                };
                if text.is_empty() {
                    return Err(anyhow!("no message provided"));
                }

                let prefix = if no_attribution {
                    String::new()
                } else {
                    let template = cfg
                        .attribution
                        .as_deref()
                        .unwrap_or("*{org} (on behalf of {user})*");
                    let user = identity.user.as_deref().unwrap_or("unknown");
                    let org = cfg
                        .org
                        .as_deref()
                        .or(identity.org.as_deref())
                        .unwrap_or("Adom");
                    let repo = identity.repo.as_deref().unwrap_or("");
                    let mut attr = template
                        .replace("{user}", user)
                        .replace("{org}", org)
                        .replace("{repo}", repo);
                    if with_repo && !repo.is_empty() {
                        attr = format!("{attr} via {repo}");
                    }
                    format!("{attr}  ")
                };

                let full_text = format!("{prefix}{text}");
                serde_json::json!({ "text": full_text }).to_string()
            };

            if dry_run {
                println!("OK: dry-run (not posted)");
                println!("SPACE: {space}");
                if let Some(ref t) = thread {
                    println!("THREAD: {t}");
                }
                println!("BODY: {body}");
                println!("HINT: remove --dry-run to post for real");
            } else if !permission_to_post_given {
                // Gate: show preview and refuse to post without the flag
                println!("PREVIEW: the following message will be posted to #{space}:");
                println!("---");
                println!("{body}");
                println!("---");
                println!("HINT: show this preview to the user and get explicit approval.");
                println!("HINT: once the user says 'yes' / 'send it' / 'post it', re-run with --permission-to-post-given.");
                println!("HINT: do NOT pass --permission-to-post-given without actual user confirmation.");
                std::process::exit(2);
            } else {
                send::post_webhook(&entry.url, &body, thread.as_deref())?;
                println!("OK: posted to space '{space}'");
                if let Some(ref t) = thread {
                    println!("THREAD: {t}");
                }
            }
        }

        Cmd::Spaces => {
            let cfg = load_webhooks()?;
            if cfg.spaces.is_empty() {
                println!("WARN: no spaces configured");
                println!("HINT: run `adom-gchat setup` to add a webhook");
                return Ok(());
            }
            let org = cfg.org.as_deref().unwrap_or("(none)");
            println!("OK: {count} space(s) configured", count = cfg.spaces.len());
            println!("ORG: {org}");
            for (name, entry) in &cfg.spaces {
                let desc = entry
                    .description
                    .as_deref()
                    .unwrap_or("(no description)");
                println!("SPACE: {name} — {desc}");
            }
            println!("HINT: use `adom-gchat send --space <name> \"message\"` to post");
            println!("HINT: use --dry-run to preview without posting");
        }

        Cmd::Health => {
            let cfg = load_webhooks()?;
            if cfg.spaces.is_empty() {
                println!("WARN: no spaces configured");
                println!("HINT: run `adom-gchat setup` to add a webhook");
                return Ok(());
            }
            let client = reqwest::blocking::Client::new();
            let mut all_ok = true;
            for (name, entry) in &cfg.spaces {
                let base_url = entry.url.split('?').next().unwrap_or(&entry.url);
                match client
                    .get(base_url)
                    .timeout(std::time::Duration::from_secs(5))
                    .send()
                {
                    Ok(r) if r.status().is_success()
                        || r.status().as_u16() == 400
                        || r.status().as_u16() == 401 =>
                    {
                        println!("OK: {name} (reachable)");
                    }
                    Ok(r) => {
                        println!("FAIL: {name} (HTTP {})", r.status());
                        all_ok = false;
                    }
                    Err(e) => {
                        println!("FAIL: {name} ({e})");
                        all_ok = false;
                    }
                }
            }
            if all_ok {
                println!("HINT: all spaces reachable — ready to send");
            } else {
                println!("HINT: check webhook URLs with `adom-gchat setup`");
                std::process::exit(1);
            }
        }

        Cmd::Setup => {
            let mut cfg = load_webhooks()?;

            const KEL_AVATAR_URL: &str = "https://wiki.adom.inc/blob/app/adom-gchat/icons/kel/png/256x256.png";
            const KEL_BOT_NAME: &str = "Kel";

            println!("adom-gchat setup");
            println!();

            print!("Org name (e.g. acme-robotics) [{}]: ", cfg.org.as_deref().unwrap_or(""));
            std::io::Write::flush(&mut std::io::stdout())?;
            let mut line = String::new();
            std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line)?;
            let line = line.trim();
            if !line.is_empty() {
                cfg.org = Some(line.to_string());
            }

            print!("Attribution template [{}]: ",
                cfg.attribution.as_deref().unwrap_or("*{org} (on behalf of {user})*"));
            std::io::Write::flush(&mut std::io::stdout())?;
            let mut line = String::new();
            std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line)?;
            let line = line.trim();
            if !line.is_empty() {
                cfg.attribution = Some(line.to_string());
            }

            println!();
            println!("Now add your Google Chat spaces. For each space:");
            println!("  1. Open Google Chat > Space settings > Apps & integrations > Webhooks");
            println!("  2. Click 'Add webhook'");
            println!("  3. Bot name: {KEL_BOT_NAME}");
            println!("  4. Avatar URL: {KEL_AVATAR_URL}");
            println!("  5. Click Save, then copy the webhook URL");
            println!();

            loop {
                print!("Space name (e.g. engineering, general — empty to finish): ");
                std::io::Write::flush(&mut std::io::stdout())?;
                let mut name = String::new();
                std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut name)?;
                let name = name.trim().to_string();
                if name.is_empty() {
                    break;
                }

                print!("  Paste the webhook URL for '{name}': ");
                std::io::Write::flush(&mut std::io::stdout())?;
                let mut url = String::new();
                std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut url)?;
                let url = url.trim().to_string();
                if !url.starts_with("https://chat.googleapis.com/") {
                    eprintln!("  WARN: doesn't look like a Google Chat webhook URL");
                }

                print!("  Description (optional): ");
                std::io::Write::flush(&mut std::io::stdout())?;
                let mut desc = String::new();
                std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut desc)?;
                let desc = desc.trim().to_string();

                cfg.spaces.insert(
                    name.clone(),
                    SpaceEntry {
                        url,
                        description: if desc.is_empty() { None } else { Some(desc) },
                    },
                );
                println!("  OK: added '{name}'");
            }

            save_webhooks(&cfg)?;
            println!();
            println!("OK: saved to {:?}", config_dir().join("gchat-webhooks.json"));
            if let Some(first) = cfg.spaces.keys().next() {
                println!("HINT: test with `adom-gchat send --space {first} --dry-run \"hello world\"`");
            }
        }

        Cmd::Preview { card, port, tab_name } => {
            let card_json = std::fs::read_to_string(&card)
                .with_context(|| format!("reading card file {}", card.display()))?;

            // Validate it's valid JSON with cardsV2
            let parsed: serde_json::Value = serde_json::from_str(&card_json)
                .with_context(|| "card file is not valid JSON")?;
            if parsed.get("cardsV2").is_none() {
                return Err(anyhow!("card JSON must have a top-level 'cardsV2' array"));
            }

            // Save the hash so send --card can verify
            let hash = card_hash(&card_json);
            std::fs::write(preview_hash_path(), &hash)?;

            // Generate strict Cards v2 preview HTML
            let preview_html = generate_cards_v2_preview(&card_json);

            // Write preview files
            let preview_dir = std::env::temp_dir().join("adom-gchat-preview");
            std::fs::create_dir_all(&preview_dir)?;
            std::fs::write(preview_dir.join("preview.html"), &preview_html)?;
            std::fs::write(preview_dir.join("card.json"), &card_json)?;

            // Start a tiny HTTP server
            let addr = format!("127.0.0.1:{port}");
            eprintln!("OK: preview server on http://{addr}/preview.html");
            eprintln!("HASH: {hash}");
            eprintln!("Hint: `send --card` will verify this hash before posting to non-kel spaces.");

            // Try to open in Hydrogen
            let proxy_base = std::env::var("VSCODE_PROXY_URI")
                .unwrap_or_default()
                .replace("{{port}}", &port.to_string());
            if !proxy_base.is_empty() {
                let url = format!("{proxy_base}preview.html");
                let _ = std::process::Command::new("adom-cli")
                    .args(["hydrogen", "webview", "open-or-refresh",
                           "--name", &tab_name, "--url", &url])
                    .output();
                eprintln!("OK: opened in Hydrogen tab '{tab_name}'");
            }

            // Serve files
            use std::io::{Read, Write};
            let listener = std::net::TcpListener::bind(&addr)
                .with_context(|| format!("binding to {addr}"))?;
            for stream in listener.incoming().flatten() {
                let mut buf = [0u8; 4096];
                let mut reader = std::io::BufReader::new(&stream);
                let n = reader.read(&mut buf).unwrap_or(0);
                let req = String::from_utf8_lossy(&buf[..n]);
                let path = req.split_whitespace().nth(1).unwrap_or("/");

                let (ct, body) = if path.contains("card.json") {
                    ("application/json", card_json.as_bytes())
                } else {
                    ("text/html", preview_html.as_bytes())
                };

                let resp = format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: {ct}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
                    body.len()
                );
                let mut writer = stream;
                let _ = writer.write_all(resp.as_bytes());
                let _ = writer.write_all(body);
            }
        }

        Cmd::Install => {
            let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/home/adom"));

            let skill_content = fetch_or_fallback(
                "https://wiki.adom.inc/blob/app/adom-gchat/skills/adom-gchat/SKILL.md",
                include_str!("../SKILL.md"),
                "SKILL.md",
            );
            let skill_dir = home.join(".claude/skills/adom-gchat");
            std::fs::create_dir_all(&skill_dir)?;
            std::fs::write(skill_dir.join("SKILL.md"), &skill_content)?;
            println!("OK: skill installed to {}", skill_dir.display());

            let completions = r#"complete -W "send spaces health setup preview install publish edit delete" adom-gchat"#;
            let comp_dir = home.join(".bash_completion.d");
            std::fs::create_dir_all(&comp_dir)?;
            std::fs::write(comp_dir.join("adom-gchat"), completions)?;
            println!("OK: bash completions installed");

            // Auto-import the org's gchat config from its private wiki page
            // (slug `adom-gchat-<org>`). The page is org-private, so only org
            // members get a 200 here. We read `org_webhooks` from the repo's
            // page.json. Webhook URLs may be absent (secure default) — in that
            // case we keep any URLs already configured locally and prompt setup.
            let org = load_identity().org.or_else(|| load_webhooks().ok().and_then(|c| c.org));
            if let Some(ref org_name) = org {
                let url = format!(
                    "{}/api/v1/pages/adom-gchat-{}/files/page.json",
                    wiki_base(), org_name
                );
                let token = wiki_token();
                let client = reqwest::blocking::Client::builder()
                    .timeout(std::time::Duration::from_secs(10))
                    .build()
                    .ok();
                let body = client.and_then(|c| {
                    let mut req = c.get(&url).header("User-Agent", "adom-gchat");
                    if !token.is_empty() {
                        req = req.header("Authorization", format!("Bearer {token}"));
                    }
                    req.send().ok()
                }).and_then(|r| if r.status().is_success() { r.text().ok() } else { None });

                let mut imported = false;
                if let Some(page_json) = body {
                    if let Ok(pj) = serde_json::from_str::<serde_json::Value>(&page_json) {
                        if let Some(webhooks) = pj.get("org_webhooks") {
                            if let Ok(mut org_cfg) =
                                serde_json::from_value::<WebhookConfig>(webhooks.clone())
                            {
                                // Preserve any locally-configured URLs for spaces
                                // whose URL the org page intentionally omitted.
                                let local = load_webhooks().unwrap_or(WebhookConfig {
                                    org: None, attribution: None, spaces: HashMap::new(),
                                });
                                for (name, sp) in org_cfg.spaces.iter_mut() {
                                    if sp.url.is_empty() {
                                        if let Some(l) = local.spaces.get(name) {
                                            sp.url = l.url.clone();
                                        }
                                    }
                                }
                                let total = org_cfg.spaces.len();
                                let with_url = org_cfg.spaces.values()
                                    .filter(|s| !s.url.is_empty()).count();
                                save_webhooks(&org_cfg)?;
                                println!("OK: imported {total} space(s) from org '{org_name}' config ({with_url} with webhook URLs)");
                                if with_url < total {
                                    println!("HINT: {} space(s) still need a webhook URL — run `adom-gchat setup` to paste them.", total - with_url);
                                }
                                imported = true;
                            }
                        }
                    }
                }
                if !imported {
                    println!("HINT: no org gchat config found for '{org_name}' — run `adom-gchat setup` to configure manually");
                }
            } else {
                let existing = load_webhooks()?;
                if existing.spaces.is_empty() {
                    println!("HINT: run `adom-gchat setup` to configure your Google Chat spaces");
                } else {
                    println!("OK: {} space(s) already configured", existing.spaces.len());
                }
            }
        }

        Cmd::Publish { org, include_webhook_urls } => {
            let cfg = load_webhooks()?;
            let org_name = org
                .or_else(|| cfg.org.clone())
                .ok_or_else(|| anyhow!("no org set — pass --org <name> or run `adom-gchat setup`"))?;
            if cfg.spaces.is_empty() {
                return Err(anyhow!("no spaces configured — run `adom-gchat setup` first"));
            }
            let base = wiki_base();
            let token = wiki_token();
            if token.is_empty() {
                return Err(anyhow!(
                    "no wiki token found (looked in /var/run/adom/api-key and ~/.config/adom-wiki/token)"
                ));
            }
            let slug = format!("adom-gchat-{org_name}");
            let auth = format!("Bearer {token}");
            let ua = concat!("adom-gchat/", env!("CARGO_PKG_VERSION"));
            let client = reqwest::blocking::Client::builder()
                .timeout(std::time::Duration::from_secs(20))
                .build()?;

            // Build the publishable config — strip secret URLs unless opted in.
            let mut pub_cfg = cfg.clone();
            pub_cfg.org = Some(org_name.clone());
            if !include_webhook_urls {
                for sp in pub_cfg.spaces.values_mut() {
                    sp.url = String::new();
                }
            }

            // 1. Ensure the page exists, org-owned, and private. Only create on
            //    an explicit 404 — a transient error (429/5xx) must NOT trigger a
            //    bogus create against an existing slug.
            let exists = match client
                .get(format!("{base}/api/v1/pages/{slug}"))
                .header("User-Agent", ua)
                .header("Authorization", &auth)
                .send()
            {
                Ok(r) if r.status().is_success() => true,
                Ok(r) if r.status().as_u16() == 404 => false,
                Ok(r) => return Err(anyhow!("wiki returned HTTP {} checking {slug} — try again in a moment", r.status())),
                Err(e) => return Err(anyhow!("can't reach the wiki to check {slug}: {e}")),
            };
            if !exists {
                let create = serde_json::json!({
                    "type": "app", "slug": slug, "version": "1.0.0",
                    "title": format!("adom-gchat — {org_name} Org Config"),
                    "brief": format!("{org_name}'s Google Chat config for adom-gchat."),
                    "install": { "binary_name": slug },
                });
                let r = client
                    .post(format!("{base}/api/v1/pages"))
                    .header("User-Agent", ua)
                    .header("Authorization", &auth)
                    .json(&create)
                    .send()?;
                if !r.status().is_success() {
                    return Err(anyhow!("failed to create page {slug}: HTTP {}", r.status()));
                }
                let _ = client
                    .post(format!("{base}/api/v1/pages/{slug}/transfer"))
                    .header("User-Agent", ua).header("Authorization", &auth)
                    .json(&serde_json::json!({ "org": org_name })).send();
                let _ = client
                    .post(format!("{base}/api/v1/pages/{slug}/visibility"))
                    .header("User-Agent", ua).header("Authorization", &auth)
                    .json(&serde_json::json!({ "visibility": "private" })).send();
                println!("OK: created private org page {slug}");
            }

            // 2. Merge org_webhooks into the existing page.json (keep other fields).
            let mut page_json: serde_json::Value = client
                .get(format!("{base}/api/v1/pages/{slug}/files/page.json"))
                .header("User-Agent", ua).header("Authorization", &auth)
                .send().ok()
                .and_then(|r| if r.status().is_success() { r.text().ok() } else { None })
                .and_then(|t| serde_json::from_str(&t).ok())
                .unwrap_or_else(|| serde_json::json!({
                    "slug": slug, "type": "app", "version": "1.0.0",
                    "title": format!("adom-gchat — {org_name} Org Config"),
                }));
            page_json["org_webhooks"] = serde_json::to_value(&pub_cfg)?;
            page_json["visibility"] = serde_json::json!("private");

            // 3. Auto-generate the masked README (regenerate just the config
            //    block if the page already has one; else write a full template).
            //    Built from the LOCAL cfg so URLs show masked even though the
            //    page stores none.
            let block = org_config_block(&cfg);
            let existing_readme = client
                .get(format!("{base}/api/v1/pages/{slug}/files/README.md"))
                .header("User-Agent", ua).header("Authorization", &auth)
                .send().ok()
                .and_then(|r| if r.status().is_success() { r.text().ok() } else { None })
                .unwrap_or_default();
            let readme = match (existing_readme.find(CFG_START), existing_readme.find(CFG_END)) {
                (Some(s), Some(e)) if e > s => {
                    format!("{}{}{}", &existing_readme[..s], block, &existing_readme[e + CFG_END.len()..])
                }
                _ => org_readme_full(&org_name, &block),
            };

            // 4. Commit page.json + README.md together.
            let payload = serde_json::json!({
                "files": [
                    { "path": "page.json", "content": serde_json::to_string_pretty(&page_json)? + "\n" },
                    { "path": "README.md", "content": readme },
                ],
                "message": "Publish org gchat config (org_webhooks + masked README)",
            });
            let r = client
                .post(format!("{base}/api/v1/pages/{slug}/files"))
                .header("User-Agent", ua).header("Authorization", &auth)
                .json(&payload).send()?;
            if !r.status().is_success() {
                return Err(anyhow!(
                    "failed to write page.json: HTTP {} — {}",
                    r.status(), r.text().unwrap_or_default()
                ));
            }

            let total = pub_cfg.spaces.len();
            println!("OK: published {total} space(s) to {base}/{org_name}/{slug} (private)");
            if include_webhook_urls {
                println!("WARN: webhook URLs are now stored in this page's git repo — keep the page private.");
            } else {
                println!("NOTE: webhook URLs were NOT published (secure default — they're secrets and the page is git-backed).");
                println!("      Teammates run `adom-gchat setup` to paste the shared URL once, or re-run with --include-webhook-urls.");
            }
            println!("HINT: teammates whose org is '{org_name}' auto-import this on `adom-gchat install`.");
        }

        Cmd::Edit { .. } => {
            deeper_features_hint("edit");
            std::process::exit(2);
        }

        Cmd::Delete { .. } => {
            deeper_features_hint("delete");
            std::process::exit(2);
        }
    }

    Ok(())
}