import Foundation
import NucleicCore
import Observation

/// Classifies the composer's draft prompt as the user types, so the Intelligence slider's
/// router already knows the purpose by the time they hit send. One instance per composer.
///
/// Layering (the accuracy/cost contract):
/// 1. `HeuristicPurposeClassifier` — a HIGH-confidence keyword verdict is final; no model.
/// 2. `PurposeMLClassifier` — the bundled task-specific Core ML model; accepted when
///    calibrated HIGH/MEDIUM, or LOW-but-agreeing with the heuristic guardrail.
/// 3. `AppleIntelligenceProvider.classifyPromptPurpose` — AFM guided generation; accepted
///    when confident (or LOW-but-agreeing). Returns the heuristic verdict otherwise.
/// 4. One-shot small-SKU escalation (`DelegatedIntelligenceProvider.agent()`, `claude -p`
///    on haiku) — only when the AFM layer couldn't or wouldn't answer.
/// 5. The heuristic's best guess (ultimately `PurposeVerdict.general`, whose routing row
///    is deliberately mid-tier).
///
/// Latency contract: classification is *debounced draft work* — the send path never blocks
/// on a model. `resolveForSend` returns a cached verdict instantly, grace-waits at most
/// 400 ms on an in-flight classification, and otherwise answers with the synchronous
/// heuristic (<1 ms).
@MainActor
@Observable
final class PromptPurposeService {
    /// The newest verdict for the current draft — drives the resolved chip. Nil until the
    /// first classification lands (the chip shows the `general` resolution meanwhile).
    private(set) var verdict: PurposeVerdict?

    /// The primary (AFM-backed) provider. A value, not a factory: the provider itself reads
    /// `IntelligenceChoice.current` live, so a mid-session Settings change still applies.
    private let intelligence: any IntelligenceProviding
    /// The local task-specific Core ML layer. Nil disables it (primarily for tests); the
    /// production actor also honors the defaults-backed kill switch and model pin.
    private let modelClassifier: (any PurposeModelClassifying)?
    /// The escalation provider — nil disables Layer 4 (tests, or a Mac with no agent CLI;
    /// the call itself also degrades to nil harmlessly if the CLI is missing).
    private let escalation: (any IntelligenceProviding)?

    /// Verdicts keyed by normalized draft hash, so backspacing to a prior draft (or the
    /// send following the last debounce tick) never re-classifies. Bounded — drafts churn.
    private var cache: [Int: PurposeVerdict] = [:]
    private var cacheOrder: [Int] = []
    private var inFlight: Task<PurposeVerdict, Never>?
    private var inFlightHash: Int?

    /// Drafts shorter than this carry too little signal to spend a model call on — the
    /// heuristic (which handles the "fix typo"-sized prompts well) answers alone.
    private static let minModelLength = 12
    /// How long the send path is willing to wait on an in-flight classification. Users
    /// pause before hitting send, so this rarely triggers — and `createSession` does its
    /// own async work after, so the wait is imperceptible when it does.
    private static let sendGraceSeconds: TimeInterval = 0.4

    init(
        intelligence: any IntelligenceProviding,
        modelClassifier: (any PurposeModelClassifying)? = PurposeMLClassifier.shared,
        escalation: (any IntelligenceProviding)? = DelegatedIntelligenceProvider.agent()
    ) {
        self.intelligence = intelligence
        self.modelClassifier = modelClassifier
        self.escalation = escalation
    }

    /// Called from the composer's debounced `.task(id:)` whenever the draft has materially
    /// changed. Cheap on cache hits; otherwise kicks off (and publishes) a classification.
    func draftChanged(_ text: String) async {
        let normalized = Self.normalize(text)
        guard !normalized.isEmpty else {
            verdict = nil
            return
        }
        let hash = normalized.hashValue
        if let cached = cache[hash] {
            verdict = cached
            return
        }
        if inFlightHash == hash { return }  // already classifying this exact draft
        inFlight?.cancel()
        inFlightHash = hash
        let task = Task { [intelligence, modelClassifier, escalation] in
            await Self.classifyLayered(
                normalized, modelClassifier: modelClassifier,
                intelligence: intelligence, escalation: escalation)
        }
        inFlight = task
        let result = await task.value
        guard !Task.isCancelled, inFlightHash == hash else { return }
        remember(hash: hash, verdict: result)
        verdict = result
        inFlight = nil
        inFlightHash = nil
    }

    /// The verdict to route the send on — never blocks perceptibly (see latency contract).
    func resolveForSend(_ text: String) async -> PurposeVerdict {
        let normalized = Self.normalize(text)
        guard !normalized.isEmpty else { return .general }
        let hash = normalized.hashValue
        if let cached = cache[hash] { return cached }
        if let task = inFlight, inFlightHash == hash {
            if let result = await withTimeout(Self.sendGraceSeconds, { await task.value }) {
                remember(hash: hash, verdict: result)
                return result
            }
        }
        // No classification in time — the deterministic layer answers instantly.
        return HeuristicPurposeClassifier.classify(normalized)
    }

    // MARK: - Internals

    /// Layers 0–4. Kept internal so the app tests can pin the acceptance/fallback contract
    /// without loading Core ML or waiting through the composer's debounce.
    static func classifyLayered(
        _ text: String,
        modelClassifier: (any PurposeModelClassifying)?,
        intelligence: any IntelligenceProviding,
        escalation: (any IntelligenceProviding)?
    ) async -> PurposeVerdict {
        let heuristic = HeuristicPurposeClassifier.classify(text)
        if heuristic.confidence == .high { return heuristic }
        guard text.count >= minModelLength else {
            return heuristic
        }
        if let local = await modelClassifier?.classify(prompt: text),
           local.confidence >= .medium || local.purpose == heuristic.purpose {
            return local
        }
        let primary = await intelligence.classifyPromptPurpose(prompt: text)
        // A model answer, or an unambiguous heuristic one, is final.
        if primary.source != .heuristic || primary.confidence == .high { return primary }
        // The AFM layer couldn't (off/unavailable) or wouldn't (low + disagreeing) answer —
        // one bounded small-SKU shot before settling for the uncertain heuristic.
        if let escalation {
            let escalated = await escalation.classifyPromptPurpose(prompt: text)
            if escalated.source == .agent { return escalated }
        }
        return primary
    }

    private func remember(hash: Int, verdict: PurposeVerdict) {
        if cache[hash] == nil {
            cacheOrder.append(hash)
            if cacheOrder.count > 8 { cache[cacheOrder.removeFirst()] = nil }
        }
        cache[hash] = verdict
    }

    /// Whitespace-collapsed draft text, so a re-wrap or trailing newline doesn't read as a
    /// materially different prompt. The composer keys its debounce `.task(id:)` on the same
    /// normalization (`normalizedHash`).
    private static func normalize(_ text: String) -> String {
        text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
    }

    /// The debounce key for the composer's `.task(id:)`.
    static func normalizedHash(_ text: String) -> Int { normalize(text).hashValue }
}
