//! Edge construction for the project graph index (import/module, co-change,
//! sibling, barrel and language-specific edges). Split out of `graph_index/mod.rs`.
//! `use super::*` re-imports the parent module’s types and helpers.

#[allow(clippy::wildcard_imports)]
use super::*;

pub(crate) fn build_edges_cached(
    index: &mut ProjectIndex,
    content_cache: &HashMap<String, String>,
) {
    // Co-change history is a single `git log` subprocess that is I/O bound on the
    // git object store, whereas the import/deep-analysis pass below is CPU bound.
    // Spawn git up front so its ~1s overlaps that CPU work, then consume its
    // output when the co-change layer is built. The output is a pure function of
    // repo state, so overlapping it does not change the result (#934).
    let cochange_git = spawn_cochange_git(&index.project_root);

    build_edges_with_cache(index, content_cache);
    build_implicit_edges_with_cache(index, content_cache);
    build_cochange_edges(index, cochange_git);
    build_sibling_edges(index);
}

fn build_edges_with_cache(index: &mut ProjectIndex, content_cache: &HashMap<String, String>) {
    index.edges.clear();

    if crate::core::memory_guard::abort_requested() {
        tracing::warn!("[graph_index: skipping edge-building due to memory pressure]");
        return;
    }

    let root = normalize_project_root(&index.project_root);
    let root_path = Path::new(&root);

    let mut file_paths: Vec<String> = index.files.keys().cloned().collect();
    file_paths.sort();

    let resolver_ctx =
        import_resolver::ResolverContext::new(root_path, file_paths.clone(), content_cache);

    // #934: the per-file deep analysis (a second tree-sitter parse) dominates the
    // edge-build cost and is pure + thread-safe (thread_local parser, read-only
    // resolver context). Fan it out, collecting in file order; the sequential
    // branch keeps the memory-pressure early-break. Edges are sorted + deduped
    // below, so per-file insertion order never affects the result.
    let per_file: Vec<FileEdges> = if crate::core::memory_guard::is_under_pressure() {
        let mut acc = Vec::with_capacity(file_paths.len());
        for (i, rel_path) in file_paths.iter().enumerate() {
            if i.is_multiple_of(1000) && crate::core::memory_guard::is_under_pressure() {
                tracing::warn!(
                    "[graph_index: stopping edge-building at file {i}/{} due to memory pressure]",
                    file_paths.len()
                );
                break;
            }
            acc.push(resolve_file_edges(
                rel_path,
                content_cache,
                &resolver_ctx,
                root_path,
            ));
        }
        acc
    } else {
        file_paths
            .par_iter()
            .map(|rel_path| resolve_file_edges(rel_path, content_cache, &resolver_ctx, root_path))
            .collect()
    };

    // Full analyses of C#/Java/Go/Kotlin files, kept to derive cross-file
    // type_ref edges after the import pass (GH #398). Those languages resolve
    // same-namespace/package types without an import, so the import list alone
    // is not enough.
    let mut type_inputs: Vec<(String, String, crate::core::deep_queries::DeepAnalysis)> =
        Vec::new();
    for fe in per_file {
        index.edges.extend(fe.edges);
        if let Some(ti) = fe.type_input {
            type_inputs.push(ti);
        }
    }

    // Cross-file type-reference edges for C#/Java same-namespace usage (GH #398).
    // Emitted in the durable graph_index -> PropertyGraph mirror so a background
    // reindex preserves the impact blast radius instead of clearing it; shares
    // its resolution with the `ctx_impact` builder via `type_ref_edges`.
    let type_ref_inputs: Vec<crate::core::type_ref_edges::FileAnalysis> = type_inputs
        .iter()
        .map(
            |(path, file_ext, analysis)| crate::core::type_ref_edges::FileAnalysis {
                path,
                ext: file_ext,
                analysis,
            },
        )
        .collect();
    for (from, to) in crate::core::type_ref_edges::cross_file_type_edges(&type_ref_inputs) {
        index.edges.push(IndexEdge {
            from,
            to,
            kind: "type_ref".to_string(),
            weight: 0.5,
        });
    }

    index.edges.sort_by(|a, b| {
        a.from
            .cmp(&b.from)
            .then_with(|| a.to.cmp(&b.to))
            .then_with(|| a.kind.cmp(&b.kind))
    });
    index
        .edges
        .dedup_by(|a, b| a.from == b.from && a.to == b.to && a.kind == b.kind);
}

/// One file's edge contribution from the import pass, plus the full analysis kept
/// for the cross-file `type_ref` pass (C#/Java/Go/Kotlin only).
struct FileEdges {
    edges: Vec<IndexEdge>,
    type_input: Option<(String, String, crate::core::deep_queries::DeepAnalysis)>,
}

/// Pure, thread-safe per-file import resolution: read content (cache or a
/// size-capped disk read), run the deep analysis, and resolve imports into
/// `import` edges. Returns no edges for files that are missing, oversized, or
/// import-free — matching the sequential build's `continue` cases.
fn resolve_file_edges(
    rel_path: &str,
    content_cache: &HashMap<String, String>,
    resolver_ctx: &import_resolver::ResolverContext,
    root_path: &Path,
) -> FileEdges {
    const MAX_FILE_SIZE_FOR_EDGES: u64 = 2 * 1024 * 1024;
    let mut out = FileEdges {
        edges: Vec::new(),
        type_input: None,
    };

    let content = if let Some(cached) = content_cache.get(rel_path) {
        std::borrow::Cow::Borrowed(cached.as_str())
    } else {
        let abs_path = root_path.join(rel_path.trim_start_matches(['/', '\\']));
        if let Ok(meta) = abs_path.metadata()
            && meta.len() > MAX_FILE_SIZE_FOR_EDGES
        {
            return out;
        }
        match std::fs::read_to_string(&abs_path) {
            Ok(c) => std::borrow::Cow::Owned(c),
            Err(_) => return out,
        }
    };

    let ext = Path::new(rel_path)
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("");

    // Godot scenes carry their dependencies in `[ext_resource]` headers, not in
    // source-code import statements, so they bypass tree-sitter analysis and use
    // the dedicated PackedScene parser. `res://` paths resolve via the GDScript
    // resolver. (#316)
    let (resolve_ext, imports) = if ext == "tscn" {
        (
            "tscn",
            crate::core::godot::scene::extract_scene_imports(&content),
        )
    } else {
        let resolve_ext = match ext {
            "vue" | "svelte" => "ts",
            _ => ext,
        };

        // SFC <script> extraction needs the tree-sitter stack; without it the
        // raw file body still yields usable import lines for the regex analyzer.
        #[cfg(feature = "tree-sitter")]
        let analysis_content = if ext == "vue" || ext == "svelte" {
            if let Some(script) = crate::core::signatures_ts::sfc::extract_script_block(&content) {
                std::borrow::Cow::Owned(script)
            } else {
                content
            }
        } else {
            content
        };
        #[cfg(not(feature = "tree-sitter"))]
        let analysis_content = content;

        let analysis = crate::core::deep_queries::analyze(&analysis_content, resolve_ext);
        // C#/Java/Go/Kotlin need the full analysis for type_ref edges (GH #398);
        // every other language uses only the resolved import list. Go/Kotlin still
        // get their import edges from `imports` too.
        if matches!(resolve_ext, "cs" | "java" | "go" | "kt" | "kts") {
            let imports = analysis.imports.clone();
            out.type_input = Some((rel_path.to_string(), resolve_ext.to_string(), analysis));
            (resolve_ext, imports)
        } else {
            (resolve_ext, analysis.imports)
        }
    };

    if imports.is_empty() {
        return out;
    }

    let resolved = import_resolver::resolve_imports(&imports, rel_path, resolve_ext, resolver_ctx);
    for r in resolved {
        if r.is_external {
            continue;
        }
        if let Some(to) = r.resolved_path {
            out.edges.push(IndexEdge {
                from: rel_path.to_string(),
                to,
                kind: "import".to_string(),
                weight: 1.0,
            });
        }
    }
    out
}

// ---------------------------------------------------------------------------
// Layer 2: Implicit Language Edges (weight 0.8)
// ---------------------------------------------------------------------------

fn build_implicit_edges_with_cache(
    index: &mut ProjectIndex,
    content_cache: &HashMap<String, String>,
) {
    let file_paths: Vec<String> = index.files.keys().cloned().collect();
    let file_set: std::collections::HashSet<&str> = file_paths.iter().map(String::as_str).collect();

    let mut new_edges: Vec<IndexEdge> = Vec::new();

    for file in &file_paths {
        let ext = Path::new(file.as_str())
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("");

        match ext {
            "rs" => {
                collect_rust_mod_edges_cached(
                    file,
                    &file_set,
                    index,
                    &mut new_edges,
                    content_cache,
                );
            }
            // Go same-package dependencies are now precise `type_ref` edges
            // (GH #398 bug class); the old coarse one-edge-per-file `package`
            // heuristic mislabelled them as top-weight `imports` in the mirror
            // and polluted the impact blast radius, so it is gone — unused
            // orphans fall to the standard low-weight sibling rescue instead.
            "py" => collect_python_init_edges(file, &file_paths, &mut new_edges),
            "ts" | "js" | "tsx" | "jsx" => {
                collect_barrel_edges_cached(file, &file_set, index, &mut new_edges, content_cache);
            }
            _ => {}
        }
    }

    index.edges.extend(new_edges);
}

fn collect_rust_mod_edges_cached(
    file: &str,
    file_set: &std::collections::HashSet<&str>,
    index: &ProjectIndex,
    edges: &mut Vec<IndexEdge>,
    content_cache: &HashMap<String, String>,
) {
    if !index.files.contains_key(file) {
        return;
    }

    let content = if let Some(cached) = content_cache.get(file) {
        std::borrow::Cow::Borrowed(cached.as_str())
    } else {
        let full_path = Path::new(&index.project_root).join(file);
        match std::fs::read_to_string(&full_path) {
            Ok(c) => std::borrow::Cow::Owned(c),
            Err(_) => return,
        }
    };

    let dir = Path::new(file)
        .parent()
        .map(|p| p.to_string_lossy().to_string());

    for line in content.lines() {
        let trimmed = line.trim();
        if !trimmed.starts_with("mod ") || trimmed.contains('{') {
            continue;
        }
        let mod_name = trimmed
            .trim_start_matches("mod ")
            .trim_start_matches("pub mod ")
            .trim_start_matches("pub(crate) mod ")
            .trim_end_matches(';')
            .trim();

        if mod_name.is_empty() || mod_name.contains(' ') {
            continue;
        }

        let candidates = if let Some(ref d) = dir {
            vec![
                format!("{d}/{mod_name}.rs"),
                format!("{d}/{mod_name}/mod.rs"),
            ]
        } else {
            vec![format!("{mod_name}.rs"), format!("{mod_name}/mod.rs")]
        };

        for candidate in candidates {
            if file_set.contains(candidate.as_str()) {
                edges.push(IndexEdge {
                    from: file.to_string(),
                    to: candidate,
                    kind: "module".to_string(),
                    weight: 0.8,
                });
                break;
            }
        }
    }
}

fn collect_python_init_edges(file: &str, file_paths: &[String], edges: &mut Vec<IndexEdge>) {
    let p = Path::new(file);
    if p.file_name().and_then(|n| n.to_str()) != Some("__init__.py") {
        return;
    }

    let Some(dir) = p.parent().map(|d| d.to_string_lossy().to_string()) else {
        return;
    };

    for other in file_paths {
        if other == file {
            continue;
        }
        let op = Path::new(other.as_str());
        if op.extension().and_then(|e| e.to_str()) != Some("py") {
            continue;
        }
        let other_dir = op
            .parent()
            .map(|d| d.to_string_lossy().to_string())
            .unwrap_or_default();
        if other_dir == dir {
            edges.push(IndexEdge {
                from: file.to_string(),
                to: other.clone(),
                kind: "module".to_string(),
                weight: 0.8,
            });
        }
    }
}

fn collect_barrel_edges_cached(
    file: &str,
    file_set: &std::collections::HashSet<&str>,
    index: &ProjectIndex,
    edges: &mut Vec<IndexEdge>,
    content_cache: &HashMap<String, String>,
) {
    let basename = Path::new(file)
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("");
    if basename != "index" {
        return;
    }

    let content = if let Some(cached) = content_cache.get(file) {
        std::borrow::Cow::Borrowed(cached.as_str())
    } else {
        let full_path = Path::new(&index.project_root).join(file);
        match std::fs::read_to_string(&full_path) {
            Ok(c) => std::borrow::Cow::Owned(c),
            Err(_) => return,
        }
    };

    let dir = Path::new(file)
        .parent()
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_default();

    let ext = Path::new(file)
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("ts");

    for line in content.lines() {
        let trimmed = line.trim();
        if !trimmed.starts_with("export") || !trimmed.contains("from") {
            continue;
        }
        if let Some(from_pos) = trimmed.find("from") {
            let after = &trimmed[from_pos + 4..];
            let source = after
                .trim()
                .trim_start_matches(['\'', '"'])
                .trim_end_matches([';', '\'', '"'])
                .trim_end_matches(['\'', '"']);

            if source.starts_with("./") || source.starts_with("../") {
                let resolved = if dir.is_empty() {
                    source.trim_start_matches("./").to_string()
                } else {
                    format!("{dir}/{}", source.trim_start_matches("./"))
                };

                let candidates = vec![
                    format!("{resolved}.{ext}"),
                    format!("{resolved}/index.{ext}"),
                    resolved.clone(),
                ];

                for candidate in candidates {
                    if file_set.contains(candidate.as_str()) {
                        edges.push(IndexEdge {
                            from: file.to_string(),
                            to: candidate,
                            kind: "reexport".to_string(),
                            weight: 0.8,
                        });
                        break;
                    }
                }
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Layer 3: Co-Change Edges (weight 0.5)
// ---------------------------------------------------------------------------

/// Spawn the co-change `git log` as a detached child so it runs concurrently with
/// the CPU-bound edge passes. Returns `None` when git is unavailable; the caller
/// then skips the co-change layer (same as a failed invocation). See
/// [`build_edges_cached`] for the overlap rationale (#934).
fn spawn_cochange_git(project_root: &str) -> Option<std::process::Child> {
    std::process::Command::new("git")
        .args([
            "log",
            "--name-only",
            "--pretty=format:---",
            "--since=6 months",
            "--",
            ".",
        ])
        .current_dir(project_root)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .spawn()
        .ok()
}

fn build_cochange_edges(index: &mut ProjectIndex, git: Option<std::process::Child>) {
    let output = match git.and_then(|c| c.wait_with_output().ok()) {
        Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
        _ => return,
    };

    let file_set: std::collections::HashSet<&str> =
        index.files.keys().map(String::as_str).collect();

    let connected: std::collections::HashSet<&str> = index
        .edges
        .iter()
        .flat_map(|e| [e.from.as_str(), e.to.as_str()])
        .collect();

    // Parse commits into groups of files
    let mut cooccurrence: HashMap<(String, String), u32> = HashMap::new();
    let mut current_commit: Vec<&str> = Vec::new();

    for line in output.lines() {
        if line == "---" {
            if current_commit.len() >= 2 && current_commit.len() <= 20 {
                for i in 0..current_commit.len() {
                    for j in (i + 1)..current_commit.len() {
                        let a = current_commit[i];
                        let b = current_commit[j];
                        if !file_set.contains(a) || !file_set.contains(b) {
                            continue;
                        }
                        // Only add if at least one is currently isolated
                        if connected.contains(a) && connected.contains(b) {
                            continue;
                        }
                        let key = if a < b {
                            (a.to_string(), b.to_string())
                        } else {
                            (b.to_string(), a.to_string())
                        };
                        *cooccurrence.entry(key).or_insert(0) += 1;
                    }
                }
            }
            current_commit.clear();
        } else if !line.is_empty() {
            current_commit.push(line.trim());
        }
    }

    // Filter: min 5 shared commits
    let mut cochange_edges: Vec<IndexEdge> = cooccurrence
        .into_iter()
        .filter(|(_, count)| *count >= 5)
        .map(|((from, to), _)| IndexEdge {
            from,
            to,
            kind: "cochange".to_string(),
            weight: 0.5,
        })
        .collect();

    // Cap at 500 to prevent noise
    cochange_edges.sort_by(|a, b| a.from.cmp(&b.from).then_with(|| a.to.cmp(&b.to)));
    cochange_edges.truncate(500);

    index.edges.extend(cochange_edges);
}

// ---------------------------------------------------------------------------
// Layer 4: Sibling Edges (weight 0.2)
// ---------------------------------------------------------------------------

fn build_sibling_edges(index: &mut ProjectIndex) {
    let connected: std::collections::HashSet<&str> = index
        .edges
        .iter()
        .flat_map(|e| [e.from.as_str(), e.to.as_str()])
        .collect();

    let file_paths: Vec<String> = index.files.keys().cloned().collect();
    let mut new_edges: Vec<IndexEdge> = Vec::new();

    for file in &file_paths {
        if connected.contains(file.as_str()) {
            continue;
        }

        let ext = Path::new(file.as_str())
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("");
        let dir = Path::new(file.as_str())
            .parent()
            .map(|p| p.to_string_lossy().to_string())
            .unwrap_or_default();

        // Find one sibling with same extension
        for other in &file_paths {
            if other == file {
                continue;
            }
            let other_ext = Path::new(other.as_str())
                .extension()
                .and_then(|e| e.to_str())
                .unwrap_or("");
            let other_dir = Path::new(other.as_str())
                .parent()
                .map(|p| p.to_string_lossy().to_string())
                .unwrap_or_default();

            if other_ext == ext && other_dir == dir {
                new_edges.push(IndexEdge {
                    from: file.clone(),
                    to: other.clone(),
                    kind: "sibling".to_string(),
                    weight: 0.2,
                });
                break; // Max 1 sibling edge per isolate
            }
        }
    }

    index.edges.extend(new_edges);
}
