use crate::config::{
    self, ensure_store_layout, load_or_create_config, status, store_dir, STORE_SUBDIRECTORIES,
};
use crate::models::OllamaClient;
use crate::store;
use crate::telemetry::{TelemetryConfig, TelemetryStatus};
use crate::ui::{self, Panel};
use anyhow::{Context, Result};
use console::style;
use serde::Serialize;
use std::collections::BTreeMap;
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;

#[derive(Debug, Serialize)]
pub struct PathStatusReport {
    pub path: String,
    pub status: &'static str,
}

#[derive(Debug, Serialize)]
pub struct NamedPathStatusReport {
    pub name: String,
    pub path: String,
    pub status: &'static str,
}

#[derive(Debug, Serialize)]
pub struct ModelInstallReport {
    pub embed_model_installed: bool,
    pub chat_model_installed: bool,
    pub vision_model_installed: bool,
}

#[derive(Debug, Serialize)]
pub struct DoctorReport {
    pub time: u64,
    pub base: PathStatusReport,
    pub store: PathStatusReport,
    pub config: PathStatusReport,
    pub ollama_url: String,
    pub embed_model: String,
    pub chat_model: String,
    pub vision_model: String,
    pub ollama_reachable: bool,
    pub ollama_error: Option<String>,
    pub installed_models: Option<ModelInstallReport>,
    pub telemetry: TelemetryStatus,
    pub telemetry_error: Option<String>,
    pub metadata: PathStatusReport,
    pub metadata_summary: Option<String>,
    pub metadata_error: Option<String>,
    pub subdirectories: Vec<NamedPathStatusReport>,
}

pub async fn run(name: Option<&str>, json: bool) -> Result<()> {
    let report = build_report(name).await?;
    if json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        print_human(&report);
    }
    Ok(())
}

async fn build_report(name: Option<&str>) -> Result<DoctorReport> {
    let store = store_dir(name)?;
    ensure_store_layout(&store)?;
    let cfg = load_or_create_config(&store)?;
    let (telemetry, telemetry_error) = match TelemetryConfig::from_env() {
        Ok(config) => (config.status(), None),
        Err(err) => (TelemetryStatus::from_env_lossy(), Some(err.to_string())),
    };
    let base = config::base_dir()?;
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .context("system time before unix epoch")?
        .as_secs();

    let ollama = OllamaClient::new(cfg.ollama.base_url.clone());
    let (ollama_reachable, ollama_error, installed_models) = match ollama.list_models().await {
        Ok(models) => (
            true,
            None,
            Some(ModelInstallReport {
                embed_model_installed: models.iter().any(|model| model == &cfg.models.embed),
                chat_model_installed: models.iter().any(|model| model == &cfg.models.chat),
                vision_model_installed: models.iter().any(|model| model == &cfg.models.vision),
            }),
        ),
        Err(err) => (false, Some(err.to_string()), None),
    };

    let metadata_path = store::metadata_path(&store);
    let (metadata_summary, metadata_error) = if metadata_path.exists() {
        match fs::read_to_string(&metadata_path) {
            Ok(contents) => (Some(contents.replace('\n', " ")), None),
            Err(err) => (None, Some(err.to_string())),
        }
    } else {
        (None, None)
    };

    let subdirectories = STORE_SUBDIRECTORIES
        .into_iter()
        .map(|sub| {
            let path = store.join(sub);
            NamedPathStatusReport {
                name: sub.to_string(),
                path: path.display().to_string(),
                status: status(path.exists()),
            }
        })
        .collect();

    Ok(DoctorReport {
        time: now,
        base: PathStatusReport {
            path: base.display().to_string(),
            status: status(base.exists()),
        },
        store: PathStatusReport {
            path: store.display().to_string(),
            status: status(store.exists()),
        },
        config: PathStatusReport {
            path: config::config_path(&store).display().to_string(),
            status: status(config::config_path(&store).exists()),
        },
        ollama_url: cfg.ollama.base_url,
        embed_model: cfg.models.embed,
        chat_model: cfg.models.chat,
        vision_model: cfg.models.vision,
        ollama_reachable,
        ollama_error,
        installed_models,
        telemetry,
        telemetry_error,
        metadata: PathStatusReport {
            path: metadata_path.display().to_string(),
            status: status(metadata_path.exists()),
        },
        metadata_summary,
        metadata_error,
        subdirectories,
    })
}

fn print_human(report: &DoctorReport) {
    ui::command_header("ragcli doctor", format_unix_timestamp(report.time));

    // ── Paths ─────────────────────────────────────────────────────────────────
    let mut paths_box = Panel::new("Paths");
    paths_box.push(path_row_str("base", &report.base, 8));
    paths_box.push(path_row_str("store", &report.store, 8));
    paths_box.push(path_row_str("config", &report.config, 8));
    paths_box.push(path_row_str("metadata", &report.metadata, 8));
    if let Some(e) = &report.metadata_error {
        paths_box.push(format!(
            "  {:<8}  {}",
            style("error").red().bold(),
            style(e).red()
        ));
    }
    if let Some(summary) = &report.metadata_summary {
        for row in ui::wrapped_dim_rows("summary", summary, 8, ui::DEFAULT_WRAP_WIDTH) {
            paths_box.push(row);
        }
    }
    paths_box.render();
    println!();

    // ── Ollama ────────────────────────────────────────────────────────────────
    let mut ollama_box = Panel::new("Ollama");
    ollama_box.push(kv_str("url", &report.ollama_url, 9));
    if report.ollama_reachable {
        ollama_box.push(kv_str("reachable", &ui::ok("yes"), 9));
    } else {
        ollama_box.push(kv_str("reachable", &ui::err("no"), 9));
        if let Some(e) = &report.ollama_error {
            ollama_box.push(format!(
                "  {:<9}  {}",
                style("error").red().bold(),
                style(e).red()
            ));
        }
    }
    ollama_box.render();
    println!();

    // ── Models ────────────────────────────────────────────────────────────────
    let mut models_box = Panel::new("Models");
    match &report.installed_models {
        Some(inst) => {
            models_box.push(model_row_str(
                "embed",
                &report.embed_model,
                inst.embed_model_installed,
                6,
            ));
            models_box.push(model_row_str(
                "chat",
                &report.chat_model,
                inst.chat_model_installed,
                6,
            ));
            models_box.push(model_row_str(
                "vision",
                &report.vision_model,
                inst.vision_model_installed,
                6,
            ));
        }
        None => {
            models_box.push(kv_str("embed", &report.embed_model, 6));
            models_box.push(kv_str("chat", &report.chat_model, 6));
            models_box.push(kv_str("vision", &report.vision_model, 6));
        }
    }
    models_box.render();
    println!();

    // ── Telemetry ─────────────────────────────────────────────────────────────
    let mut tel_box = Panel::new("Telemetry");
    let tel = &report.telemetry;
    if tel.enabled {
        tel_box.push(kv_str("enabled", &ui::ok("yes"), 14));
    } else {
        tel_box.push(kv_str("enabled", &ui::err("no"), 14));
    }
    tel_box.push(kv_str("service name", &tel.service_name, 14));
    tel_box.push(kv_str("protocol", &tel.protocol, 14));
    if let Some(endpoint) = &tel.endpoint {
        tel_box.push(kv_str("endpoint", endpoint, 14));
    }
    if let Some(timeout_ms) = tel.timeout_ms {
        tel_box.push(kv_str("timeout (ms)", &timeout_ms.to_string(), 14));
    }
    if tel.headers_configured {
        tel_box.push(kv_str("headers", &ui::ok("configured"), 14));
    } else {
        tel_box.push(kv_str("headers", &ui::err("not configured"), 14));
    }
    if let Some(e) = &report.telemetry_error {
        tel_box.push(format!(
            "  {:<14}  {}",
            style("config error").red().bold(),
            style(e).red()
        ));
    }
    tel_box.render();
    println!();

    // ── Store Layout ──────────────────────────────────────────────────────────
    let mut layout_box = Panel::new("Store Layout");
    let col = report
        .subdirectories
        .iter()
        .map(|s| s.name.len())
        .max()
        .unwrap_or(0);
    for sub in &report.subdirectories {
        let status_str = if sub.status == "exists" {
            ui::ok("exists")
        } else {
            ui::err("missing")
        };
        layout_box.push(format!(
            "  {}  {}  {}",
            style(format!("{:<col$}", sub.name)).bold(),
            style(&sub.path).dim(),
            status_str,
        ));
    }
    layout_box.render();

    print_hints(report);
}

fn print_hints(report: &DoctorReport) {
    let hints = doctor_hints(report);
    if hints.is_empty() {
        return;
    }

    println!();
    let mut hints_box = Panel::new("Hints");
    for hint in hints {
        for row in ui::wrapped_hint_rows(&hint, ui::DEFAULT_WRAP_WIDTH) {
            hints_box.push(row);
        }
    }
    hints_box.render();
}

fn doctor_hints(report: &DoctorReport) -> Vec<String> {
    let mut hints = Vec::new();

    if report.base.status != "exists"
        || report.store.status != "exists"
        || report.config.status != "exists"
        || report
            .subdirectories
            .iter()
            .any(|sub| sub.status != "exists")
    {
        hints.push(format!(
            "Store layout is incomplete. Run `ragcli doctor` again; if it stays missing, check write permissions under `{}`.",
            report.store.path
        ));
    }

    if report.metadata_error.is_some() {
        hints.push(format!(
            "Fix metadata access at `{}`; it should be a readable file, not a directory.",
            report.metadata.path
        ));
    } else if report.metadata.status != "exists" {
        hints.push(
            "No indexed metadata found yet. Run `ragcli index <path>` before querying.".to_string(),
        );
    }

    if report.ollama_reachable {
        if let Some(installed_models) = &report.installed_models {
            hints.extend(model_install_hints(report, installed_models));
        }
    } else {
        hints.push(format!(
            "Start Ollama with `ollama serve`, or set the correct URL with `RAGCLI_OLLAMA_URL` / `ragcli config set ollama.base_url <url>` (current: {}).",
            report.ollama_url
        ));
        hints.push(
            "After Ollama is reachable, rerun `ragcli doctor` to verify configured models."
                .to_string(),
        );
    }

    if report.telemetry_error.is_some() {
        hints.push(
            "Fix telemetry environment variables, or unset OTEL_EXPORTER_OTLP_* to disable telemetry."
                .to_string(),
        );
    } else if !report.telemetry.enabled {
        hints.push(
            "Telemetry is optional. For local traces, run Arize Phoenix and set `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:6006`; for managed tracing like Logfire or Phoenix Cloud, set that provider's OTLP endpoint and headers."
                .to_string(),
        );
    } else if !report.telemetry.headers_configured {
        hints.push(
            "Telemetry export is enabled. Local collectors usually do not need headers; managed services like Logfire or Phoenix Cloud usually require `OTEL_EXPORTER_OTLP_HEADERS`."
                .to_string(),
        );
    }

    hints
}

fn model_install_hints(
    report: &DoctorReport,
    installed_models: &ModelInstallReport,
) -> Vec<String> {
    let mut missing_by_model: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
    let configured = [
        (
            "embed",
            report.embed_model.as_str(),
            installed_models.embed_model_installed,
        ),
        (
            "chat",
            report.chat_model.as_str(),
            installed_models.chat_model_installed,
        ),
        (
            "vision",
            report.vision_model.as_str(),
            installed_models.vision_model_installed,
        ),
    ];

    for (role, model, installed) in configured {
        if !installed {
            missing_by_model.entry(model).or_default().push(role);
        }
    }

    missing_by_model
        .into_iter()
        .map(|(model, roles)| {
            format!(
                "Install the {} model for {}: `ollama pull {}`.",
                if roles.len() == 1 {
                    "missing"
                } else {
                    "shared"
                },
                roles.join("/"),
                model
            )
        })
        .collect()
}

/// Build a plain key/value row string with aligned padding.
fn kv_str(label: &str, value: &str, width: usize) -> String {
    format!("  {}  {}", style(format!("{label:<width$}")).dim(), value)
}

/// Build a path row string: label  path  ✓/✗ status.
fn path_row_str(label: &str, report: &PathStatusReport, width: usize) -> String {
    let status_str = if report.status == "exists" {
        ui::ok("exists")
    } else {
        ui::err("missing")
    };
    format!(
        "  {}  {}  {}",
        style(format!("{label:<width$}")).dim(),
        style(&report.path).dim(),
        status_str,
    )
}

/// Build a model row string: label  model-name  ✓/✗ installed.
fn model_row_str(label: &str, model: &str, installed: bool, width: usize) -> String {
    let status_str = if installed {
        ui::ok("installed")
    } else {
        ui::err("not installed")
    };
    format!(
        "  {}  {:<30}  {}",
        style(format!("{label:<width$}")).dim(),
        model,
        status_str,
    )
}

fn format_unix_timestamp(timestamp: u64) -> String {
    match OffsetDateTime::from_unix_timestamp(timestamp as i64)
        .ok()
        .and_then(|time| time.format(&Rfc3339).ok())
    {
        Some(formatted) => formatted,
        None => timestamp.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::{sequential_json_server, with_test_env};
    use std::env;

    struct ScopedEnvVars {
        previous: Vec<(&'static str, Option<std::ffi::OsString>)>,
    }

    impl ScopedEnvVars {
        fn set(vars: &[(&'static str, &str)]) -> Self {
            let previous = vars
                .iter()
                .map(|(name, _)| (*name, env::var_os(name)))
                .collect();

            unsafe {
                for (name, value) in vars {
                    env::set_var(name, value);
                }
            }

            Self { previous }
        }
    }

    impl Drop for ScopedEnvVars {
        fn drop(&mut self) {
            unsafe {
                for (name, value) in self.previous.drain(..).rev() {
                    restore_var(name, value);
                }
            }
        }
    }

    #[test]
    fn test_format_unix_timestamp_uses_rfc3339_when_possible() {
        assert_eq!(format_unix_timestamp(0), "1970-01-01T00:00:00Z");
    }

    #[tokio::test(flavor = "current_thread")]
    async fn test_build_report_succeeds_on_empty_store() {
        let dir = tempfile::tempdir().unwrap();
        with_test_env(dir.path(), None, || async {
            let report = build_report(Some("empty")).await.unwrap();
            assert_eq!(report.store.status, "exists");
            assert!(serde_json::to_string(&report)
                .unwrap()
                .contains("\"ollama_reachable\""));
            assert!(!report.telemetry.enabled);
        })
        .await;
    }

    #[tokio::test(flavor = "current_thread")]
    async fn test_build_report_succeeds_with_reachable_mock_ollama() {
        let dir = tempfile::tempdir().unwrap();
        let server = sequential_json_server(vec![
            r#"{"models":[{"name":"nomic-embed-text-v2-moe:latest"},{"name":"qwen3.5:4b"}]}"#,
        ]);

        with_test_env(dir.path(), Some(&server), || async {
            let report = build_report(Some("reachable")).await.unwrap();
            assert!(report.ollama_reachable);
            assert!(report.installed_models.is_some());
            assert_eq!(report.subdirectories[0].name, "lancedb");
        })
        .await;
    }

    #[tokio::test(flavor = "current_thread")]
    async fn test_build_report_keeps_running_when_metadata_read_fails() {
        let dir = tempfile::tempdir().unwrap();
        with_test_env(dir.path(), None, || async {
            let store = store_dir(Some("broken-metadata")).unwrap();
            ensure_store_layout(&store).unwrap();
            fs::create_dir_all(store::metadata_path(&store)).unwrap();

            let report = build_report(Some("broken-metadata")).await.unwrap();

            assert!(report.metadata_summary.is_none());
            assert!(report.metadata_error.is_some());
        })
        .await;
    }

    #[tokio::test(flavor = "current_thread")]
    async fn test_build_report_includes_telemetry_env_configuration() {
        let dir = tempfile::tempdir().unwrap();
        with_test_env(dir.path(), None, || async {
            let _vars = ScopedEnvVars::set(&[
                (
                    crate::telemetry::ENV_OTEL_EXPORTER_OTLP_ENDPOINT,
                    "http://localhost:6006",
                ),
                (
                    crate::telemetry::ENV_OTEL_EXPORTER_OTLP_PROTOCOL,
                    "http/protobuf",
                ),
                (crate::telemetry::ENV_OTEL_SERVICE_NAME, "ragcli-test"),
                (
                    crate::telemetry::ENV_OTEL_EXPORTER_OTLP_HEADERS,
                    "api_key=secret",
                ),
                (crate::telemetry::ENV_OTEL_EXPORTER_OTLP_TIMEOUT, "2500"),
            ]);

            let report = build_report(Some("telemetry")).await.unwrap();
            assert!(report.telemetry.enabled);
            assert_eq!(report.telemetry.service_name, "ragcli-test");
            assert_eq!(report.telemetry.protocol, "http/protobuf");
            assert_eq!(
                report.telemetry.endpoint.as_deref(),
                Some("http://localhost:6006/v1/traces")
            );
            assert_eq!(report.telemetry.timeout_ms, Some(2500));
            assert!(report.telemetry.headers_configured);
            assert!(report.telemetry_error.is_none());
        })
        .await;
    }

    #[tokio::test(flavor = "current_thread")]
    async fn test_build_report_keeps_running_when_telemetry_env_is_invalid() {
        let dir = tempfile::tempdir().unwrap();
        with_test_env(dir.path(), None, || async {
            let _vars = ScopedEnvVars::set(&[
                (
                    crate::telemetry::ENV_OTEL_EXPORTER_OTLP_ENDPOINT,
                    "http://localhost:6006",
                ),
                (
                    crate::telemetry::ENV_OTEL_EXPORTER_OTLP_PROTOCOL,
                    "http/json",
                ),
                (crate::telemetry::ENV_OTEL_SERVICE_NAME, "ragcli-test"),
            ]);

            let report = build_report(Some("telemetry-invalid")).await.unwrap();
            assert!(report.telemetry.enabled);
            assert_eq!(report.telemetry.service_name, "ragcli-test");
            assert_eq!(report.telemetry.protocol, "http/protobuf");
            assert_eq!(
                report.telemetry.endpoint.as_deref(),
                Some("http://localhost:6006/v1/traces")
            );
            assert!(report
                .telemetry_error
                .as_deref()
                .unwrap()
                .contains("unsupported OTEL_EXPORTER_OTLP_PROTOCOL value"));
            assert!(serde_json::to_string(&report)
                .unwrap()
                .contains("\"telemetry_error\""));
        })
        .await;
    }

    #[test]
    fn test_scoped_env_vars_restore_on_panic() {
        let _guard = crate::config::test_env_lock().lock().unwrap();
        let previous_service_name = env::var_os(crate::telemetry::ENV_OTEL_SERVICE_NAME);

        let result = std::panic::catch_unwind(|| {
            let _vars = ScopedEnvVars::set(&[(crate::telemetry::ENV_OTEL_SERVICE_NAME, "boom")]);
            panic!("force unwind");
        });

        assert!(result.is_err());
        assert_eq!(
            env::var_os(crate::telemetry::ENV_OTEL_SERVICE_NAME),
            previous_service_name
        );
    }

    unsafe fn restore_var(name: &str, value: Option<std::ffi::OsString>) {
        match value {
            Some(value) => env::set_var(name, value),
            None => env::remove_var(name),
        }
    }
}
