//! Provider abstraction (plan §10): one trait over two families — native-agent
//! backends (claude/codex driven by subprocess) and raw-model backends
//! (anthropic/openai/openrouter/ollama driven by hephaes's own agent loop).

mod anthropic;
mod claude;
mod codex;
mod openai_compat;

use crate::agent::{AgentOutcome, AgentTask};
use crate::config::{Config, Paths, ProviderId};
use crate::progress::Progress;
use crate::secret;
use anyhow::{Context, Result};
use async_trait::async_trait;

pub use anthropic::AnthropicProvider;
pub use claude::ClaudeProvider;
pub use codex::CodexProvider;
pub use openai_compat::{Flavor, OpenAiCompat};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderKind {
    NativeAgent,
    RawModel,
}

#[derive(Debug, Clone)]
pub enum AuthStatus {
    Ok,
    NeedsLogin,
    MissingKey,
}

impl AuthStatus {
    pub fn label(&self) -> &'static str {
        match self {
            AuthStatus::Ok => "ok",
            AuthStatus::NeedsLogin => "needs login",
            AuthStatus::MissingKey => "no key",
        }
    }
    pub fn is_ok(&self) -> bool {
        matches!(self, AuthStatus::Ok)
    }
}

#[async_trait]
pub trait Provider: Send + Sync {
    fn id(&self) -> ProviderId;
    fn kind(&self) -> ProviderKind;
    /// Run an agentic task: read the repo, write the wiki. Streams progress to
    /// `progress` and returns a summary.
    async fn run_agent(&self, task: &AgentTask, progress: &dyn Progress) -> Result<AgentOutcome>;
    async fn auth_status(&self) -> AuthStatus;
    async fn list_models(&self) -> Result<Vec<String>>;
}

/// Construct the configured provider with an operation-resolved model.
pub fn build(cfg: &Config, paths: &Paths, model: Option<String>) -> Result<Box<dyn Provider>> {
    Ok(match cfg.provider {
        ProviderId::Claude => Box::new(ClaudeProvider::new(model)),
        ProviderId::Codex => Box::new(CodexProvider::new(model)),
        ProviderId::Anthropic => {
            let key = secret::get_key(paths, ProviderId::Anthropic)
                .context("no Anthropic API key — run `hephaes login anthropic`")?;
            Box::new(AnthropicProvider::new(Some(key), model))
        }
        ProviderId::Openai => {
            let key = secret::get_key(paths, ProviderId::Openai)
                .context("no OpenAI API key — run `hephaes login openai`")?;
            Box::new(OpenAiCompat::new(
                Flavor::OpenAi,
                "https://api.openai.com/v1".to_string(),
                Some(key),
                model,
            ))
        }
        ProviderId::Openrouter => {
            let key = secret::get_key(paths, ProviderId::Openrouter)
                .context("no OpenRouter API key — run `hephaes login openrouter`")?;
            Box::new(OpenAiCompat::new(
                Flavor::OpenRouter,
                "https://openrouter.ai/api/v1".to_string(),
                Some(key),
                model,
            ))
        }
        ProviderId::Ollama => {
            let base = format!("{}/v1", cfg.ollama_url.trim_end_matches('/'));
            Box::new(OpenAiCompat::new(Flavor::Ollama, base, None, model))
        }
    })
}

/// Shared reqwest client with a generous timeout for long agent calls.
pub(crate) fn http_client() -> reqwest::Client {
    reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(600))
        .user_agent("hephaes/0.1")
        .build()
        .unwrap_or_else(|_| reqwest::Client::new())
}

// ---- native-agent (subprocess) helpers ----------------------------------

/// Run a CLI with `stdin_data` piped in, streaming stdout line-by-line through
/// `on_line` (so callers can surface live progress) while collecting the full
/// stdout, stderr, and exit status. kill_on_drop + a timeout prevent hangs.
pub(crate) async fn run_streaming(
    program: &str,
    args: &[String],
    cwd: &std::path::Path,
    stdin_data: &str,
    mut on_line: impl FnMut(&str),
) -> Result<(String, String, std::process::ExitStatus)> {
    use std::process::Stdio;
    use std::time::Duration;
    use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};

    const MAX_RUNTIME: Duration = Duration::from_secs(1800);

    let mut child = tokio::process::Command::new(program)
        .args(args)
        .current_dir(cwd)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true)
        .spawn()
        .with_context(|| format!("spawning `{program}` (is it installed and on PATH?)"))?;

    if let Some(mut stdin) = child.stdin.take() {
        stdin
            .write_all(stdin_data.as_bytes())
            .await
            .context("writing to subprocess stdin")?;
        stdin.shutdown().await.ok();
    }

    // Drain stderr concurrently so a full stderr pipe can't deadlock the child.
    let stderr = child.stderr.take();
    let stderr_task = tokio::spawn(async move {
        let mut s = String::new();
        if let Some(e) = stderr {
            let _ = BufReader::new(e).read_to_string(&mut s).await;
        }
        s
    });

    let stdout = child.stdout.take().expect("piped stdout");
    let mut lines = BufReader::new(stdout).lines();
    let mut full = String::new();

    let read = async {
        while let Some(line) = lines.next_line().await? {
            on_line(&line);
            full.push_str(&line);
            full.push('\n');
        }
        let status = child.wait().await?;
        Ok::<_, anyhow::Error>(status)
    };

    let status = match tokio::time::timeout(MAX_RUNTIME, read).await {
        Ok(res) => res?,
        Err(_) => {
            anyhow::bail!(
                "`{program}` timed out after {} minutes",
                MAX_RUNTIME.as_secs() / 60
            )
        }
    };

    let stderr_str = stderr_task.await.unwrap_or_default();
    Ok((full, stderr_str, status))
}

/// Best-effort check that an executable is on PATH. Public so front-ends (the
/// onboarding selector) can show availability hints.
pub fn binary_on_path(name: &str) -> bool {
    let path = match std::env::var_os("PATH") {
        Some(p) => p,
        None => return false,
    };
    for dir in std::env::split_paths(&path) {
        let candidate = dir.join(name);
        if candidate.is_file() {
            return true;
        }
        #[cfg(windows)]
        {
            for ext in ["exe", "cmd", "bat"] {
                if dir.join(format!("{name}.{ext}")).is_file() {
                    return true;
                }
            }
        }
    }
    false
}

/// Compose the prompt for native-agent backends: instruction + (inlined) repo
/// map + extra context.
pub(crate) fn compose_native_prompt(task: &AgentTask) -> String {
    let mut p = task.instruction.clone();
    if !task.repo_map.trim().is_empty() {
        p.push_str("\n\n# Repository map\n\n");
        p.push_str(&crate::util::truncate_chars(&task.repo_map, 16_000));
    }
    if !task.context.trim().is_empty() {
        p.push_str("\n\n");
        p.push_str(&task.context);
    }
    p
}
