//! `ckg index` — walk a path, find git repos, extract & persist.
//!
//! MVP: serial file walk. Honors hidden-dir exclusion (`.git`, `target`, etc.)
//! at a coarse level; full `.gitignore` integration is a follow-up.
//!
//! Submodules:
//!  - `discover`    — git repo discovery with symlink guard (CR-C1)
//!  - `extract`     — per-file walker, parallel parse, hash persistence
//!  - `index_one`   — single-repo orchestrator (`RegistryWrite` type lives here)
//!  - `embed`       — embedding generation + `now_unix` helper
//!  - `scip_indexer`— SCIP indexer detection and spawning
//!  - `scip_verify` — SCIP verification pass + `resolve_scip_source`
//!
//! AD6 Rails route prune moved to `ckg_lang::framework::rails::post_process`;
//! FastAPI/Axum cross-file route-prefix join moved to
//! `ckg_lang::framework::router_prefix`. Both run via
//! `AdapterRegistry::run_post_processes` during adapter-refactor phase 3.

mod discover;
mod embed;
mod extract;
mod index_one;
mod scip_indexer;
mod scip_verify;

use std::path::PathBuf;

use anyhow::{Context, Result, bail};
use ckg_storage::manifest::ManifestEntry;
use ckg_storage::{Manifest, RegistryStorage, manifest_with_lock, with_repo_lock};
use clap::Args as ClapArgs;

#[derive(ClapArgs, Debug)]
pub struct Args {
    /// Path to the repository (or directory tree containing repos).
    #[arg(default_value = ".")]
    pub path: PathBuf,
    /// Force a clean rebuild (drop existing graph first).
    #[arg(long)]
    pub clean: bool,
    /// Recurse past the first-found `.git` (for monorepos / submodules).
    #[arg(long)]
    pub recursive: bool,
    /// Skip embedding generation.
    #[arg(long)]
    pub no_embed: bool,
    /// Ingest a pre-built SCIP index file. Skips tree-sitter; SCIP edges
    /// land at confidence 1.0.
    #[arg(long)]
    pub scip: Option<PathBuf>,
    /// Force-run a SCIP indexer (errors if none available). By default ckg
    /// will opportunistically auto-detect and run an indexer when one is on
    /// `$PATH`; this flag makes the opportunism mandatory.
    #[arg(long, conflicts_with = "scip", conflicts_with = "no_scip")]
    pub scip_auto: bool,
    /// Disable SCIP entirely — always use tree-sitter, even if `scip-rust` /
    /// `scip-python` / `scip-ruby` / `scip-typescript` is installed.
    #[arg(long, conflicts_with = "scip")]
    pub no_scip: bool,
    /// M9: Embedding batch size (symbols per ONNX inference call).
    /// Default 64 is a memory-vs-throughput compromise (~50 MB RAM at
    /// 768-dim f32). Raise for high-RAM machines; lower for ARM SBCs.
    /// Must be in 1..=4096.
    #[arg(long, env = "CKG_EMBED_BATCH", default_value_t = 64)]
    pub embed_batch: usize,
}

pub fn run(home: Option<PathBuf>, args: Args) -> Result<()> {
    let base = crate::ckg_home(home)?;
    std::fs::create_dir_all(&base)?;
    let _ = ckg_fs::set_dir_0700(&base);

    let repos = discover::discover_repos(&args.path, args.recursive)?;
    if repos.is_empty() {
        bail!("no git repositories found in {}", args.path.display());
    }

    let manifest_path = base.join("manifest.json");

    // #5 fix: narrow the lock window. Indexing per-repo can take
    // minutes; holding the manifest flock that whole time blocks every
    // other `ckg index` / `ckg remove` run. Per-repo concurrency is
    // already serialised by the per-repo RocksDB exclusive lock, so
    // the only thing actually needing the manifest lock is the brief
    // read-merge-write window at the end. Collect updates in memory
    // during unlocked indexing, then merge under lock.
    // L4: `registry` handle is intentionally kept open for the duration of the
    // repos loop so the registry DB stays locked (preventing a concurrent
    // `ckg remove` from deleting a DB while we are still writing it).
    // The registry writes themselves happen inside the manifest lock below.
    let registry = RegistryStorage::open(&base).with_context(|| "opening registry DB")?;
    let mut updates: Vec<(String, ManifestEntry, index_one::RegistryWrite)> =
        Vec::with_capacity(repos.len());
    let mut first_err: Option<anyhow::Error> = None;
    let mut additional_failures: usize = 0;
    for repo_root in &repos {
        let scip_path = match scip_verify::resolve_scip_source(repo_root, &args) {
            Ok(scip_verify::ScipSource::Path(p)) => Some(p),
            Ok(scip_verify::ScipSource::None) => None,
            Err(e) => {
                // SCIP setup failed for this repo. Record the error,
                // skip this repo, but continue indexing others — #1:
                // a single failure must not orphan earlier successes
                // from their manifest entries.
                if first_err.is_some() {
                    additional_failures += 1;
                    tracing::warn!(
                        "additional repo failure (scip setup) for {}: {e}",
                        repo_root.display()
                    );
                } else {
                    first_err =
                        Some(e.context(format!("scip setup for {}", repo_root.display())));
                }
                continue;
            }
        };
        // #1 fix: hold the per-repo flock for the entire `index_one`
        // run so a concurrent `ckg remove <same-repo>` cannot wipe
        // `db_dir` underneath us. Both writers respect this flock;
        // RocksDB's own process-wide lock alone doesn't stop
        // `remove_dir_all`.
        let repo_id_str = ckg_core::RepoId::from_path(repo_root)?.as_str().to_string();
        let embed_batch = args.embed_batch.clamp(1, 4096);
        let res: Result<_> = with_repo_lock(&repo_id_str, &base, || {
            index_one::index_one(
                repo_root,
                &base,
                args.clean,
                args.no_embed,
                embed_batch,
                scip_path.as_deref(),
            )
            .map_err(|e| match e.downcast::<ckg_core::Error>() {
                Ok(ce) => ce,
                Err(other) => ckg_core::Error::Other(other.to_string()),
            })
        })
        .map_err(anyhow::Error::from);
        match res {
            Ok(triple) => updates.push(triple),
            Err(e) => {
                if first_err.is_some() {
                    additional_failures += 1;
                    tracing::warn!(
                        "additional repo failure (indexing) for {}: {e}",
                        repo_root.display()
                    );
                } else {
                    first_err = Some(e.context(format!("indexing {}", repo_root.display())));
                }
                // Continue to next repo so prior successes still get
                // their manifest entries written below.
            }
        }
    }

    // Flush whatever partial successes we have, **always** (#1 fix).
    // Brief manifest lock: re-read under the lock so we don't clobber
    // a concurrent `ckg index --clean <other>` that ran while we were
    // working, then apply our updates and write.
    //
    // C1 fix: under the manifest lock, verify each repo's per-repo DB
    // still has a `CURRENT` file before re-inserting its manifest
    // entry. A concurrent `ckg remove <repo>` that won the lock-race
    // mid-indexing would have wiped `<base>/workspace_folders/<id>/`;
    // re-inserting the entry would otherwise leave the manifest
    // pointing at a deleted dir. We drop those entries and warn —
    // orphaned RocksDB writes from the indexing process are
    // unavoidable but the manifest stays consistent.
    //
    // NC2 fix: registry put_repo also runs inside this block, gated
    // on the same CURRENT check. Previously the registry was written
    // inside `index_one` (no lock held) — a concurrent `ckg remove`
    // between `index_one` returning and the manifest lock acquire
    // produced a registry orphan pointing at a deleted DB.
    if !updates.is_empty() {
        manifest_with_lock(&manifest_path, || {
            let mut manifest = Manifest::read(&manifest_path)?;
            for (key, entry, reg) in &updates {
                let db_dir = base.join("workspace_folders").join(&entry.repo_id);
                // RocksDB writes its `CURRENT` file inside the column-family
                // data subdir (`<db_dir>/data/CURRENT`), not at db_dir root.
                // The earlier `db_dir.join("CURRENT")` check looked at the
                // wrong path and ALWAYS reported "vanished" — even on
                // healthy indexing runs. Use the actual RocksDB layout.
                if !db_dir.join("data").join("CURRENT").is_file() {
                    tracing::warn!(
                        "skipping manifest+registry update for {} ({}): db_dir \
                         vanished during indexing — concurrent `ckg remove` likely \
                         won the race",
                        key,
                        entry.repo_id
                    );
                    continue;
                }
                manifest.repos.insert(key.clone(), entry.clone());
                let lang_refs: Vec<&str> = reg.langs.iter().map(String::as_str).collect();
                if let Err(e) = registry.put_repo(
                    &entry.repo_id,
                    &reg.db_path_str,
                    &reg.canonical_str,
                    &reg.head,
                    &reg.now,
                    &lang_refs,
                ) {
                    tracing::warn!(
                        "registry put failed for {} ({}): {e}; manifest still updated",
                        key,
                        entry.repo_id
                    );
                }
            }
            manifest.write_atomic(&manifest_path)
        })
        .with_context(|| format!("manifest update at {}", manifest_path.display()))?;
    }

    if let Some(e) = first_err {
        if additional_failures > 0 {
            tracing::warn!(
                "{} additional repo failures suppressed; first one returned below",
                additional_failures
            );
        }
        return Err(e);
    }
    Ok(())
}
