//! Single-repo indexing orchestrator.
//!
//! `index_one` drives the full pipeline for one git repo:
//! storage open → candidate walk → parallel extract → serial flush →
//! post-passes (cross-file resolve, route prefix, AD6 prune, SCIP verify,
//! embed) → sentinel clear → manifest/registry data return.

use std::path::Path;
use std::time::Instant;

use anyhow::{Context, Result};
use ckg_core::RepoId;
use ckg_storage::{Storage, manifest::ManifestEntry};

use super::{embed, extract, scip_verify};

/// Data needed to register a repo in `RegistryStorage`. Owned strings
/// (not `&str`) because the registry write happens AFTER `index_one`
/// returns — the caller does it inside the manifest_with_lock window
/// to avoid the NC2 desync race.
pub(super) struct RegistryWrite {
    pub db_path_str: String,
    pub canonical_str: String,
    pub head: String,
    pub now: String,
    pub langs: Vec<String>,
}

/// Index one git repo end-to-end. Returns `(manifest_key, ManifestEntry,
/// RegistryWrite)` for the caller to flush under the manifest lock.
pub(super) fn index_one(
    repo_root: &Path,
    base: &Path,
    clean: bool,
    no_embed: bool,
    embed_batch: usize,
    scip_path: Option<&Path>,
) -> Result<(String, ManifestEntry, RegistryWrite)> {
    let started = Instant::now();
    // CR-cli-H3: capture wall-clock UP-FRONT so a clock-before-epoch
    // panic surfaces BEFORE 5+ minutes of indexing work. Pre-fix
    // `now_unix()` was called near the end of `index_one`, so a clock
    // skew at the registry-write moment would unwind the entire run
    // and the user lost all the indexing work.
    let now = embed::now_unix()?;
    let repo_id = RepoId::from_path(repo_root)?;
    // #2: use the same canonicalize-or-lexical fallback as
    // `repo_lookup::open_for` so writer and reader produce identical
    // manifest keys even when `Path::canonicalize` fails on one side
    // (e.g. Docker bind-mount or symlinked source paths).
    let canonical = super::super::repo_lookup::canonicalize_or_lexical(repo_root);

    if clean {
        let db_dir = base.join("workspace_folders").join(repo_id.as_str());
        if db_dir.exists() {
            std::fs::remove_dir_all(&db_dir)
                .with_context(|| format!("dropping {}", db_dir.display()))?;
        }
    }

    // Open WITH canonical root verification (I2). The DB will refuse
    // to open if it was previously initialised with a different repo
    // — protects against the silent-merge case when two repos hash
    // to the same 64-bit RepoId.
    let storage = Storage::open_at(repo_id.clone(), &canonical, base)?;
    // CR-storage-H4: enforce `--clean` recovery on a needs_reindex DB.
    // The :put-driven indexer upserts rows by id, so plain `ckg index`
    // would inherit orphan rows from a partial / crashed prior run.
    // The I-2 sentinel detects the partial state but the recovery is
    // only effective if user actually drops the DB. Refuse to proceed
    // unless `clean` is set.
    if !clean && storage.needs_reindex() {
        return Err(anyhow::anyhow!(
            "repo {} is marked needs_reindex (previous run did not complete cleanly). \
             Re-run with `--clean` to drop the per-repo DB before indexing — \
             plain `ckg index` would inherit orphan rows.",
            repo_id.as_str()
        ));
    }
    // CR-I-2: stamp the atomicity sentinel BEFORE the first per-file
    // put_symbols / put_edges call. If we crash mid-batch the next
    // `Storage::open_at` will detect this flag and promote
    // needs_reindex=true so the user is warned about the inconsistent
    // state. Cleared by `mark_indexed` at the end of this function.
    storage.mark_index_in_progress()?;
    let head = ckg_git::head_sha(repo_root)?;

    // (SCIP is now applied **after** tree-sitter as a verification pass —
    // see `apply_scip_verification` below.)

    // Collect candidate source files (respects .gitignore + hardcoded
    // build-output excludes). Walker errors surface as warnings (CR-I5).
    let candidates = extract::collect_candidates(repo_root)?;

    // Existing per-file hashes for incremental skip.
    let known_hashes = if clean {
        std::collections::HashMap::new()
    } else {
        extract::load_file_hashes(&storage)
    };

    // Fan out extraction in parallel; results are aggregated below serially.
    let extract::ExtractBatch {
        files_scanned,
        outputs,
    } = extract::run_parallel_extract(repo_id.as_str(), repo_root, candidates, &known_hashes);

    // Aggregate + flush serially (one writer per repo as planned).
    let mut total_symbols = 0usize;
    let mut total_edges = 0usize;
    let mut files_skipped = 0usize;
    let mut files_unchanged = 0usize;
    let mut sym_buf = Vec::with_capacity(2048);
    let mut edge_buf = Vec::with_capacity(2048);
    let mut file_rows: Vec<(String, String, String)> = Vec::new();
    // For semantic search: keep one (id, "name + qname + doc") tuple per
    // embeddable symbol — only top-level decls, no Routes.
    let mut embed_targets: Vec<(String, String)> = Vec::new();

    for r in outputs {
        match r {
            extract::ExtractResult::Done {
                rel,
                lang,
                hash,
                symbols,
                edges,
            } => {
                total_symbols += symbols.len();
                total_edges += edges.len();
                for s in &symbols {
                    if matches!(
                        s.kind,
                        ckg_core::Kind::Function
                            | ckg_core::Kind::Method
                            | ckg_core::Kind::Class
                            | ckg_core::Kind::Struct
                            | ckg_core::Kind::Trait
                            | ckg_core::Kind::Enum
                            | ckg_core::Kind::Module
                    ) {
                        let mut text = format!("{} {}", s.name, s.qname);
                        if !s.doc.is_empty() {
                            text.push_str(" -- ");
                            text.push_str(&s.doc);
                        }
                        embed_targets.push((s.id.clone(), text));
                    }
                }
                sym_buf.extend(symbols);
                edge_buf.extend(edges);
                file_rows.push((rel, lang, hash));
                if sym_buf.len() >= 1000 {
                    storage.put_symbols(&sym_buf)?;
                    sym_buf.clear();
                }
                if edge_buf.len() >= 1000 {
                    storage.put_edges(&edge_buf)?;
                    edge_buf.clear();
                }
            }
            extract::ExtractResult::Skipped => files_unchanged += 1,
            extract::ExtractResult::Failed => files_skipped += 1,
        }
    }
    if !sym_buf.is_empty() {
        storage.put_symbols(&sym_buf)?;
    }
    if !edge_buf.is_empty() {
        storage.put_edges(&edge_buf)?;
    }
    // M2: file-hashes persisted at end-of-success (see below, after
    // all post-extract passes). Moving `persist_file_hashes` here would
    // record hashes even when a post-extract pass fails mid-run, causing
    // incremental skip to hide the broken state on the next plain
    // `ckg index`. Instead we collect `file_rows` now and flush only
    // after `mark_indexed` (gated on `!had_post_extract_err`).

    // Cross-file Calls resolution: rewrite bare-name dsts to their unique
    // workspace-local Symbol id. Skipped if extract already resolved everything.
    match storage.resolve_cross_file_calls() {
        Ok(n) if n > 0 => tracing::info!("cross-file resolved {n} Calls edges"),
        Ok(_) => {}
        Err(e) => tracing::warn!("cross-file resolve failed: {e}"),
    }

    // Phase-3 refactor: framework post-passes (AD6 Rails route prune,
    // FastAPI/Axum cross-file route-prefix join, future hooks) run via
    // the adapter registry. Per-adapter logic owns its own Datalog.
    // Logging happens inside each adapter's post_process impl.
    let registry = ckg_lang::AdapterRegistry::detect(repo_root);
    registry.run_post_processes(&storage);
    // The route-prefix post-pass (FastAPI/Axum) is idempotent so running
    // it from both adapters is safe — see framework::router_prefix doc.
    // Item #4 / #7 (FastAPI/Axum cross-file route-prefix resolution)
    // now runs via FastAPIAdapter::post_process and AxumAdapter::
    // post_process — both delegate to ckg_lang::framework::router_prefix.
    // No-op here; kept as a marker so the search "cross-file route
    // prefix" still lands a reader on this orchestrator.
    // Tests edge detection: link `test_foo` → `foo` symbol.
    match storage.detect_test_edges() {
        Ok(n) if n > 0 => tracing::info!("emitted {n} Tests edges"),
        Ok(_) => {}
        Err(e) => tracing::warn!("test-edge detection failed: {e}"),
    }

    // SCIP verification pass (compiler-grade). Runs **after** tree-sitter so
    // facts already in the graph can be cross-checked. Edges that SCIP also
    // sees are upgraded to confidence 1.0 and tagged `@scip-verified`.
    //
    // NC1 fix: track whether any post-extract pass returned `Err` (vs.
    // `Ok` or "nothing to do"). The C3 sentinel-clear runs only when
    // every post-extract pass succeeded — otherwise the user gets the
    // "needs_reindex" warning on next read AND a chance to fix the
    // root cause (most commonly: offline embed model not yet cached).
    let mut had_post_extract_err = false;
    if let Some(scip) = scip_path {
        match scip_verify::apply_scip_verification(&storage, repo_id.as_str(), scip) {
            Ok((sym_v, edge_upgraded, edge_merged)) => {
                tracing::info!(
                    "scip-verified {sym_v} symbols, upgraded {edge_upgraded} overlap edges, \
                     merged {edge_merged} SCIP-only edges (all conf 1.0)"
                )
            }
            Err(e) => {
                tracing::warn!("scip verification failed: {e}");
                had_post_extract_err = true;
            }
        }
    }

    if !no_embed && !embed_targets.is_empty() {
        match embed::embed_and_persist(&storage, &embed_targets, embed_batch) {
            Ok(n) => tracing::info!("embedded {n} symbols"),
            Err(e) => {
                tracing::warn!(
                    "embedding skipped ({e}); rerun with `ckg index --clean` after \
                     first model download to populate"
                );
                had_post_extract_err = true;
            }
        }
    }

    // C3 fix: clear `needs_reindex` AFTER SCIP + embed. Earlier ordering
    // cleared it before those passes — a panic in either left a partial
    // graph with the sentinel cleared, so users got silent empty results
    // with no warning.
    //
    // NC1 fix: also gate on `!had_post_extract_err`. The dominant
    // failure mode is `embed_and_persist` returning Err (offline mode,
    // model not yet downloaded). Previously the swallowed-Err path
    // still cleared the sentinel, defeating C3's intent.
    if !had_post_extract_err {
        // M2: persist file-hashes only on a fully-successful run so
        // incremental skip doesn't hide post-extract failures. A plain
        // `ckg index` on a repo where e.g. the embed pass failed will
        // re-process unchanged files, re-run the failing pass, and give
        // the user another chance to fix it without `--clean`.
        if let Err(e) = extract::persist_file_hashes(&storage, &file_rows) {
            tracing::warn!("could not persist file hashes (incremental skip disabled for next run): {e}");
        }
        if let Err(e) = storage.mark_indexed() {
            tracing::warn!("could not clear needs_reindex sentinel: {e}");
        }
    } else {
        tracing::warn!(
            "leaving needs_reindex sentinel set due to post-extract failures; \
             fix the underlying issue and re-run `ckg index --clean`"
        );
    }

    // CR-cli-H3: `now` already captured up-front — reuse, don't re-call.
    let manifest_key = canonical.display().to_string();
    let manifest_entry = ManifestEntry {
        repo_id: repo_id.to_string(),
        head_sha: head.clone(),
        last_indexed_at: now.clone(),
        status: "ok".into(),
    };

    // NC2 fix: do NOT write to the cross-repo registry here. Caller
    // does it inside the same `manifest_with_lock` window that gates
    // on `CURRENT`-file existence — otherwise a concurrent
    // `ckg remove` between this point and the manifest write leaves
    // the registry pointing at a deleted DB while the manifest
    // (correctly) skips the entry. Return the data needed for the
    // registry put so the caller can do it under lock.
    // CR-storage-M-7: prefer Storage's recorded root_path over the
    // caller's input. Storage::open_at may have auto-migrated a
    // symlink-equivalent path to its canonical form; using the recorded
    // value here keeps Meta and the registry's `Repo.root_path`
    // consistent so ckg-resolve / mcp::open lookups against either
    // shape resolve to the same row.
    // CR-storage-H3: Storage::recorded_root_path now returns Result.
    // Distinguish "Meta read failed" (Err — disk error → propagate so
    // we don't silently record a stale path in the registry) from
    // "no row" (Ok(None) — fresh DB, fall back to caller's shape).
    let canonical_str = match storage.recorded_root_path() {
        Ok(Some(s)) => s,
        Ok(None) => canonical.display().to_string(),
        Err(e) => {
            return Err(anyhow::anyhow!(
                "could not read Meta.root_path for repo {}: {e} — \
                 refusing to write stale registry entry",
                repo_id.as_str()
            ));
        }
    };
    let registry_data = RegistryWrite {
        db_path_str: storage.db_path().display().to_string(),
        canonical_str,
        head: head.clone(),
        now: now.clone(),
        langs: file_rows
            .iter()
            .map(|(_, l, _)| l.clone())
            .collect::<std::collections::HashSet<_>>()
            .into_iter()
            .collect(),
    };

    let elapsed = started.elapsed();
    // CLI-H2: human progress → stderr; stdout reserved for structured data.
    eprintln!(
        "indexed {} ({}): {} files ({} unchanged, {} skipped), {} symbols, {} edges ({} ms)",
        repo_root.display(),
        repo_id,
        files_scanned,
        files_unchanged,
        files_skipped,
        total_symbols,
        total_edges,
        elapsed.as_millis(),
    );
    Ok((manifest_key, manifest_entry, registry_data))
}
