//! Contextual embedder-suggestion use cases (LOT C3, ARCHITECTURE §14.5.5).
//!
//! - [`CheckEmbedderSuggestion`] — the **best-effort** check run when an agent
//!   reads the project memory at activation: the *first* time a project's memory
//!   outgrows the recall budget while no embedder is configured (strategy `none`),
//!   it publishes a one-time [`DomainEvent::EmbedderSuggested`]. Anti-spam: at most
//!   once per session per project, and never again once the user chose
//!   [`EmbedderPromptDismissal::Never`].
//! - [`DismissEmbedderSuggestion`] — persists the user's "plus tard" / "ne plus
//!   demander" response.
//!
//! By construction this never blocks the activation flow: every error degrades to
//! "no suggestion", and a `none`-strategy/under-budget project is a cheap no-op.

use std::collections::HashSet;
use std::sync::{Arc, Mutex};

use domain::ports::{
    EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore,
    EventBus, MemoryStore,
};
use domain::profile::EmbedderStrategy;
use domain::{DomainEvent, ProjectId, ProjectPath};

use crate::error::AppError;

/// In-memory "already suggested this session" guard, shared with the composition
/// root (held in `AppState`). One [`ProjectId`] per project that has already seen
/// the suggestion since the IDE started. Plain std types so the application stays
/// dependency-free and the guard is trivially testable.
pub type SuggestedThisSession = Arc<Mutex<HashSet<ProjectId>>>;

/// Sums the approximate token size of the index, mirroring the infrastructure
/// `index_token_size` so the application does not depend on the infra crate. One
/// entry's payload is its rendered index line (title + hook + slug), and the rule
/// only needs a *monotone* measure consistent with the recall's budget unit
/// (~1 token / 4 chars), so we count characters / 4 (min 1 per entry).
fn index_token_size(entries: &[domain::MemoryIndexEntry]) -> usize {
    entries
        .iter()
        .map(|e| {
            let chars = e.title.len() + e.hook.len() + e.slug.as_str().len();
            (chars / 4).max(1)
        })
        .sum()
}

// ---------------------------------------------------------------------------
// CheckEmbedderSuggestion
// ---------------------------------------------------------------------------

/// Input for [`CheckEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionInput {
    /// The project whose memory is being read at agent activation.
    pub project_id: ProjectId,
    /// That project's root (where its `.ideai/memory/` lives).
    pub project_root: ProjectPath,
}

/// Output of [`CheckEmbedderSuggestion::execute`]: whether a suggestion event was
/// published on this call (useful for tests; the caller ignores it — the flow is
/// best-effort).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CheckEmbedderSuggestionOutput {
    /// `true` iff an [`DomainEvent::EmbedderSuggested`] was published.
    pub suggested: bool,
}

/// Publishes a one-time embedder suggestion the first time a project's memory
/// outgrows the recall budget while no embedder is configured (LOT C3).
///
/// Composes — each port only for its slice (Interface Segregation):
/// - [`EmbedderProfileStore`] to read the active strategy (no non-`none` profile ⇒
///   strategy `none`),
/// - [`MemoryStore`] to measure the memory index size,
/// - [`EmbedderPromptStore`] for the persistent dismissal (`never` ⇒ silence),
/// - [`EmbedderEnvInspector`] to enrich the event with the detected environment,
/// - [`EventBus`] to publish, plus the shared [`SuggestedThisSession`] guard.
///
/// **Never blocks the caller**: any port error degrades to "no suggestion".
pub struct CheckEmbedderSuggestion {
    profiles: Arc<dyn EmbedderProfileStore>,
    memories: Arc<dyn MemoryStore>,
    prompts: Arc<dyn EmbedderPromptStore>,
    inspector: Arc<dyn EmbedderEnvInspector>,
    events: Arc<dyn EventBus>,
    suggested_this_session: SuggestedThisSession,
    /// Recall token budget the memory must exceed (injected so it stays aligned
    /// with [`crate::AGENT_MEMORY_RECALL_BUDGET`] without re-hardcoding it).
    budget: usize,
    /// Compiled-in HTTP capability flag (carried into the event).
    vector_http_enabled: bool,
    /// Compiled-in ONNX capability flag (carried into the event).
    vector_onnx_enabled: bool,
}

impl CheckEmbedderSuggestion {
    /// Builds the use case from its ports + the session guard + the budget and
    /// compiled-capability flags (the latter injected at the composition root, the
    /// application stays infra-free).
    #[must_use]
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        profiles: Arc<dyn EmbedderProfileStore>,
        memories: Arc<dyn MemoryStore>,
        prompts: Arc<dyn EmbedderPromptStore>,
        inspector: Arc<dyn EmbedderEnvInspector>,
        events: Arc<dyn EventBus>,
        suggested_this_session: SuggestedThisSession,
        budget: usize,
        vector_http_enabled: bool,
        vector_onnx_enabled: bool,
    ) -> Self {
        Self {
            profiles,
            memories,
            prompts,
            inspector,
            events,
            suggested_this_session,
            budget,
            vector_http_enabled,
            vector_onnx_enabled,
        }
    }

    /// Whether any configured profile selects a non-`none` strategy. No profile (or
    /// a store error) ⇒ `none` (the dependency-free default posture).
    async fn strategy_is_none(&self) -> bool {
        match self.profiles.list().await {
            Ok(profiles) => !profiles
                .iter()
                .any(|p| p.strategy != EmbedderStrategy::None),
            // A store failure must not turn into a suggestion: behave as configured
            // (i.e. *not* `none`) so we stay silent.
            Err(_) => false,
        }
    }

    /// Runs the check best-effort.
    ///
    /// Order (each step short-circuits to "no suggestion"):
    /// 1. already suggested this session ⇒ stop (no I/O beyond the guard);
    /// 2. a non-`none` strategy is configured ⇒ stop;
    /// 3. the persisted dismissal is `never` ⇒ stop;
    /// 4. the memory index size does not exceed the budget ⇒ stop;
    /// 5. otherwise mark the session guard, probe the environment, and publish
    ///    [`DomainEvent::EmbedderSuggested`].
    ///
    /// # Errors
    /// Never in practice — the signature keeps a uniform `Result` with the other
    /// use cases; every failing port read degrades to `Ok(suggested: false)`.
    pub async fn execute(
        &self,
        input: CheckEmbedderSuggestionInput,
    ) -> Result<CheckEmbedderSuggestionOutput, AppError> {
        let not_suggested = Ok(CheckEmbedderSuggestionOutput { suggested: false });

        // 1. Once per session per project (cheap guard check before any I/O).
        if self
            .suggested_this_session
            .lock()
            .map(|set| set.contains(&input.project_id))
            .unwrap_or(true)
        {
            return not_suggested;
        }

        // 2. Only when no embedder is configured (strategy `none`).
        if !self.strategy_is_none().await {
            return not_suggested;
        }

        // 3. Persistent "ne plus demander" silences the suggestion forever.
        if matches!(
            self.prompts.read(&input.project_root).await,
            Ok(Some(EmbedderPromptDismissal::Never))
        ) {
            return not_suggested;
        }

        // 4. Memory must have outgrown the recall budget (a fresh project has 0).
        let size = match self.memories.read_index(&input.project_root).await {
            Ok(entries) => index_token_size(&entries),
            Err(_) => return not_suggested,
        };
        if size <= self.budget {
            return not_suggested;
        }

        // 5. Mark the guard *before* publishing so a concurrent activation cannot
        //    double-fire; if another thread won the race, stay silent.
        match self.suggested_this_session.lock() {
            Ok(mut set) => {
                if !set.insert(input.project_id) {
                    return not_suggested;
                }
            }
            Err(_) => return not_suggested,
        }

        let report = self.inspector.inspect().await;
        self.events.publish(DomainEvent::EmbedderSuggested {
            project_id: input.project_id,
            ollama_detected: report.ollama_detected,
            onnx_cached: report.onnx_cached_models,
            vector_http_enabled: self.vector_http_enabled,
            vector_onnx_enabled: self.vector_onnx_enabled,
        });
        Ok(CheckEmbedderSuggestionOutput { suggested: true })
    }
}

// ---------------------------------------------------------------------------
// DismissEmbedderSuggestion
// ---------------------------------------------------------------------------

/// The user's response to the embedder suggestion popup.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DismissChoice {
    /// "Plus tard" — re-proposable on a future session.
    Later,
    /// "Ne plus demander" — never again (persistent).
    Never,
}

impl From<DismissChoice> for EmbedderPromptDismissal {
    fn from(c: DismissChoice) -> Self {
        match c {
            DismissChoice::Later => Self::Later,
            DismissChoice::Never => Self::Never,
        }
    }
}

/// Input for [`DismissEmbedderSuggestion::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DismissEmbedderSuggestionInput {
    /// The project the suggestion concerned.
    pub project_root: ProjectPath,
    /// The user's choice.
    pub choice: DismissChoice,
}

/// Persists the user's dismissal of the embedder suggestion (LOT C3).
pub struct DismissEmbedderSuggestion {
    prompts: Arc<dyn EmbedderPromptStore>,
}

impl DismissEmbedderSuggestion {
    /// Builds the use case from the [`EmbedderPromptStore`] port.
    #[must_use]
    pub fn new(prompts: Arc<dyn EmbedderPromptStore>) -> Self {
        Self { prompts }
    }

    /// Writes the dismissal state. A `later` keeps the suggestion re-proposable on
    /// a future session; a `never` silences it for good.
    ///
    /// # Errors
    /// [`AppError::Store`] on a persistence failure.
    pub async fn execute(&self, input: DismissEmbedderSuggestionInput) -> Result<(), AppError> {
        self.prompts
            .write(&input.project_root, input.choice.into())
            .await?;
        Ok(())
    }
}
