//! Schema fetching — spawns the configured backend's schema-export command
//! and deserializes its stdout into a typed `MizanIR`.
//!
//! Two backends recognized today:
//!   - FastAPI: `python -m mizan_fastapi.cli <module>`
//!   - Django:  `python manage.py export_mizan_schema --indent 0`
//!
//! The fetcher reads stdout, skips any banner text before the first `{`,
//! and parses the remainder as JSON.

use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{anyhow, Context, Result};

use crate::config::{Config, DjangoSource, FastapiSource};
use crate::ir::MizanIR;


pub fn fetch_schema(config: &Config, config_dir: &Path) -> Result<MizanIR> {
    let raw = if let Some(fa) = &config.source.fastapi {
        run_fastapi(fa, config_dir)?
    } else if let Some(dj) = &config.source.django {
        run_django(dj, config_dir)?
    } else {
        return Err(anyhow!(
            "config.source must declare either [source.fastapi] or [source.django]"
        ));
    };

    parse_ir(&raw)
}


fn run_fastapi(src: &FastapiSource, config_dir: &Path) -> Result<String> {
    let cwd = match &src.cwd {
        Some(rel) => config_dir.join(rel),
        None => config_dir.to_path_buf(),
    };

    let (program, mut args) = resolve_command(&src.command, &src.python);
    args.extend([
        "-m".to_string(),
        "mizan_fastapi.cli".to_string(),
        src.module.clone(),
    ]);

    run_subprocess(&program, &args, &cwd, &src.env, "FastAPI schema export")
}


fn run_django(src: &DjangoSource, config_dir: &Path) -> Result<String> {
    let manage_path = config_dir.join(&src.manage_path);
    let manage_dir = manage_path
        .parent()
        .ok_or_else(|| anyhow!("django manage_path has no parent: {}", manage_path.display()))?
        .to_path_buf();

    let (program, mut args) = resolve_command(&src.command, &src.python);

    // If the user supplied an explicit command (e.g. `uv run python`), they
    // expect to invoke from the manage_dir without a path prefix on manage.py.
    // Otherwise we pass the absolute manage_path so the python interpreter
    // doesn't depend on cwd.
    if src.command.is_some() {
        args.push("manage.py".to_string());
    } else {
        args.push(manage_path.to_string_lossy().into_owned());
    }
    args.extend([
        "export_mizan_schema".to_string(),
        "--indent".to_string(),
        "0".to_string(),
    ]);

    run_subprocess(&program, &args, &manage_dir, &src.env, "Django schema export")
}


fn resolve_command(
    explicit: &Option<Vec<String>>,
    python_override: &Option<String>,
) -> (String, Vec<String>) {
    if let Some(cmd) = explicit {
        let (head, tail) = cmd.split_first().expect("command must be non-empty");
        return (head.clone(), tail.to_vec());
    }
    let python = python_override.as_deref().unwrap_or("python");
    (python.to_string(), Vec::new())
}


fn run_subprocess(
    program: &str,
    args: &[String],
    cwd: &Path,
    env: &std::collections::BTreeMap<String, String>,
    label: &str,
) -> Result<String> {
    let mut cmd = Command::new(program);
    cmd.args(args).current_dir(cwd);
    for (k, v) in env {
        cmd.env(k, v);
    }

    let output = cmd
        .output()
        .with_context(|| format!("spawning {label} ({program})"))?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let stdout = String::from_utf8_lossy(&output.stdout);
        return Err(anyhow!(
            "{label} failed (exit {:?})\n--- stderr ---\n{stderr}\n--- stdout ---\n{stdout}",
            output.status.code(),
        ));
    }

    let stdout = String::from_utf8(output.stdout)
        .with_context(|| format!("{label}: non-UTF-8 stdout"))?;
    Ok(stdout)
}


fn parse_ir(raw: &str) -> Result<MizanIR> {
    let json_start = raw
        .find('{')
        .ok_or_else(|| anyhow!("no JSON object found in schema-export output"))?;
    serde_json::from_str(&raw[json_start..]).context("deserializing Mizan IR from schema JSON")
}


/// Library helper for tests: deserialize an IR from a pre-fetched JSON string
/// (no subprocess). Mirrors `parse_ir` but exposed for crate-external callers.
pub fn parse_ir_from_str(json: &str) -> Result<MizanIR> {
    parse_ir(json)
}


/// Library helper: resolve a path relative to the config directory, returning
/// an absolute path. Consumers may want this when constructing output paths.
pub fn resolve_path(config_dir: &Path, p: impl Into<PathBuf>) -> PathBuf {
    let p = p.into();
    if p.is_absolute() {
        p
    } else {
        config_dir.join(p)
    }
}
