//! Harness-owned thread titles, generated by the CLI's own fast model.
//!
//! Port of `packages/harness/src/title-generator.ts`, which the cluster runs
//! and the desktop previously had no counterpart for. Both sides share the
//! design that matters: the title is produced by the SAME CLI (and therefore
//! the same account and credentials) that runs the turn, using that CLI's
//! cheapest model — `claude-code:haiku`, `codex:gpt-5.6-luna`, the exact ids
//! `claude-code/index.ts` and `codex/index.ts` pass to `genTitle`. Nothing
//! here spends Studio credits or reaches the cluster.
//!
//! OpenCode is intentionally absent from this one-shot path: its TUI owns a
//! hidden title agent and publishes the result through its native session/OSC
//! title lifecycle. A competing subprocess would waste a model call and race
//! the provider's authoritative title.
//!
//! The differences from the TypeScript are forced by the boundary, not chosen:
//! it calls the model through the AI SDK's `generateObject` with a Zod schema,
//! while this can only exec the CLI and read stdout. So the schema becomes an
//! instruction to answer in JSON plus a tolerant parse, and `retry` becomes a
//! single attempt — the caller already has a deterministic title on screen, so
//! a failure here costs a nicer name, never a name.

use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;

use serde_json::Value;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command;

use crate::resolve::{resolve_argv, HarnessId};

/// Verbatim from `TITLE_GENERATOR_PROMPT`, with the final instruction kept —
/// the TypeScript gets its JSON from `generateObject`'s schema, this asks for
/// it in words, and [`extract_title`] tolerates a model that answers in prose
/// anyway.
const TITLE_PROMPT: &str = r#"Generate a concise, sentence-case title (3-7 words) that captures the main topic or goal of this session. Use sentence case: capitalize only the first word and proper nouns.

Good examples:
- Fix login button on mobile
- Add OAuth authentication
- Query product catalog data
- Set up event subscriptions

Bad (too vague): Help with task
Bad (too long): Investigate and fix the issue where the login button does not respond on mobile devices
Bad (wrong case): Fix Login Button On Mobile

Respond with a JSON object containing the title.

Session:
"#;

/// `TITLE_GEN_TIMEOUT_MS`. A slow title model must never keep the thread on
/// the fallback name for the whole run.
pub const TITLE_TIMEOUT: Duration = Duration::from_secs(20);

/// Longest title kept, matching the TypeScript's `.slice(0, 60)`.
const MAX_TITLE_CHARS: usize = 60;

/// How much of the message the title model is shown. A whole pasted file is
/// useless to a 3-7 word title.
const MAX_PROMPT_CHARS: usize = 2000;

/// Provider state that a one-shot title process is allowed to inherit from
/// its interactive terminal. Codex requires the managed account home so title
/// generation cannot silently use another global account or configuration.
#[derive(Clone, Debug, Default)]
pub struct TitleEnvironment {
    codex_home: Option<PathBuf>,
}

impl TitleEnvironment {
    pub fn for_codex_home(codex_home: PathBuf) -> Self {
        Self {
            codex_home: Some(codex_home),
        }
    }
}

/// The cheapest model each CLI offers, by the same wire ids the TypeScript
/// harnesses pass to `genTitle`.
fn title_model(harness: HarnessId) -> Option<&'static str> {
    match harness {
        HarnessId::ClaudeCode => Some("haiku"),
        HarnessId::Codex => Some("gpt-5.6-luna"),
        HarnessId::OpenCode => None,
    }
}

/// A title for `user_message`, or `None` when the CLI is unavailable, fails,
/// answers with nothing usable, or runs past `timeout`.
///
/// `None` is not an error worth surfacing — the caller keeps the deterministic
/// title it already showed.
pub async fn generate_title(
    harness: HarnessId,
    cwd: &Path,
    user_message: &str,
    environment: &TitleEnvironment,
    timeout: Duration,
) -> Option<String> {
    // Prefer the authoritative title emitted by OpenCode's live TUI. Keep this
    // check before binary resolution so the fallback helper never even probes
    // or spawns a competing OpenCode process.
    title_model(harness)?;
    let mut argv = resolve_argv(harness).ok()?;
    let binary = argv.remove(0);
    let prompt = format!(
        "{TITLE_PROMPT}{}",
        user_message
            .chars()
            .take(MAX_PROMPT_CHARS)
            .collect::<String>()
    );
    append_title_args(harness, &mut argv);

    let mut command = Command::new(&binary);
    command
        .args(&argv)
        .current_dir(cwd)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .kill_on_drop(true);
    if harness == HarnessId::Codex {
        // Failing closed keeps a title helper from falling through to an
        // unrelated ~/.codex account if launch context was not carried over.
        command.env("CODEX_HOME", environment.codex_home.as_ref()?);
    }
    let mut child = match command.spawn() {
        Ok(child) => child,
        Err(error) => {
            tracing::debug!(binary = %binary, %error, "title generation spawn failed");
            return None;
        }
    };
    let mut stdin = child.stdin.take()?;
    let mut stdout = child.stdout.take()?;

    let collected = tokio::time::timeout(timeout, async {
        stdin.write_all(prompt.as_bytes()).await.ok()?;
        stdin.shutdown().await.ok()?;
        drop(stdin);
        let mut buffer = String::new();
        stdout.read_to_string(&mut buffer).await.ok()?;
        let _ = child.wait().await;
        Some(buffer)
    })
    .await
    .ok()
    .flatten()?;

    extract_title(harness, &collected)
}

/// One-shot, read-only, cheapest model. Mirrors the `toolApprovalLevel:
/// "readonly", isPlanMode: true` the TypeScript builds its title model with —
/// a title call must never be able to touch the worktree it is named after.
fn append_title_args(harness: HarnessId, argv: &mut Vec<String>) {
    let Some(model) = title_model(harness).map(str::to_string) else {
        return;
    };
    match harness {
        HarnessId::ClaudeCode => {
            argv.push("-p".to_string());
            argv.push("--output-format".to_string());
            argv.push("json".to_string());
            argv.push("--model".to_string());
            argv.push(model);
            // No MCP config, and the user's own servers stay out: a title call
            // needs no tools at all.
            argv.push("--strict-mcp-config".to_string());
            argv.push("--safe-mode".to_string());
            argv.push("--disable-slash-commands".to_string());
            argv.push("--no-session-persistence".to_string());
            argv.push("--permission-mode".to_string());
            argv.push("bypassPermissions".to_string());
            argv.push("--disallowedTools".to_string());
            argv.push(
                [
                    "Write",
                    "Edit",
                    "Bash",
                    "NotebookEdit",
                    "AskUserQuestion",
                    "ExitPlanMode",
                    "EnterWorktree",
                    "ExitWorktree",
                    "Config",
                ]
                .join(","),
            );
        }
        HarnessId::Codex => {
            // These are global flags and must precede the `exec` subcommand.
            // The managed home supplies auth only; title generation does not
            // load its profiles, hooks, apps, plugins, or project rules.
            for feature in ["apps", "plugins", "hooks"] {
                argv.push("--disable".to_string());
                argv.push(feature.to_string());
            }
            argv.push("exec".to_string());
            argv.push("--json".to_string());
            argv.push("--skip-git-repo-check".to_string());
            argv.push("--ignore-user-config".to_string());
            argv.push("--ignore-rules".to_string());
            argv.push("--ephemeral".to_string());
            argv.push("--model".to_string());
            argv.push(model);
            argv.push("--sandbox".to_string());
            argv.push("read-only".to_string());
            // Explicit `-` documents that the prompt arrives over stdin and
            // keeps user text out of argv/process listings.
            argv.push("-".to_string());
        }
        HarnessId::OpenCode => unreachable!("OpenCode uses its provider-owned TUI title"),
    }
}

/// The assistant's answer, dug out of whichever envelope the CLI used.
fn assistant_text(harness: HarnessId, stdout: &str) -> Option<String> {
    match harness {
        // `--output-format json` is ONE object whose `result` is the text.
        HarnessId::ClaudeCode => serde_json::from_str::<Value>(stdout.trim())
            .ok()?
            .get("result")?
            .as_str()
            .map(str::to_string),
        // `--json` is a JSONL event stream; the answer is the last completed
        // `agent_message` item.
        HarnessId::Codex => stdout
            .lines()
            .filter_map(|line| serde_json::from_str::<Value>(line.trim()).ok())
            .filter_map(|event| {
                let item = event.get("item")?;
                if item.get("type").and_then(Value::as_str) != Some("agent_message") {
                    return None;
                }
                item.get("text").and_then(Value::as_str).map(str::to_string)
            })
            .next_back(),
        HarnessId::OpenCode => None,
    }
}

/// Sanitizer, ported from `genTitle`'s post-processing: drop trailing
/// punctuation, clamp to 60 characters, trim, and keep it only if it holds a
/// letter or a digit in any script.
fn sanitize_title(raw: &str) -> Option<String> {
    let title = raw
        .trim()
        .trim_matches(|c| c == '"' || c == '\'' || c == '`')
        .trim_end_matches(['.', '!', '?'])
        .chars()
        .take(MAX_TITLE_CHARS)
        .collect::<String>()
        .trim()
        .to_string();
    title.chars().any(char::is_alphanumeric).then_some(title)
}

/// The title inside the assistant's answer.
///
/// Prefers the requested `{"title": …}` — possibly inside a ```json fence, and
/// possibly with prose around it — and otherwise treats the whole answer as
/// the title, which is what a model that ignored the JSON instruction gives.
fn extract_title(harness: HarnessId, stdout: &str) -> Option<String> {
    let text = assistant_text(harness, stdout)?;
    let from_json = text
        .find('{')
        .zip(text.rfind('}'))
        .filter(|(start, end)| start < end)
        .and_then(|(start, end)| serde_json::from_str::<Value>(&text[start..=end]).ok())
        .and_then(|value| {
            value
                .get("title")
                .and_then(Value::as_str)
                .map(str::to_string)
        });
    sanitize_title(&from_json.unwrap_or(text))
}

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

    #[test]
    fn a_claude_json_envelope_yields_the_title_inside_it() {
        let stdout = serde_json::json!({
            "type": "result",
            "subtype": "success",
            "result": "{\"title\": \"Fix login button on mobile\"}"
        })
        .to_string();
        assert_eq!(
            extract_title(HarnessId::ClaudeCode, &stdout).as_deref(),
            Some("Fix login button on mobile")
        );
    }

    #[test]
    fn a_codex_event_stream_yields_its_last_agent_message() {
        let stdout = [
            r#"{"type":"thread.started","thread_id":"t"}"#,
            r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"thinking"}}"#,
            r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"{\"title\":\"Add OAuth authentication\"}"}}"#,
        ]
        .join("\n");
        assert_eq!(
            extract_title(HarnessId::Codex, &stdout).as_deref(),
            Some("Add OAuth authentication")
        );
    }

    /// A model that ignores the JSON instruction still gets used — the answer
    /// itself is the title.
    #[test]
    fn a_bare_or_fenced_answer_is_still_a_title() {
        let bare = serde_json::json!({"result": "Query product catalog data."}).to_string();
        assert_eq!(
            extract_title(HarnessId::ClaudeCode, &bare).as_deref(),
            Some("Query product catalog data")
        );
        let fenced = serde_json::json!({
            "result": "Here you go:\n```json\n{\"title\": \"Set up event subscriptions\"}\n```"
        })
        .to_string();
        assert_eq!(
            extract_title(HarnessId::ClaudeCode, &fenced).as_deref(),
            Some("Set up event subscriptions")
        );
    }

    #[test]
    fn unusable_answers_and_broken_output_produce_no_title() {
        for answer in ["", "   ", "...", "!!!", "🙂"] {
            let stdout = serde_json::json!({ "result": answer }).to_string();
            assert_eq!(
                extract_title(HarnessId::ClaudeCode, &stdout),
                None,
                "{answer:?}"
            );
        }
        assert_eq!(extract_title(HarnessId::ClaudeCode, "not json"), None);
        assert_eq!(extract_title(HarnessId::Codex, "not json"), None);
    }

    #[test]
    fn a_long_title_is_clamped_and_quotes_are_stripped() {
        let long = "a".repeat(200);
        let stdout = serde_json::json!({ "result": format!("\"{long}\"") }).to_string();
        let title = extract_title(HarnessId::ClaudeCode, &stdout).unwrap();
        assert_eq!(title.chars().count(), MAX_TITLE_CHARS);
    }

    #[test]
    fn title_invocations_keep_prompt_text_out_of_argv() {
        let secret_prompt = "customer secret that must stay off argv";
        for harness in HarnessId::ALL {
            let mut argv = Vec::new();
            append_title_args(harness, &mut argv);
            assert!(!argv.iter().any(|argument| argument == secret_prompt));
        }
    }

    #[test]
    fn opencode_defers_to_its_provider_owned_tui_title() {
        assert_eq!(title_model(HarnessId::OpenCode), None);
        let mut argv = Vec::new();
        append_title_args(HarnessId::OpenCode, &mut argv);
        assert!(argv.is_empty());
        assert_eq!(assistant_text(HarnessId::OpenCode, "anything"), None);
    }

    #[test]
    fn codex_title_invocation_disables_integrations_before_exec() {
        let mut argv = Vec::new();
        append_title_args(HarnessId::Codex, &mut argv);
        let exec = argv.iter().position(|argument| argument == "exec").unwrap();
        assert_eq!(
            &argv[..exec],
            [
                "--disable",
                "apps",
                "--disable",
                "plugins",
                "--disable",
                "hooks"
            ]
        );
        assert!(argv[exec..].contains(&"--ignore-user-config".to_string()));
        assert!(argv[exec..].contains(&"--ignore-rules".to_string()));
        assert_eq!(argv.last().map(String::as_str), Some("-"));
    }
}
