mod helpers;
mod policy;
mod providers;
mod reconcile;
mod reencode;
mod status;
mod store;
mod throughput_tune;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::Instant;

pub(crate) use helpers::*;
pub(crate) use policy::*;
#[cfg(feature = "fastembed")]
pub use providers::FastEmbedEmbedder;
#[cfg(test)]
pub use providers::MockEmbedder;
#[cfg(feature = "model2vec")]
pub use providers::Model2VecEmbedder;
// EVAL-ONLY seam for the `rag-rat benchmark-embedding` subcommand (#346): the CLI (a separate
// crate) provisions an ephemeral box (`provision_box_for_benchmark`, holding the
// `ProvisionedBox` for teardown) and runs its OWN measured concurrency sweep
// (`benchmark_remote_concurrency`), emitting every per-candidate `MeasuredCandidate`. No knee
// selection, no tune-cache write — those stay on the reconcile path.
#[cfg(feature = "eval")]
pub use providers::provision_box_for_benchmark;
pub(crate) use providers::{
    ChunkEmbedder, acquire_chunk_embedder, active_embedder, provision_and_build,
    resolve_auth_header,
};
// Curate the provider surface onto the `index::ai` path so existing `super::*` callers
// (`reconcile`, `status`, `helpers`) and the external `ai::Embedder` /
// `ai::FASTEMBED_MISSING_*` references keep resolving after the move into `providers/`. mod.rs
// is the index, not a shim. The types/consts re-export with their pre-move `pub` visibility:
// before the move they were `pub` items directly in this (`pub mod ai`) module, i.e. reachable
// as the crate-public path `crate::index::ai::*`, which exempts them from dead-code/unused
// analysis even when a given feature build has no live caller (`MockEmbedder`, the
// `#[cfg(not(...))]`-only consts). A `pub(crate)` re-export would narrow that and redden `-D
// warnings`.
pub use providers::{
    CookbookInput, CookbookProvisioner, Embedder, FASTEMBED_MISSING_FEATURE_MESSAGE, HashEmbedder,
    MODEL2VEC_HF_REPO, MODEL2VEC_MISSING_FEATURE_MESSAGE, OpenAiEmbedder, ProvisionedBox,
    abort_active_provisioning, install_provision_log_sink, verify_ephemeral_remote,
    verify_ephemeral_remote_cancellable,
};
pub(crate) use reconcile::*;
pub(crate) use reencode::*;
use rusqlite::types::Value;
use rusqlite::{Connection, OptionalExtension, params, params_from_iter};
use serde::Serialize;
use sha2::{Digest, Sha256};
pub(crate) use status::*;
pub(crate) use store::*;
#[cfg(feature = "eval")]
pub use throughput_tune::{
    MeasuredCandidate, benchmark_remote_concurrency, default_benchmark_budget_ms,
    default_benchmark_candidates, measure_remote_dim, min_benchmark_budget_ms,
};

use crate::index::now_ms;
use crate::language::Language;

const ACTIVE_EMBEDDING_MODEL_META: &str = "active_embedding_model";
const ACTIVE_EMBEDDING_MODEL_VERSION_META: &str = "embedding_active_model_version";
/// `"1"` when the active embedding model was set AUTOMATICALLY — a config seed
/// (`seed_active_embedding_model`) or a fastembed-cache recovery (`recover_cached_fastembed_model`)
/// — and NOT confirmed by the user. Set to `"0"` when the user selects it explicitly
/// (`install_model`) or a reconcile commits embeddings under it. A PROVISIONAL active model yields
/// to a differing config on the next config-aware open; an explicit / committed one is respected.
/// Absent ⇒ treated as non-provisional (a pre-#394 index, or an explicit choice), so the seed never
/// clobbers it (#394).
const ACTIVE_EMBEDDING_MODEL_PROVISIONAL_META: &str = "active_embedding_model_provisional";
/// Secret-free JSON of the active [`RemoteEmbeddingConfig`] (#317 task 5). Written at install when
/// an Ollama model is activated; read by `active_embedder` to reconstruct the remote embedder for
/// BOTH chunk-embed (reconcile) and query-embed (search), so the `conn`-based construction path
/// needs no config threading. SECRET-FREE: `auth_env` is the env-var NAME, never the token.
const ACTIVE_EMBEDDING_REMOTE_CONFIG_META: &str = "active_embedding_remote_config";
const LAST_EMBEDDING_RECONCILE_STARTED_META: &str = "last_embedding_reconcile_started_at_ms";
const LAST_EMBEDDING_RECONCILE_FINISHED_META: &str = "last_embedding_reconcile_finished_at_ms";
const DEFAULT_BATCH_SIZE: usize = 64;
pub const DEFAULT_MAX_EMBEDDING_CHARS: usize = 4_000;
const MIN_EMBEDDING_CHARS: usize = 80;
pub const EMBEDDING_TEXT_VERSION: &str = "embedding-text-v2";
// Old model ids that must be cleaned on open. `embedding-small` is the pre-#112 id; the four
// dashed ids are the pre-#317 ids renamed to HF paths (existing vectors re-key → re-embed, fine
// pre-launch). `ollama-all-minilm` is the pre-#317 REMOTE id: Ollama is now a transport (a
// `[remote]` block on a real model), not a registry row, so an index that had it installed+active
// would otherwise keep an `ai_models` row + active-model meta + remote-config meta whose `spec()`
// is gone — `active_embedder` then fails with "unknown active embedding model" (its aliases
// `ollama`/`ollama-minilm` were never stored ids, so only the canonical id needs cleaning).
// `remove_legacy_models` drops their `ai_models`/`chunk_embeddings`/active-meta (and, for a legacy
// ACTIVE model, the stale remote-config meta) rows.
const LEGACY_MODEL_IDS: &[&str] = &[
    "embedding-small",
    "fastembed-all-minilm-l6-v2",
    "fastembed-bge-small-en-v1.5",
    "fastembed-jina-v2-base-code",
    "model2vec-potion-retrieval-32m",
    "ollama-all-minilm",
];
#[cfg(feature = "fastembed")]
const FASTEMBED_HF_CACHE_REPO_DIR: &str = "models--Qdrant--all-MiniLM-L6-v2-onnx";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ArtifactStatus {
    Current,
    Missing,
    Stale,
    Failed,
    Blocked,
    Disabled,
}

impl ArtifactStatus {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Current => "Current",
            Self::Missing => "Missing",
            Self::Stale => "Stale",
            Self::Failed => "Failed",
            Self::Blocked => "Blocked",
            Self::Disabled => "Disabled",
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct LlmStatus {
    pub embedding: CapabilityStatus,
    pub artifacts: ArtifactCounts,
    pub fastembed: FastEmbedOperationalStatus,
    pub last_reconcile: Option<LastReconcileStatus>,
}

#[derive(Debug, Clone, Serialize)]
pub struct CapabilityStatus {
    pub capability: String,
    pub model_id: String,
    pub state: String,
    pub installed: bool,
    pub disabled: bool,
    pub current_artifacts: u64,
    pub stale_artifacts: u64,
    pub failed_artifacts: u64,
    pub blocked_artifacts: u64,
    pub message: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct FastEmbedOperationalStatus {
    pub backend: String,
    pub build_feature_enabled: bool,
    pub model_id: String,
    pub model: String,
    pub dim: usize,
    pub cache: String,
    pub installed: bool,
    pub active: bool,
    pub status: String,
    pub current_embeddings: u64,
    pub eligible_embeddings: u64,
    pub skipped_embeddings: u64,
    pub stale_embeddings: u64,
    pub missing_embeddings: u64,
    pub failed_embeddings: u64,
    pub failed_retryable_embeddings: u64,
    pub failed_waiting_embeddings: u64,
    pub message: Option<String>,
    pub next: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ArtifactCounts {
    pub total_chunks: u64,
    pub eligible_chunks: u64,
    pub skipped_chunks: u64,
    pub current: u64,
    pub missing: u64,
    pub stale: u64,
    pub failed: u64,
    pub blocked: u64,
    pub disabled: u64,
}

#[derive(Debug, Clone, Serialize)]
pub struct LastReconcileStatus {
    pub started_at_ms: i64,
    pub finished_at_ms: Option<i64>,
    pub batch_size: u64,
    pub processed_chunks: u64,
    pub embeddings_written: u64,
    pub blocked_chunks: u64,
    pub elapsed_ms: u64,
    pub input_chars: u64,
    pub chunks_per_sec: f64,
    pub chars_per_sec: f64,
    pub status: String,
    pub message: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ModelInfo {
    pub model_id: String,
    pub capability: String,
    pub embedding_dim: Option<i64>,
    pub runtime: String,
    pub installed: bool,
    pub disabled: bool,
    pub status: String,
    pub installed_at_ms: Option<i64>,
    pub last_error: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ReconcileReport {
    pub processed_chunks: u64,
    pub embeddings_written: u64,
    pub skipped_chunks: u64,
    pub failed_chunks: u64,
    pub blocked_chunks: u64,
    pub model_id: String,
    pub model_version: String,
    pub embedding_dim: usize,
    pub batch_size: usize,
    pub max_embedding_chars: usize,
    pub forced: bool,
    pub changed_first: bool,
    pub until_clean: bool,
    pub max_seconds: Option<u64>,
    pub work_reasons: BTreeMap<String, u64>,
    pub skipped_by_policy: BTreeMap<String, u64>,
    pub input_chars: u64,
    pub truncated_inputs: u64,
    pub elapsed_ms: u64,
    pub chunks_per_sec: f64,
    pub chars_per_sec: f64,
    pub avg_chars_per_chunk: f64,
    pub status: String,
    pub message: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ReconcilePlan {
    pub embeddings: EmbeddingReconcilePlan,
    pub summaries: SummaryReconcilePlan,
}

#[derive(Debug, Clone, Serialize)]
pub struct EmbeddingReconcilePlan {
    pub model_id: String,
    pub model_version: String,
    pub dim: usize,
    pub available: bool,
    pub current: u64,
    pub missing: u64,
    pub stale: u64,
    pub model_changed: u64,
    pub dim_changed: u64,
    pub failed_retryable: u64,
    pub failed_waiting: u64,
    pub blocked: u64,
    pub disabled: u64,
    pub skipped_total: u64,
    pub skipped_by_policy: BTreeMap<String, u64>,
    pub missing_by_priority: BTreeMap<String, u64>,
    pub message: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct SummaryReconcilePlan {
    pub enabled: bool,
    pub message: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum ReconcileReason {
    Missing,
    SourceChanged,
    InputChanged,
    ModelChanged,
    DimChanged,
    RetryAfterFailure,
    Forced,
}

impl ReconcileReason {
    fn as_str(self) -> &'static str {
        match self {
            Self::Missing => "Missing",
            Self::SourceChanged => "SourceChanged",
            Self::InputChanged => "InputChanged",
            Self::ModelChanged => "ModelChanged",
            Self::DimChanged => "DimChanged",
            Self::RetryAfterFailure => "RetryAfterFailure",
            Self::Forced => "Forced",
        }
    }
}

#[derive(Debug, Clone)]
pub struct ReconcileOptions {
    pub limit: Option<u32>,
    pub batch_size: Option<u32>,
    pub force: bool,
    pub until_clean: bool,
    pub changed_first: bool,
    pub max_seconds: Option<u64>,
    pub max_embedding_chars: usize,
    /// ONNX Runtime intra-op thread cap (`ort_threads`). `None` lets the backend pick (all cores).
    pub intra_threads: Option<usize>,
    /// Whether this reconcile may PROVISION an ephemeral cookbook box for chunk embedding (#318).
    /// The explicit `rag-rat reconcile` (a deliberate bulk pass) sets this TRUE; the
    /// watcher/maintenance incremental pass sets it FALSE — a 2-chunk edit must never cold-start a
    /// GPU box. With an ephemeral active model and this FALSE, the chunk embed is SKIPPED (chunks
    /// stay pending; lexical search still works; query embedding uses the local box). Ignored for
    /// connect / local models.
    pub provision_remote: bool,
}

impl Default for ReconcileOptions {
    fn default() -> Self {
        Self {
            limit: None,
            batch_size: None,
            force: false,
            until_clean: false,
            changed_first: false,
            max_seconds: None,
            max_embedding_chars: DEFAULT_MAX_EMBEDDING_CHARS,
            intra_threads: None,
            // Default FALSE: only the explicit `rag-rat reconcile` opts into provisioning. The
            // watcher (which uses `..ReconcileOptions::default()`) must never provision.
            provision_remote: false,
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct EmbeddingPolicyDecision {
    pub policy: String,
    pub priority: i64,
    pub eligible: bool,
}

#[derive(Debug, Clone, Serialize)]
pub enum ReconcileProgress {
    Started {
        model_id: String,
        total_chunks: u64,
        batch_size: usize,
    },
    Batch {
        processed_chunks: u64,
        total_chunks: u64,
        embeddings_written: u64,
        failed_chunks: u64,
        blocked_chunks: u64,
    },
    Finished {
        processed_chunks: u64,
        embeddings_written: u64,
        failed_chunks: u64,
        blocked_chunks: u64,
    },
}

#[derive(Debug, Clone)]
pub(crate) struct CurrentChunk {
    id: i64,
    path: String,
    language: String,
    file_kind: String,
    chunk_kind: String,
    symbol_path: Option<String>,
    text: String,
    text_hash: String,
    embedding_status: Option<String>,
    source_text_hash: Option<String>,
    model_version: Option<String>,
    embedding_dim: Option<i64>,
    input_hash: Option<String>,
    embedding_text_version: Option<String>,
    next_retry_after_ms: Option<i64>,
    reason: ReconcileReason,
}

#[derive(Debug)]
pub(crate) struct PreparedEmbeddingJob {
    id: i64,
    text_hash: String,
    input_text: String,
    input_hash: String,
    input_chars: usize,
    input_truncated: bool,
    policy: String,
    priority: i64,
    reason: ReconcileReason,
}

pub(crate) struct SelectedBatch {
    jobs: Vec<PreparedEmbeddingJob>,
}

impl CurrentChunk {
    fn reason(
        &self,
        model_version: &str,
        dim: usize,
        now_ms: i64,
        _max_embedding_chars: usize,
    ) -> ReconcileReason {
        if self.reason == ReconcileReason::Forced {
            return ReconcileReason::Forced;
        }
        if self.embedding_status.is_none() {
            return ReconcileReason::Missing;
        }
        if self.source_text_hash.as_deref() != Some(self.text_hash.as_str()) {
            return ReconcileReason::SourceChanged;
        }
        if self.input_hash.as_deref().is_none_or(str::is_empty) {
            return ReconcileReason::InputChanged;
        }
        if self.model_version.as_deref() != Some(model_version)
            || self.embedding_text_version.as_deref() != Some(EMBEDDING_TEXT_VERSION)
        {
            return ReconcileReason::ModelChanged;
        }
        if self.embedding_dim != Some(i64::try_from(dim).unwrap_or(i64::MAX)) {
            return ReconcileReason::DimChanged;
        }
        if self.embedding_status.as_deref() == Some("Failed")
            && self.next_retry_after_ms.unwrap_or(0) <= now_ms
        {
            return ReconcileReason::RetryAfterFailure;
        }
        ReconcileReason::Missing
    }
}

/// The embedding-model identity for one reconcile run. Stable across every batch, so it
/// travels as a context struct rather than as repeated positional args (rust-modern-style:
/// separate context from per-call command).
pub(crate) struct EmbeddingScan<'a> {
    model_id: &'a str,
    model_version: &'a str,
    dim: usize,
    max_embedding_chars: usize,
}

pub(crate) struct EmbeddingInput {
    text: String,
    chars: usize,
    truncated: bool,
}

pub struct QueryEmbedding {
    pub model_id: String,
    pub dim: usize,
    pub vector: Vec<f32>,
}
