//! Adapter discovery and the interactive registration picker: probe the
//! environment for configurable adapters, prompt the operator, and persist the
//! picks to `config.toml`. Config-time and interactive - kept off the
//! sync-time seam in `mod.rs`.

use std::path::Path;

use anyhow::{Context, bail};
use serde_json::Value;
use toml_edit::{DocumentMut, Item, Table};

use super::{Env, by_name, known_names, probe_all};

/// One discovered adapter: its name, a hint to show the operator (typically
/// the probed path or endpoint), and the JSON config blob that will be
/// persisted under `[adapters.<name>]` if the operator confirms.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
    pub name: String,
    pub hint: String,
    pub config: Value,
}

/// Probe every registered factory whose name is NOT already a key in
/// `configured`. Used by `pond sync` to spot freshly-detectable adapters
/// without re-prompting the operator about ones they already opted in or
/// out of. Returns `Candidate`s in registry order.
pub fn probe_unconfigured(
    configured: &std::collections::BTreeMap<String, Value>,
) -> Vec<Candidate> {
    let Some(env) = Env::from_env() else {
        return Vec::new();
    };
    super::registry()
        .iter()
        .filter(|factory| !configured.contains_key(factory.name()))
        .filter_map(|factory| {
            factory.probe_default(&env).map(|config| Candidate {
                name: factory.name().to_owned(),
                hint: hint_for(&config),
                config,
            })
        })
        .collect()
}

/// Probe every registered factory (or just the one named in `focus`) under
/// the current environment and shape results into [`Candidate`]s for the
/// picker. Returns an empty list when no factory's `probe_default` returned
/// anything - the caller surfaces a "configure manually" error in that case.
pub fn discover(focus: Option<&str>) -> Vec<Candidate> {
    let Some(env) = Env::from_env() else {
        return Vec::new();
    };
    let candidates: Vec<Candidate> = match focus {
        None => probe_all(&env)
            .into_iter()
            .map(|(name, config)| Candidate {
                name: name.to_owned(),
                hint: hint_for(&config),
                config,
            })
            .collect(),
        Some(name) => by_name(name)
            .and_then(|factory| factory.probe_default(&env))
            .map(|config| Candidate {
                name: name.to_owned(),
                hint: hint_for(&config),
                config,
            })
            .into_iter()
            .collect(),
    };
    candidates
}

/// Best-effort label for the picker. For filesystem configs that's the
/// `path` (with `$HOME` contracted to `~` for readability); for richer
/// configs we fall back to a compact JSON dump so the operator at least sees
/// what they're confirming.
fn hint_for(config: &Value) -> String {
    if let Some(path) = config.get("path").and_then(Value::as_str) {
        return crate::config::contract_home(Path::new(path))
            .display()
            .to_string();
    }
    if let Some(endpoint) = config.get("endpoint").and_then(Value::as_str) {
        return endpoint.to_owned();
    }
    serde_json::to_string(config).unwrap_or_default()
}

/// Prompt the operator to pick which `candidates` to register, then persist
/// the chosen entries to `config.toml` and return them. Pre-checks every
/// candidate (the operator already opted in by running `pond sync`). When
/// `stdin_is_tty` is false we never prompt - we bail with a clear "configure
/// manually" message so CI and post-install scripts get a predictable error
/// instead of a hang. The caller injects TTY-ness so tests can drive the
/// non-tty branch deterministically regardless of how `cargo test` is invoked.
pub fn prompt_and_persist(
    config_path: &Path,
    candidates: &[Candidate],
    stdin_is_tty: bool,
) -> anyhow::Result<Vec<Candidate>> {
    if candidates.is_empty() {
        bail!(
            "no adapters detected in this environment; known adapters: {}",
            known_names().join(", "),
        );
    }
    if !stdin_is_tty {
        bail!(
            "[adapters] is empty and stdin is not a terminal; run `pond init --yes` to enable \
             detected adapters, or add an [adapters.<adapter>] entry to {} (known adapters: {})",
            config_path.display(),
            known_names().join(", "),
        );
    }
    let mut picker = cliclack::multiselect("Select adapters to register")
        .initial_values(candidates.iter().map(|c| c.name.clone()).collect());
    for candidate in candidates {
        picker = picker.item(candidate.name.clone(), &candidate.name, &candidate.hint);
    }
    let selected: Vec<String> = match picker.interact() {
        Ok(picks) => picks,
        // Esc / Ctrl-C in the picker means "register nothing", same outcome
        // as an empty selection - not a failure.
        Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {
            bail!("no adapters selected; nothing to sync");
        }
        Err(error) => return Err(error).context("adapter picker prompt failed"),
    };
    if selected.is_empty() {
        bail!("no adapters selected; nothing to sync");
    }
    let picks: Vec<Candidate> = candidates
        .iter()
        .filter(|candidate| selected.contains(&candidate.name))
        .cloned()
        .collect();
    persist_accept(config_path, &picks)?;
    Ok(picks)
}

/// Shape accepted and declined adapters into a `toml_edit` document:
/// `[adapters.<name>]` with `enabled` as the first key, the accept's config
/// blob unpacked into TOML pairs (home-prefixed `path` values contracted to
/// `~/...` so the file stays portable), declines as `enabled = false` stubs.
/// The one table-shaping routine shared by the sync picker writes and the
/// `pond init` wizard's single end-of-run write.
pub fn apply_to_doc(
    doc: &mut DocumentMut,
    accepts: &[Candidate],
    declines: &[&str],
) -> anyhow::Result<()> {
    if accepts.is_empty() && declines.is_empty() {
        return Ok(());
    }
    if !doc.contains_key("adapters") {
        let mut table = Table::new();
        table.set_implicit(true);
        doc.insert("adapters", Item::Table(table));
    }
    let adapters = adapters_table_mut(doc)?;
    for pick in accepts {
        let mut entry = json_to_toml_table(&pick.config).with_context(|| {
            format!(
                "pick for {:?} did not produce a TOML-shaped table",
                pick.name
            )
        })?;
        contract_path_key(&mut entry);
        prepend_enabled(&mut entry, true);
        adapters.insert(&pick.name, Item::Table(entry));
    }
    for name in declines {
        let mut entry = Table::new();
        prepend_enabled(&mut entry, false);
        adapters.insert(name, Item::Table(entry));
    }
    Ok(())
}

/// Contract a home-prefixed `path` value to `~/...` before it lands in
/// config.toml. Reads (`expand_home_under` inside each factory's `open()`)
/// expand the same prefix back, so the round-trip is lossless.
fn contract_path_key(table: &mut Table) {
    if let Some(path) = table.get("path").and_then(Item::as_str) {
        let contracted = crate::config::contract_home(Path::new(path))
            .display()
            .to_string();
        if contracted != path {
            table["path"] = toml_edit::value(contracted);
        }
    }
}

/// Write the picked adapters back to `config.toml` under `[adapters.<name>]`,
/// preserving any existing user comments/formatting via `toml_edit`.
pub fn persist_accept(config_path: &Path, picks: &[Candidate]) -> anyhow::Result<()> {
    let mut doc = open_or_init(config_path)?;
    apply_to_doc(&mut doc, picks, &[])?;
    crate::config::write_config_file(config_path, &doc.to_string())?;
    Ok(())
}

/// Flip `[adapters.<name>].enabled` on an already-configured adapter. Returns
/// `false` (writing nothing) when the section is absent - the caller decides
/// whether to discover it first. Backs `pond adapters enable|disable`.
pub fn set_adapter_enabled(config_path: &Path, name: &str, enabled: bool) -> anyhow::Result<bool> {
    let mut doc = open_or_init(config_path)?;
    let adapters = adapters_table_mut(&mut doc)?;
    let Some(entry) = adapters.get_mut(name).and_then(Item::as_table_mut) else {
        return Ok(false);
    };
    entry.insert("enabled", toml_edit::value(enabled));
    crate::config::write_config_file(config_path, &doc.to_string())?;
    Ok(true)
}

fn open_or_init(config_path: &Path) -> anyhow::Result<DocumentMut> {
    if let Some(parent) = config_path.parent() {
        std::fs::create_dir_all(parent)
            .with_context(|| format!("failed to create config dir {}", parent.display()))?;
    }
    let existing = if config_path.exists() {
        std::fs::read_to_string(config_path)
            .with_context(|| format!("failed to read {}", config_path.display()))?
    } else {
        String::new()
    };
    let mut doc: DocumentMut = existing
        .parse()
        .with_context(|| format!("failed to parse {} as TOML", config_path.display()))?;
    if !doc.contains_key("adapters") {
        let mut table = Table::new();
        table.set_implicit(true);
        doc.insert("adapters", Item::Table(table));
    }
    Ok(doc)
}

fn adapters_table_mut(doc: &mut DocumentMut) -> anyhow::Result<&mut Table> {
    doc["adapters"]
        .as_table_mut()
        .ok_or_else(|| anyhow::anyhow!("config.toml has an `adapters` value that is not a table"))
}

fn prepend_enabled(table: &mut Table, value: bool) {
    use toml_edit::value as tv;
    let implicit = table.is_implicit();
    let keys: Vec<String> = table.iter().map(|(k, _)| k.to_owned()).collect();
    let mut existing: Vec<(String, Item)> = Vec::with_capacity(keys.len());
    for key in keys {
        if key == "enabled" {
            continue;
        }
        if let Some(item) = table.remove(&key) {
            existing.push((key, item));
        }
    }
    table.remove("enabled");
    let mut fresh = Table::new();
    fresh.set_implicit(implicit);
    fresh.insert("enabled", tv(value));
    for (key, item) in existing {
        fresh.insert(&key, item);
    }
    *table = fresh;
}

/// Convert a JSON object into a `toml_edit::Table`. Factories produce JSON
/// blobs (the seam contract); the picker persists them as TOML tables. Non-
/// object roots are rejected with an error rather than silently dropped.
fn json_to_toml_table(value: &Value) -> anyhow::Result<Table> {
    let Value::Object(map) = value else {
        bail!("config blob must be a JSON object, got {value}");
    };
    let mut table = Table::new();
    for (key, val) in map {
        table[key] = json_to_toml_item(val)?;
    }
    Ok(table)
}

fn json_to_toml_item(value: &Value) -> anyhow::Result<Item> {
    use toml_edit::{Array, InlineTable, Value as TomlValue, value as tv};
    Ok(match value {
        Value::Null => bail!("null is not representable in TOML"),
        Value::Bool(b) => tv(*b),
        Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                tv(i)
            } else if let Some(f) = n.as_f64() {
                tv(f)
            } else {
                bail!("number {n} is not representable in TOML");
            }
        }
        Value::String(s) => tv(s.clone()),
        Value::Array(values) => {
            let mut array = Array::new();
            for v in values {
                let item = json_to_toml_item(v)?;
                let toml_value: TomlValue = item.into_value().map_err(|_| {
                    anyhow::anyhow!("array element {v} is not a scalar; nested tables in arrays")
                })?;
                array.push(toml_value);
            }
            Item::Value(TomlValue::Array(array))
        }
        Value::Object(_) => {
            let table = json_to_toml_table(value)?;
            let mut inline = InlineTable::new();
            for (key, item) in table.iter() {
                if let Some(v) = item.as_value() {
                    inline.insert(key, v.clone());
                }
            }
            Item::Value(TomlValue::InlineTable(inline))
        }
    })
}

#[cfg(test)]
mod tests {
    #![allow(clippy::expect_used, clippy::unwrap_used)]

    use super::*;
    use serde_json::json;
    use tempfile::TempDir;

    #[test]
    fn prompt_and_persist_errors_on_non_tty_stdin() {
        // Drive the non-tty branch explicitly. This is the path CI and
        // package-install scripts hit; the picker must surface a clear
        // "configure manually" error instead of hanging on a prompt.
        let temp = TempDir::new().unwrap();
        let config_path = temp.path().join("config.toml");
        let candidates = vec![Candidate {
            name: "claude-code".to_owned(),
            hint: "/tmp/dummy".to_owned(),
            config: json!({ "path": "/tmp/dummy" }),
        }];
        let err = prompt_and_persist(&config_path, &candidates, false)
            .expect_err("non-tty stdin must error rather than hang");
        let msg = err.to_string();
        assert!(
            msg.contains("not a terminal"),
            "error should mention the non-tty branch: {msg}",
        );
    }

    #[test]
    fn apply_to_doc_writes_enabled_first_for_accepts_and_declines() {
        let accept = Candidate {
            name: "claude-code".to_owned(),
            hint: "/tmp/cc".to_owned(),
            config: json!({ "path": "/tmp/cc" }),
        };
        let mut doc = DocumentMut::new();
        apply_to_doc(&mut doc, &[accept], &["opencode"]).unwrap();
        let body = doc.to_string();
        // Accepted entry: discriminator on top, then the blob.
        assert!(
            body.contains("[adapters.claude-code]")
                && body.contains("enabled = true")
                && body.contains("path = \"/tmp/cc\""),
            "expected accepted entry; got: {body}",
        );
        // Declined entry: discriminator only, no path leak.
        assert!(
            body.contains("[adapters.opencode]") && body.contains("enabled = false"),
            "expected declined entry; got: {body}",
        );
    }

    #[test]
    fn set_adapter_enabled_flips_in_place_and_reports_absence() {
        let temp = TempDir::new().unwrap();
        let config_path = temp.path().join("config.toml");
        let accept = Candidate {
            name: "claude-code".to_owned(),
            hint: "/tmp/cc".to_owned(),
            config: json!({ "path": "/tmp/cc" }),
        };
        persist_accept(&config_path, &[accept]).unwrap();

        // Absent adapter: writes nothing, reports false.
        assert!(!set_adapter_enabled(&config_path, "opencode", false).unwrap());

        // Present adapter: flips enabled, keeps the path.
        assert!(set_adapter_enabled(&config_path, "claude-code", false).unwrap());
        let body = std::fs::read_to_string(&config_path).unwrap();
        assert!(
            body.contains("enabled = false") && body.contains("path = \"/tmp/cc\""),
            "flip must preserve the blob; got: {body}",
        );
        assert!(set_adapter_enabled(&config_path, "claude-code", true).unwrap());
        assert!(
            std::fs::read_to_string(&config_path)
                .unwrap()
                .contains("enabled = true")
        );
    }
}
