// WRITE_LOCK guard is held across .await inside block_on(). Safe because
// block_on runs single-threaded — no concurrent tasks can deadlock.
#![allow(clippy::await_holding_lock)]
//! Call graph upsert, delete, batch operations, and basic stats.

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

use super::CallStats;
use crate::store::helpers::StoreError;
use crate::store::{ReadWrite, Store};

/// Shared in-tx helper for writing the file-level `function_calls` table.
///
/// Used by both [`Store::upsert_function_calls`] (which opens its own tx) and
/// [`Store::upsert_chunks_calls_and_prune`] (which folds the function_calls
/// write into the same per-file tx as the chunks/FTS/calls write, so the
/// tables can't end up in an asymmetric committed state).
///
/// `file_str` MUST be the normalized origin path (call
/// `crate::normalize_path` at the call site, once).
pub(crate) async fn write_function_calls_in_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
    file_str: &str,
    function_calls: &[crate::parser::FunctionCalls],
) -> Result<(), StoreError> {
    sqlx::query("DELETE FROM function_calls WHERE file = ?1")
        .bind(file_str)
        .execute(&mut **tx)
        .await?;

    // Flatten all calls and batch insert (instead of N individual inserts)
    let all_calls: Vec<_> = function_calls
        .iter()
        .flat_map(|fc| {
            fc.calls.iter().map(move |call| {
                (
                    &fc.name,
                    fc.line_start,
                    &call.callee_name,
                    call.line_number,
                    call.kind.as_str(),
                )
            })
        })
        .collect();

    if !all_calls.is_empty() {
        use crate::store::helpers::sql::max_rows_per_statement;
        const INSERT_BATCH: usize = max_rows_per_statement(6);
        for batch in all_calls.chunks(INSERT_BATCH) {
            let mut query_builder: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new(
                "INSERT INTO function_calls (file, caller_name, caller_line, callee_name, call_line, edge_kind) ",
            );
            query_builder.push_values(
                batch.iter(),
                |mut b, (caller_name, caller_line, callee_name, call_line, edge_kind)| {
                    b.push_bind(file_str)
                        .push_bind(*caller_name)
                        .push_bind(*caller_line as i64)
                        .push_bind(*callee_name)
                        .push_bind(*call_line as i64)
                        .push_bind(*edge_kind);
                },
            );
            query_builder.build().execute(&mut **tx).await?;
        }
        tracing::info!(
            file = %file_str,
            functions = function_calls.len(),
            calls = all_calls.len(),
            "Indexed function calls"
        );
    }
    Ok(())
}

/// Shared in-tx helper for writing the file-level `candidate_edges` side-table.
///
/// Sibling of [`write_function_calls_in_tx`]. `candidate_edges` holds
/// low-confidence call-graph candidates that must NOT surface as callers; it is
/// a SEPARATE table (never a `function_calls.edge_kind` value) and is never
/// joined by a graph query, so a candidate is invisible to the caller graph by
/// construction. Like `function_calls`, the table is file-keyed and refreshed
/// wholesale per file (DELETE WHERE file = ? then INSERT the current set), so it
/// is folded into the same per-file transaction as the chunks/FTS/calls write to
/// keep the tables from drifting into an asymmetric committed state.
///
/// `file_str` MUST be the normalized origin path (call `crate::normalize_path`
/// at the call site, once); it scopes both the DELETE and every inserted row's
/// `file` column so they cannot disagree.
pub(crate) async fn write_candidate_edges_in_tx(
    tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
    file_str: &str,
    candidates: &[crate::parser::CandidateSite],
) -> Result<(), StoreError> {
    sqlx::query("DELETE FROM candidate_edges WHERE file = ?1")
        .bind(file_str)
        .execute(&mut **tx)
        .await?;

    if !candidates.is_empty() {
        use crate::store::helpers::sql::max_rows_per_statement;
        const INSERT_BATCH: usize = max_rows_per_statement(4);
        for batch in candidates.chunks(INSERT_BATCH) {
            let mut query_builder: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new(
                "INSERT INTO candidate_edges (file, callee_name, ref_line, candidate_kind) ",
            );
            query_builder.push_values(batch.iter(), |mut b, cand| {
                b.push_bind(file_str)
                    .push_bind(&cand.callee_name)
                    .push_bind(cand.ref_line as i64)
                    .push_bind(&cand.candidate_kind);
            });
            query_builder.build().execute(&mut **tx).await?;
        }
        tracing::info!(
            file = %file_str,
            candidates = candidates.len(),
            "Indexed candidate edges"
        );
    }
    Ok(())
}

impl Store<ReadWrite> {
    /// Insert or replace call sites for a chunk
    pub fn upsert_calls(
        &self,
        chunk_id: &str,
        calls: &[crate::parser::CallSite],
    ) -> Result<(), StoreError> {
        let _span = tracing::info_span!("upsert_calls", count = calls.len()).entered();
        tracing::trace!(chunk_id, call_count = calls.len(), "upserting chunk calls");

        self.rt.block_on(async {
            let (_guard, mut tx) = self.begin_write().await?;

            sqlx::query("DELETE FROM calls WHERE caller_id = ?1")
                .bind(chunk_id)
                .execute(&mut *tx)
                .await?;

            if !calls.is_empty() {
                use crate::store::helpers::sql::max_rows_per_statement;
                const INSERT_BATCH: usize = max_rows_per_statement(3);
                for batch in calls.chunks(INSERT_BATCH) {
                    let mut query_builder: sqlx::QueryBuilder<sqlx::Sqlite> =
                        sqlx::QueryBuilder::new(
                            "INSERT INTO calls (caller_id, callee_name, line_number) ",
                        );
                    query_builder.push_values(batch.iter(), |mut b, call| {
                        b.push_bind(chunk_id)
                            .push_bind(&call.callee_name)
                            .push_bind(call.line_number as i64);
                    });
                    query_builder.build().execute(&mut *tx).await?;
                }
                tracing::debug!(chunk_id, call_count = calls.len(), "Inserted chunk calls");
            }

            tx.commit().await?;
            Ok(())
        })
    }

    /// Insert call sites for multiple chunks in a single transaction.
    /// Takes `(chunk_id, CallSite)` pairs and batches them into one transaction.
    pub fn upsert_calls_batch(
        &self,
        calls: &[(String, crate::parser::CallSite)],
    ) -> Result<(), StoreError> {
        let _span = tracing::info_span!("upsert_calls_batch", count = calls.len()).entered();
        if calls.is_empty() {
            return Ok(());
        }

        tracing::trace!(call_count = calls.len(), "upserting calls batch");

        self.rt.block_on(async {
            let (_guard, mut tx) = self.begin_write().await?;

            // Collect unique chunk IDs to delete old calls
            let mut seen_ids = std::collections::HashSet::new();
            for (chunk_id, _) in calls {
                if seen_ids.insert(chunk_id.as_str()) {
                    sqlx::query("DELETE FROM calls WHERE caller_id = ?1")
                        .bind(chunk_id)
                        .execute(&mut *tx)
                        .await?;
                }
            }

            use crate::store::helpers::sql::max_rows_per_statement;
            const INSERT_BATCH: usize = max_rows_per_statement(3);
            for batch in calls.chunks(INSERT_BATCH) {
                let mut query_builder: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new(
                    "INSERT INTO calls (caller_id, callee_name, line_number) ",
                );
                query_builder.push_values(batch.iter(), |mut b, (chunk_id, call)| {
                    b.push_bind(chunk_id)
                        .push_bind(&call.callee_name)
                        .push_bind(call.line_number as i64);
                });
                query_builder.build().execute(&mut *tx).await?;
            }

            tx.commit().await?;
            Ok(())
        })
    }
}

impl<Mode> Store<Mode> {
    /// Check which chunk IDs from a set actually exist in the database.
    /// Used by periodic deferred-flush to filter calls whose FK targets are present.
    pub fn existing_chunk_ids(
        &self,
        ids: &std::collections::HashSet<&str>,
    ) -> Result<std::collections::HashSet<String>, StoreError> {
        let _span = tracing::debug_span!("existing_chunk_ids", candidates = ids.len()).entered();
        if ids.is_empty() {
            return Ok(std::collections::HashSet::new());
        }
        self.rt.block_on(async {
            let mut found = std::collections::HashSet::new();
            let id_vec: Vec<&str> = ids.iter().copied().collect();
            use crate::store::helpers::make_placeholders;
            use crate::store::helpers::sql::max_rows_per_statement;
            for batch in id_vec.chunks(max_rows_per_statement(1)) {
                // Cached helper — `Cow::Borrowed(&'static str)` on hit.
                let placeholders = make_placeholders(batch.len());
                let sql = format!("SELECT id FROM chunks WHERE id IN ({placeholders})");
                let mut query = sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(sql.as_str()));
                for id in batch {
                    query = query.bind(*id);
                }
                let rows: Vec<String> = query.fetch_all(&self.pool).await?;
                found.extend(rows);
            }
            Ok(found)
        })
    }

    /// Get all function names called by a given chunk.
    /// Takes a chunk **ID** (unique) rather than a name. Returns only callee
    /// **names** (not full chunks) because:
    /// - Callees may not exist in the index (external functions)
    /// - Callers typically chain: `get_callees` → `get_callers_full` for graph traversal
    /// For richer callee data, see [`get_callers_with_context`].
    pub fn get_callees(&self, chunk_id: &str) -> Result<Vec<String>, StoreError> {
        let _span = tracing::debug_span!("get_callees", chunk_id = %chunk_id).entered();
        self.rt.block_on(async {
            let rows: Vec<(String,)> = sqlx::query_as(
                "SELECT DISTINCT callee_name FROM calls WHERE caller_id = ?1 ORDER BY line_number",
            )
            .bind(chunk_id)
            .fetch_all(&self.pool)
            .await?;

            Ok(rows.into_iter().map(|(s,)| s).collect())
        })
    }

    /// Total call count and distinct-callee count from the `calls` table.
    pub fn call_stats(&self) -> Result<CallStats, StoreError> {
        let _span = tracing::debug_span!("call_stats").entered();
        self.rt.block_on(async {
            let (total_calls, unique_callees): (i64, i64) =
                sqlx::query_as("SELECT COUNT(*), COUNT(DISTINCT callee_name) FROM calls")
                    .fetch_one(&self.pool)
                    .await?;

            Ok(CallStats {
                total_calls: total_calls as u64,
                unique_callees: unique_callees as u64,
            })
        })
    }
}

impl Store<ReadWrite> {
    // ============ Full Call Graph Methods ============

    /// Insert function calls for a file (full call graph, no size limits)
    pub fn upsert_function_calls(
        &self,
        file: &Path,
        function_calls: &[crate::parser::FunctionCalls],
    ) -> Result<(), StoreError> {
        let _span =
            tracing::info_span!("upsert_function_calls", count = function_calls.len()).entered();
        let file_str = crate::normalize_path(file);
        let total_calls: usize = function_calls.iter().map(|fc| fc.calls.len()).sum();
        tracing::trace!(
            file = %file_str,
            functions = function_calls.len(),
            total_calls,
            "upserting function calls"
        );

        self.rt.block_on(async {
            let (_guard, mut tx) = self.begin_write().await?;
            write_function_calls_in_tx(&mut tx, &file_str, function_calls).await?;
            tx.commit().await?;
            Ok(())
        })
    }

    /// Insert the file's low-confidence `candidate_edges` (Lane 2), wholesale.
    ///
    /// Symmetric sibling of [`Store::upsert_function_calls`]: refreshes one
    /// file's candidate set (DELETE WHERE file = ? then INSERT the current rows)
    /// in its own transaction. `file` is normalized once at the call site so the
    /// DELETE scope and the inserted rows' `file` column cannot disagree.
    /// Candidates are a SEPARATE table never joined by a graph query, so writing
    /// here can never pollute the caller graph; they feed only the `cqs dead`
    /// low-confidence-live consult.
    pub fn upsert_candidate_edges(
        &self,
        file: &Path,
        candidates: &[crate::parser::CandidateSite],
    ) -> Result<(), StoreError> {
        let _span =
            tracing::info_span!("upsert_candidate_edges", count = candidates.len()).entered();
        let file_str = crate::normalize_path(file);
        self.rt.block_on(async {
            let (_guard, mut tx) = self.begin_write().await?;
            write_candidate_edges_in_tx(&mut tx, &file_str, candidates).await?;
            tx.commit().await?;
            Ok(())
        })
    }

    /// Insert function calls AND low-confidence candidate edges for multiple
    /// files in a single transaction.
    ///
    /// Mirrors the `upsert_type_edges_for_files` pattern. Calling
    /// `upsert_function_calls(file, calls)` once per file would open one
    /// transaction per file — 2,500 separate `BEGIN ... COMMIT` round-trips
    /// on a 2,500-file project. Batching to one transaction matches the shape
    /// used for type edges.
    ///
    /// `function_calls` and `candidate_edges` are BOTH file-keyed call-graph
    /// tables refreshed wholesale per file (DELETE WHERE file = ? then INSERT
    /// the current set). They are swept together here so the parse-driven watch
    /// finalize path matches the bulk pipeline's fused zero-chunk flush, which
    /// writes both. Threading `candidate_entries` lets an oversize function
    /// (zero chunks, non-empty calls) reindexed via the watch path PERSIST a
    /// cross-file bare-arg / serde candidate instead of having it silently
    /// cleared — the false-DEAD on the oversize-fn × candidate × watch path.
    ///
    /// `candidate_entries` is matched to `entries` by normalized path: a file
    /// present in `entries` always has its candidates cleared (Phase 1 DELETE),
    /// and re-inserted only if it appears in `candidate_entries` with a
    /// non-empty set. A file in `entries` but absent from `candidate_entries`
    /// (or present with an empty set) ends with zero candidate rows — the
    /// clear-on-empty contract. Candidates for a file NOT in `entries` are
    /// ignored: its `function_calls` are not being refreshed, so neither are
    /// its candidates.
    ///
    /// Inside the single transaction:
    /// - Phase 1: batched DELETE WHERE file IN (?, ?, ?) for `function_calls`
    ///   AND `candidate_edges`, for all files in the batch (chunked under the
    ///   SQLite parameter limit).
    /// - Phase 2: batched multi-row INSERT of `function_calls` rows (6 binds
    ///   per row, chunked under the same parameter limit).
    /// - Phase 3: batched multi-row INSERT of `candidate_edges` rows (4 binds
    ///   per row), for the files in `entries` that carry candidates.
    pub fn upsert_function_calls_for_files(
        &self,
        entries: &[(PathBuf, Vec<crate::parser::FunctionCalls>)],
        candidate_entries: &[(PathBuf, Vec<crate::parser::CandidateSite>)],
    ) -> Result<(), StoreError> {
        let total_files = entries.len();
        let _span =
            tracing::info_span!("upsert_function_calls_for_files", files = total_files).entered();
        if entries.is_empty() {
            return Ok(());
        }

        // Pre-normalize file paths so the borrow lives for the whole tx.
        let file_strs: Vec<String> = entries
            .iter()
            .map(|(file, _)| crate::normalize_path(file))
            .collect();

        // Normalized-path → candidate slice, so Phase 3 re-inserts each file's
        // candidates under the SAME key that scoped its Phase 1 clear. Only
        // files being refreshed (present in `entries`) are eligible; a candidate
        // entry for an unrelated file would write a row whose `function_calls`
        // were not touched, breaking the wholesale-per-file contract.
        let entry_files: std::collections::HashSet<&str> =
            file_strs.iter().map(|s| s.as_str()).collect();
        let mut candidates_by_file: std::collections::HashMap<
            String,
            &[crate::parser::CandidateSite],
        > = std::collections::HashMap::new();
        for (file, cands) in candidate_entries {
            let fs = crate::normalize_path(file);
            if entry_files.contains(fs.as_str()) {
                candidates_by_file.insert(fs, cands.as_slice());
            }
        }

        self.rt.block_on(async {
            let (_guard, mut tx) = self.begin_write().await?;

            use crate::store::helpers::sql::max_rows_per_statement;

            // Phase 1: batched DELETE WHERE file IN (?, ?, ?) — one bind per
            // file, chunked under the SQLite param limit.
            const DELETE_PER_STMT: usize = max_rows_per_statement(1);
            for chunk in file_strs.chunks(DELETE_PER_STMT) {
                let placeholders = crate::store::helpers::make_placeholders(chunk.len());
                let sql =
                    format!("DELETE FROM function_calls WHERE file IN ({})", placeholders);
                let mut q = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()));
                for fs in chunk {
                    q = q.bind(fs);
                }
                q.execute(&mut *tx).await?;

                // Clear the same files' `candidate_edges` in the same tx. Both
                // are file-keyed call-graph tables refreshed wholesale per file;
                // the matching INSERT happens in Phase 3 (re-inserting only the
                // files that carry fresh candidates). A file refreshed through
                // here with no candidates ends with zero candidate rows — the
                // clear-on-empty contract. Same DELETE-then-INSERT
                // wholesale-per-file contract the other call-graph delete sites
                // (delete_by_origin, prune_missing, the fused write) honor.
                let cand_sql =
                    format!("DELETE FROM candidate_edges WHERE file IN ({})", placeholders);
                let mut cq = sqlx::query(sqlx::AssertSqlSafe(cand_sql.as_str()));
                for fs in chunk {
                    cq = cq.bind(fs);
                }
                cq.execute(&mut *tx).await?;
            }

            // Phase 2: collect all function_calls rows tagged with their file
            // string, then batched multi-row INSERT.
            let mut all_rows: Vec<(&str, &str, u32, &str, u32, &str)> = Vec::new();
            for ((_file, function_calls), file_str) in entries.iter().zip(file_strs.iter()) {
                for fc in function_calls {
                    for call in &fc.calls {
                        all_rows.push((
                            file_str.as_str(),
                            fc.name.as_str(),
                            fc.line_start,
                            call.callee_name.as_str(),
                            call.line_number,
                            call.kind.as_str(),
                        ));
                    }
                }
            }

            if !all_rows.is_empty() {
                const INSERT_BATCH: usize = max_rows_per_statement(6);
                for batch in all_rows.chunks(INSERT_BATCH) {
                    let mut qb: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new(
                        "INSERT INTO function_calls (file, caller_name, caller_line, callee_name, call_line, edge_kind) ",
                    );
                    qb.push_values(
                        batch.iter(),
                        |mut b, (file, caller_name, caller_line, callee_name, call_line, edge_kind)| {
                            b.push_bind(*file)
                                .push_bind(*caller_name)
                                .push_bind(*caller_line as i64)
                                .push_bind(*callee_name)
                                .push_bind(*call_line as i64)
                                .push_bind(*edge_kind);
                        },
                    );
                    qb.build().execute(&mut *tx).await?;
                }
            }

            // Phase 3: re-insert candidate_edges for the refreshed files that
            // carry candidates, keyed by the normalized file string used for the
            // Phase 1 clear. This is the half the watch zero-chunk finalize path
            // was missing — without it an oversize function's cross-file
            // candidate is cleared but never re-written, a false-DEAD. Mirrors
            // the bulk pipeline's fused-flush candidate write.
            let mut all_cand_rows: Vec<(&str, &str, u32, &str)> = Vec::new();
            for file_str in &file_strs {
                if let Some(cands) = candidates_by_file.get(file_str) {
                    for cand in cands.iter() {
                        all_cand_rows.push((
                            file_str.as_str(),
                            cand.callee_name.as_str(),
                            cand.ref_line,
                            cand.candidate_kind.as_str(),
                        ));
                    }
                }
            }

            if !all_cand_rows.is_empty() {
                const CAND_INSERT_BATCH: usize = max_rows_per_statement(4);
                for batch in all_cand_rows.chunks(CAND_INSERT_BATCH) {
                    let mut qb: sqlx::QueryBuilder<sqlx::Sqlite> = sqlx::QueryBuilder::new(
                        "INSERT INTO candidate_edges (file, callee_name, ref_line, candidate_kind) ",
                    );
                    qb.push_values(
                        batch.iter(),
                        |mut b, (file, callee_name, ref_line, candidate_kind)| {
                            b.push_bind(*file)
                                .push_bind(*callee_name)
                                .push_bind(*ref_line as i64)
                                .push_bind(*candidate_kind);
                        },
                    );
                    qb.build().execute(&mut *tx).await?;
                }
            }

            tx.commit().await?;
            tracing::info!(
                files = total_files,
                rows = all_rows.len(),
                candidate_rows = all_cand_rows.len(),
                "Batch-indexed function calls"
            );
            Ok(())
        })
    }
}
