// rust-style-allow: large-file because tool-manifest build keeps source/schema
// hashing, raw payload normalization, output binding shape, and stable JSON
// emission together so the TS doctor and the rust runtime agree byte-for-byte.
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};

use runx_contracts::sha256_prefixed;
use runx_contracts::tools::{
    BuiltToolItem, JsonPayload, JsonPayloadObject, RuntimeCommand, ToolBuildReport,
    ToolBuildReportSchema, ToolBuildStatus, ToolManifest, ToolManifestSchema, ToolOutput,
};
use serde::Deserialize;

use super::error::ToolCatalogError;
use super::hash::sha256_stable;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ToolBuildOptions {
    pub root: PathBuf,
    pub tool_path: Option<PathBuf>,
    pub all: bool,
    pub toolkit_version: String,
}

#[derive(Deserialize)]
struct RawToolManifest {
    #[serde(default)]
    name: String,
    version: Option<String>,
    description: Option<String>,
    source: runx_contracts::tools::ToolSource,
    #[serde(default)]
    inputs: BTreeMap<String, runx_contracts::tools::ToolInput>,
    output: Option<ToolOutput>,
    #[serde(default)]
    scopes: Vec<String>,
    risk: Option<JsonPayload>,
    runtime: Option<RuntimeCommand>,
    retry: Option<runx_contracts::tools::ToolRetryPolicy>,
    idempotency: Option<runx_contracts::tools::ToolIdempotencyPolicy>,
    mutating: Option<bool>,
    runx: Option<JsonPayloadObject>,
}

pub fn build_tool_catalogs(
    options: &ToolBuildOptions,
) -> Result<ToolBuildReport, ToolCatalogError> {
    let tool_dirs = if options.all {
        discover_tool_directories(&options.root)?
    } else {
        vec![resolve_tool_path(
            &options.root,
            options.tool_path.as_deref(),
        )?]
    };
    let mut built = Vec::new();
    let mut errors = Vec::new();
    for tool_dir in tool_dirs {
        match build_tool_manifest(&options.root, &tool_dir, &options.toolkit_version) {
            Ok(item) => built.push(item),
            Err(error) => errors.push(format!(
                "{}: {}",
                project_path(&options.root, &tool_dir),
                error.concise_message()
            )),
        }
    }
    Ok(ToolBuildReport {
        schema: ToolBuildReportSchema::V1,
        status: if errors.is_empty() {
            ToolBuildStatus::Success
        } else {
            ToolBuildStatus::Failure
        },
        built,
        errors,
    })
}

fn build_tool_manifest(
    root: &Path,
    tool_dir: &Path,
    toolkit_version: &str,
) -> Result<BuiltToolItem, ToolCatalogError> {
    let manifest_path = tool_dir.join("manifest.json");
    let source = fs::read_to_string(&manifest_path)
        .map_err(|error| ToolCatalogError::io("reading tool manifest", &manifest_path, error))?;
    let raw_payload: JsonPayload = serde_json::from_str(&source)
        .map_err(|error| ToolCatalogError::json("parsing tool manifest", &manifest_path, error))?;
    let JsonPayload::Object(raw_object) = raw_payload else {
        return Err(ToolCatalogError::InvalidManifest {
            path: manifest_path,
            message: "manifest.json must be an object.".to_owned(),
        });
    };
    let normalized_object = normalize_tool_manifest_shape(raw_object.clone());
    let raw: RawToolManifest = serde_json::from_value(
        serde_json::to_value(JsonPayload::Object(normalized_object.clone())).map_err(|error| {
            ToolCatalogError::json("normalizing tool manifest", &manifest_path, error)
        })?,
    )
    .map_err(|error| ToolCatalogError::json("parsing tool manifest", &manifest_path, error))?;
    let output = raw
        .output
        .unwrap_or_else(|| normalize_tool_output(raw.runx.as_ref()));
    let source_hash = hash_tool_source(tool_dir)?;
    let schema_hash = schema_hash(&raw_object, &output);
    let manifest = ToolManifest {
        schema: ToolManifestSchema::V1,
        name: raw.name,
        version: raw.version,
        description: raw.description,
        source: raw.source,
        runtime: raw
            .runtime
            .unwrap_or_else(|| runtime_from_source(&raw_object)),
        inputs: raw.inputs,
        output,
        scopes: raw.scopes,
        risk: raw.risk,
        retry: raw.retry,
        idempotency: raw.idempotency,
        mutating: raw.mutating,
        runx: raw.runx,
        source_hash,
        schema_hash,
        toolkit_version: Some(toolkit_version.to_owned()),
    };
    validate_manifest(&manifest, &manifest_path)?;
    write_manifest(&manifest_path, &manifest)?;
    Ok(BuiltToolItem {
        path: project_path(root, tool_dir),
        manifest: project_path(root, &manifest_path),
        source_hash: manifest.source_hash,
        schema_hash: manifest.schema_hash,
    })
}

fn normalize_tool_manifest_shape(mut raw: JsonPayloadObject) -> JsonPayloadObject {
    let Some(JsonPayload::Object(source)) = raw.get_mut("source") else {
        return raw;
    };
    if !matches!(
        source.get("type"),
        Some(JsonPayload::String(value)) if value == "http"
    ) || source.contains_key("http")
    {
        return raw;
    }
    let mut http = JsonPayloadObject::new();
    for key in ["url", "method", "headers", "allow_private_network"] {
        if let Some(value) = source.remove(key) {
            http.insert(key.to_owned(), value);
        }
    }
    if !http.is_empty() {
        source.insert("http".to_owned(), JsonPayload::Object(http));
    }
    raw
}

fn normalize_tool_output(runx: Option<&JsonPayloadObject>) -> ToolOutput {
    let artifacts = runx
        .and_then(|runx| runx.get("artifacts"))
        .and_then(|value| match value {
            JsonPayload::Object(value) => Some(value),
            _ => None,
        });
    let wrap_as = artifacts
        .and_then(|artifacts| artifacts.get("wrap_as"))
        .and_then(|value| match value {
            JsonPayload::String(value) => Some(value.clone()),
            _ => None,
        });
    let mut extra = JsonPayloadObject::new();
    if let Some(JsonPayload::Object(named_emits)) =
        artifacts.and_then(|artifacts| artifacts.get("named_emits"))
    {
        extra.insert(
            "named_emits".to_owned(),
            JsonPayload::Object(named_emits.clone()),
        );
    }
    ToolOutput {
        packet: None,
        wrap_as,
        named_emits: BTreeMap::new(),
        outputs: BTreeMap::new(),
        extra,
    }
}

fn runtime_from_source(raw_object: &JsonPayloadObject) -> RuntimeCommand {
    let source = raw_object.get("source").and_then(|value| match value {
        JsonPayload::Object(value) => Some(value),
        _ => None,
    });
    let command = source
        .and_then(|source| source.get("command"))
        .and_then(|value| match value {
            JsonPayload::String(value) => Some(value.clone()),
            _ => None,
        })
        .unwrap_or_else(|| "node".to_owned());
    let args = source
        .and_then(|source| source.get("args"))
        .and_then(|value| match value {
            JsonPayload::Array(values) => Some(
                values
                    .iter()
                    .filter_map(|value| match value {
                        JsonPayload::String(value) => Some(value.clone()),
                        _ => None,
                    })
                    .collect::<Vec<_>>(),
            ),
            _ => None,
        })
        .filter(|args| !args.is_empty())
        .unwrap_or_else(|| vec!["./run.mjs".to_owned()]);
    RuntimeCommand {
        command,
        args,
        cwd: None,
        env: BTreeMap::new(),
    }
}

fn schema_hash(raw: &JsonPayloadObject, output: &ToolOutput) -> String {
    let mut payload = JsonPayloadObject::new();
    if let Some(inputs) = raw.get("inputs") {
        payload.insert("inputs".to_owned(), inputs.clone());
    }
    payload.insert("output".to_owned(), tool_output_payload(output));
    if let Some(artifacts) = raw.get("runx").and_then(|value| match value {
        JsonPayload::Object(value) => value.get("artifacts"),
        _ => None,
    }) {
        payload.insert("artifacts".to_owned(), artifacts.clone());
    }
    sha256_stable(&JsonPayload::Object(payload))
}

fn tool_output_payload(output: &ToolOutput) -> JsonPayload {
    let mut object = output.extra.clone();
    if let Some(packet) = &output.packet {
        object.insert("packet".to_owned(), JsonPayload::String(packet.clone()));
    }
    if let Some(wrap_as) = &output.wrap_as {
        object.insert("wrap_as".to_owned(), JsonPayload::String(wrap_as.clone()));
    }
    if !output.named_emits.is_empty() {
        let mut named = JsonPayloadObject::new();
        for (label, key) in &output.named_emits {
            named.insert(label.clone(), JsonPayload::String(key.clone()));
        }
        object.insert("named_emits".to_owned(), JsonPayload::Object(named));
    }
    if !output.outputs.is_empty() {
        let mut outputs = JsonPayloadObject::new();
        for (name, binding) in &output.outputs {
            outputs.insert(name.clone(), tool_output_binding_payload(binding));
        }
        object.insert("outputs".to_owned(), JsonPayload::Object(outputs));
    }
    JsonPayload::Object(object)
}

fn tool_output_binding_payload(binding: &runx_contracts::tools::ToolOutputBinding) -> JsonPayload {
    let mut object = binding.extra.clone();
    if let Some(packet) = &binding.packet {
        object.insert("packet".to_owned(), JsonPayload::String(packet.clone()));
    }
    if let Some(wrap_as) = &binding.wrap_as {
        object.insert("wrap_as".to_owned(), JsonPayload::String(wrap_as.clone()));
    }
    JsonPayload::Object(object)
}

pub(crate) fn hash_tool_source(tool_dir: &Path) -> Result<String, ToolCatalogError> {
    let roots = [tool_dir.join("src/index.ts"), tool_dir.join("run.mjs")];
    let files = tool_source_closure(&roots)?;
    let mut bytes = Vec::new();
    let hash_root = fs::canonicalize(tool_dir).unwrap_or_else(|_| tool_dir.to_path_buf());
    for file_path in &files {
        bytes.extend(source_hash_path(&hash_root, file_path).as_bytes());
        bytes.push(0);
        bytes.extend(
            fs::read(file_path)
                .map_err(|error| ToolCatalogError::io("reading tool source", file_path, error))?,
        );
        bytes.push(0);
    }
    if files.is_empty() {
        bytes.extend(b"no-source");
    }
    Ok(sha256_prefixed(&bytes))
}

fn tool_source_closure(roots: &[PathBuf]) -> Result<Vec<PathBuf>, ToolCatalogError> {
    let mut pending = roots.to_vec();
    let mut seen = BTreeSet::new();
    let mut index = 0;
    while index < pending.len() {
        let source_path = pending[index].clone();
        index += 1;
        if !source_path.exists() {
            continue;
        }
        let source_path = fs::canonicalize(&source_path)
            .map_err(|error| ToolCatalogError::io("resolving tool source", &source_path, error))?;
        if !seen.insert(source_path.clone()) {
            continue;
        }
        let source = fs::read_to_string(&source_path)
            .map_err(|error| ToolCatalogError::io("reading tool source", &source_path, error))?;
        for specifier in local_import_specifiers(&source) {
            if let Some(dependency) = resolve_local_source_import(&source_path, &specifier)? {
                pending.push(dependency);
            }
        }
    }
    Ok(seen.into_iter().collect())
}

fn local_import_specifiers(source: &str) -> Vec<String> {
    let mut specifiers = Vec::new();
    let mut chars = source.char_indices().peekable();
    while let Some((start, quote)) = chars.next() {
        if quote != '"' && quote != '\'' {
            continue;
        }
        let mut escaped = false;
        let mut end = start + quote.len_utf8();
        for (index, character) in chars.by_ref() {
            end = index;
            if escaped {
                escaped = false;
                continue;
            }
            if character == '\\' {
                escaped = true;
                continue;
            }
            if character == quote {
                break;
            }
        }
        let value = &source[start + quote.len_utf8()..end];
        if value.starts_with("./") || value.starts_with("../") {
            specifiers.push(value.to_owned());
        }
    }
    specifiers
}

fn resolve_local_source_import(
    from_file: &Path,
    specifier: &str,
) -> Result<Option<PathBuf>, ToolCatalogError> {
    let clean_specifier = specifier
        .split(['?', '#'])
        .next()
        .filter(|value| !value.is_empty())
        .unwrap_or(specifier);
    let base = from_file
        .parent()
        .unwrap_or_else(|| Path::new(""))
        .join(clean_specifier);
    for candidate in source_import_candidates(&base) {
        if candidate.exists() {
            return fs::canonicalize(&candidate)
                .map(Some)
                .map_err(|error| ToolCatalogError::io("resolving tool source", &candidate, error));
        }
    }
    Ok(None)
}

fn source_import_candidates(base: &Path) -> Vec<PathBuf> {
    let Some(extension) = base.extension().and_then(|extension| extension.to_str()) else {
        let extensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"];
        return extensions
            .iter()
            .map(|extension| PathBuf::from(format!("{}{}", base.display(), extension)))
            .chain(
                extensions
                    .iter()
                    .map(|extension| base.join(format!("index{extension}"))),
            )
            .collect();
    };
    let mut candidates = vec![base.to_path_buf()];
    match extension {
        "js" => {
            candidates.push(base.with_extension("ts"));
            candidates.push(base.with_extension("tsx"));
        }
        "mjs" => {
            candidates.push(base.with_extension("mts"));
            candidates.push(base.with_extension("ts"));
        }
        "cjs" => {
            candidates.push(base.with_extension("cts"));
            candidates.push(base.with_extension("ts"));
        }
        _ => {}
    }
    candidates
}

fn source_hash_path(root: &Path, file_path: &Path) -> String {
    let root_components = path_component_strings(root);
    let file_components = path_component_strings(file_path);
    let common_len = root_components
        .iter()
        .zip(&file_components)
        .take_while(|(left, right)| left == right)
        .count();
    if common_len == 0 {
        return file_path.to_string_lossy().replace('\\', "/");
    }
    let mut parts = Vec::new();
    for _ in common_len..root_components.len() {
        parts.push("..".to_owned());
    }
    parts.extend(file_components[common_len..].iter().cloned());
    if parts.is_empty() {
        ".".to_owned()
    } else {
        parts.join("/")
    }
}

fn path_component_strings(path: &Path) -> Vec<String> {
    path.components()
        .map(|component| component.as_os_str().to_string_lossy().into_owned())
        .collect()
}

fn validate_manifest(manifest: &ToolManifest, path: &Path) -> Result<(), ToolCatalogError> {
    let json = serde_json::to_string(manifest)
        .map_err(|error| ToolCatalogError::json("serializing tool manifest", path, error))?;
    let raw = runx_parser::parse_tool_manifest_json(&json).map_err(|error| {
        ToolCatalogError::InvalidManifest {
            path: path.to_path_buf(),
            message: error.to_string(),
        }
    })?;
    runx_parser::validate_tool_manifest(raw).map_err(|error| {
        ToolCatalogError::InvalidManifest {
            path: path.to_path_buf(),
            message: error.to_string(),
        }
    })?;
    Ok(())
}

fn write_manifest(path: &Path, manifest: &ToolManifest) -> Result<(), ToolCatalogError> {
    let json = serde_json::to_string_pretty(manifest)
        .map_err(|error| ToolCatalogError::json("serializing tool manifest", path, error))?;
    fs::write(path, format!("{json}\n"))
        .map_err(|error| ToolCatalogError::io("writing tool manifest", path, error))
}

fn discover_tool_directories(root: &Path) -> Result<Vec<PathBuf>, ToolCatalogError> {
    let tools_root = root.join("tools");
    let mut directories = Vec::new();
    for namespace in read_dirs(&tools_root)? {
        for tool in read_dirs(&namespace)? {
            if tool.join("manifest.json").exists() {
                directories.push(tool);
            }
        }
    }
    directories.sort();
    Ok(directories)
}

fn read_dirs(path: &Path) -> Result<Vec<PathBuf>, ToolCatalogError> {
    let entries = match fs::read_dir(path) {
        Ok(entries) => entries,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(error) => return Err(ToolCatalogError::io("reading directory", path, error)),
    };
    let mut dirs = Vec::new();
    for entry in entries {
        let entry =
            entry.map_err(|error| ToolCatalogError::io("reading directory", path, error))?;
        let file_type = entry.file_type().map_err(|error| {
            ToolCatalogError::io("reading directory entry", entry.path(), error)
        })?;
        if file_type.is_dir() {
            dirs.push(entry.path());
        }
    }
    dirs.sort();
    Ok(dirs)
}

fn resolve_tool_path(root: &Path, tool_path: Option<&Path>) -> Result<PathBuf, ToolCatalogError> {
    let Some(tool_path) = tool_path else {
        return Err(ToolCatalogError::InvalidRequest(
            "runx tool build requires a tool directory or --all".to_owned(),
        ));
    };
    if tool_path.is_absolute() {
        Ok(tool_path.to_path_buf())
    } else {
        Ok(root.join(tool_path))
    }
}

pub(crate) fn project_path(root: &Path, path: &Path) -> String {
    path.strip_prefix(root)
        .map_or(path, |path| path)
        .to_string_lossy()
        .replace('\\', "/")
}
