import Foundation
import NucleicCore

/// Selectable model SKUs and effort levels, plus the persistence keys for the per-app
/// defaults applied to new chats. The chosen SKU also selects the backend
/// (`BackendID.forModel`), and each model exposes only the effort levels it supports.
enum ModelCatalog {
    /// Full model SKUs the picker offers (not friendly aliases). The chosen SKU also selects
    /// the backend (`BackendID.forModel`): the Claude SKUs run on Claude Code, the `gpt-*` SKUs
    /// on Codex (`codex app-server`), and the `grok-*` SKUs on Grok over ACP (`grok agent stdio`).
    static let models: [String] = [
        "claude-opus-4-8[1m]",
        "claude-opus-4-8",
        "claude-sonnet-4-6",
        "claude-haiku-4-5",
        "gpt-5.5",
        "gpt-5.4",
        "gpt-5.4-mini",
        "grok-build",
    ]
    /// All effort levels, ordered lowest → highest. A given model supports a *prefix* of
    /// these (see `efforts(for:)`); the picker should only ever offer the supported ones.
    static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"]

    /// Grok's reasoning levels. Grok Build exposes a single **"Auto"** reasoning mode (xAI picks
    /// the thinking depth itself), so the only API-level choice is `auto` — presented under the
    /// "Reasoning" noun (see `effortNoun`) and rendered "Auto" (`effortDisplayName`). The backend
    /// never passes a reasoning flag for Grok; the selection is cosmetic. (Orchestra is still
    /// offered below it in the menu — it's an orchestration mode, not a Grok reasoning level.)
    static let grokEfforts: [String] = ["auto"]

    /// The effort levels `sku` actually supports. Grok exposes only its single "Auto" reasoning
    /// (`grokEfforts`); Codex models top out at "xhigh" (confirmed from `codex model/list`:
    /// gpt-5.x supports low/medium/high/xhigh); Claude models support the full range incl. "max".
    static func efforts(for sku: String) -> [String] {
        if BackendID.forModel(sku) == .grok { return grokEfforts }
        guard let cap = effortCap(for: sku), let idx = efforts.firstIndex(of: cap) else {
            return efforts
        }
        return Array(efforts[...idx])
    }

    /// Highest effort `sku` supports, or `nil` for the full range.
    private static func effortCap(for sku: String) -> String? {
        BackendID.forModel(sku) == .codex ? "xhigh" : nil
    }

    /// Clamp `effort` to what `sku` supports, so switching to a model with a lower cap can't
    /// leave an unsupported level selected (e.g. "max" carried onto a Codex model → "xhigh").
    /// Orchestra is an orchestration mode, not an API level — valid for every model (it
    /// resolves to a supported effort, `xhigh`, on the host), so it's always preserved.
    static func clampedEffort(_ effort: String, for sku: String) -> String {
        if isOrchestra(effort) { return effort }
        let supported = efforts(for: sku)
        return supported.contains(effort) ? effort : (supported.last ?? fallbackEffort)
    }

    /// The reasoning control's noun in the composer: Codex and Grok call it "Reasoning" (both
    /// expose a `reasoning_effort`-style thinking level), Claude "Effort".
    static func effortNoun(for sku: String) -> String {
        switch BackendID.forModel(sku) {
        case .codex, .grok: return "Reasoning"
        default: return "Effort"
        }
    }

    /// The picker's SKUs split into per-provider runs, preserving `models` order, so the
    /// composer's model menu can set each provider apart with a divider (Claude, then GPT,
    /// then Grok). Providers are contiguous in `models`, so a new group starts whenever the
    /// inferred backend changes (`BackendID.forModel`).
    static var modelGroups: [[String]] {
        var groups: [[String]] = []
        var current: [String] = []
        var currentBackend: BackendID?
        for sku in models {
            let backend = BackendID.forModel(sku) ?? .claudeCode
            if backend != currentBackend, !current.isEmpty {
                groups.append(current)
                current = []
            }
            currentBackend = backend
            current.append(sku)
        }
        if !current.isEmpty { groups.append(current) }
        return groups
    }

    /// The SKUs that run on `backend` — for the in-session model menu, where the backend is
    /// fixed at creation and can't change. (The home composer offers all SKUs, since the model
    /// chooses the backend.)
    static func models(for backend: BackendID) -> [String] {
        models.filter { sku in
            let modelBackend = BackendID.forModel(sku) ?? .claudeCode
            switch backend {
            case .claudeCode: return modelBackend == .claudeCode
            case .codex, .codexExec: return modelBackend == .codex
            case .grok: return modelBackend == .grok
            }
        }
    }

    /// The "orchestra" orchestration mode (`OrchestrationMode`). It rides in the effort menu
    /// like a level, but it isn't an API effort — it pairs `xhigh` with standing consent to
    /// fan work out to parallel subagents. Kept out of `efforts` (the canonical API levels) so
    /// it can be presented separately, below a divider, with its own purple treatment.
    static let orchestraEffort = OrchestrationMode.effortSentinel

    /// Whether `effort` selects orchestra.
    static func isOrchestra(_ effort: String?) -> Bool { OrchestrationMode.isOrchestra(effort) }

    /// A pretty, human label for an effort level — orchestra reads "Orchestra"; Grok's single
    /// reasoning mode reads "Auto" (a named mode, like Orchestra, not a generic level); the plain
    /// API levels keep their lowercase word (matching how they're shown in the menu today).
    static func effortDisplayName(_ effort: String) -> String {
        if isOrchestra(effort) { return "Orchestra" }
        if effort == "auto" { return "Auto" }
        return effort
    }

    /// One-line description of what orchestra does, for the menu item's help tooltip.
    static let orchestraBlurb =
        "Orchestra: maximum-effort orchestration. Runs at xhigh and lets the agent fan work "
        + "out to parallel subagents on its own for thorough, verified results (uses more tokens)."

    /// Tooltip shown once Orchestra is on for a chat: it's a one-way latch and can't be turned off,
    /// so the other effort levels are disabled (the host enforces this in `SessionController`).
    static let orchestraLockedHelp =
        "Orchestra is on for this chat and can't be turned off — start a new chat to use a "
        + "different effort level."

    /// Why Orchestra may be unavailable: it's a Nucleic Control capability (the hardened,
    /// sandboxed path), so it's only offered for projects under Nucleic Control. Shown as the
    /// grayed annotation on the disabled menu item and as its tooltip.
    static let orchestraRequiresControlNote = "Requires Nucleic Control"

    /// Full explanation for the requirement tooltip.
    static let orchestraRequiresControlHelp =
        "Orchestra is a Nucleic Control capability — clone or move this project under Nucleic "
        + "Control to enable it."

    /// A pretty, human label for a model SKU (e.g. "claude-sonnet-4-6" → "Sonnet
    /// 4.6"). The two Opus 4.8 SKUs share the name "Opus 4.8"; their context window
    /// is conveyed by the picker badge (see `contextBadge`). Falls back to a
    /// best-effort prettifier for unknown SKUs.
    static func displayName(_ sku: String) -> String {
        switch sku {
        case "claude-opus-4-8[1m]": return "Opus 4.8"
        case "claude-opus-4-8": return "Opus 4.8"
        case "claude-sonnet-4-6": return "Sonnet 4.6"
        case "claude-haiku-4-5": return "Haiku 4.5"
        case "gpt-5.5": return "GPT-5.5"
        case "gpt-5.4": return "GPT-5.4"
        case "gpt-5.4-mini": return "GPT-5.4 Mini"
        case "grok-build": return "Grok Build"
        default: return prettify(sku)
        }
    }

    private static func prettify(_ sku: String) -> String {
        var stem = sku
        if stem.hasPrefix("claude-") { stem.removeFirst("claude-".count) }
        var suffix = ""
        if let open = stem.firstIndex(of: "["), let close = stem.firstIndex(of: "]") {
            suffix = " (\(stem[stem.index(after: open)..<close].uppercased()))"  // [1m] → (1M)
            stem = String(stem[..<open])
        }
        let parts = stem.split(separator: "-")
        guard let family = parts.first else { return sku }
        let familyName = family.prefix(1).uppercased() + family.dropFirst()
        let version = parts.dropFirst().joined(separator: ".")
        return version.isEmpty ? "\(familyName)\(suffix)" : "\(familyName) \(version)\(suffix)"
    }

    static let fallbackModel = "claude-opus-4-8[1m]"
    static let fallbackEffort = "high"

    /// Per-provider default model + effort, **highest priority first**, used to pick a new-chat
    /// default the user can actually run. The first provider the user has connected wins: Claude
    /// → Opus 4.8 (1M) at high effort, then Codex → GPT-5.5 at medium, then Grok → Grok Build at
    /// "auto" (its only reasoning mode). Consulted by `resolvedDefault` when the stored default's
    /// backend isn't connected — so the default is never a model on a provider the user lacks.
    static let providerDefaults: [(backend: BackendID, model: String, effort: String)] = [
        (.claudeCode, "claude-opus-4-8[1m]", "high"),
        (.codex, "gpt-5.5", "medium"),
        (.grok, "grok-build", "auto"),
    ]

    /// A comparable model on the *other* provider, for status-driven failover: each SKU pairs
    /// with its nearest-tier counterpart across the Claude ↔ GPT line, so when one provider
    /// reports an incident the app can offer a like-for-like default on the other (Opus ↔
    /// GPT-5.5, Sonnet ↔ GPT-5.4, Haiku ↔ GPT-5.4 Mini). Nil for an unrecognized SKU.
    static func crossProviderAlternative(for sku: String) -> String? {
        switch sku {
        case "claude-opus-4-8[1m]", "claude-opus-4-8": return "gpt-5.5"
        case "claude-sonnet-4-6": return "gpt-5.4"
        case "claude-haiku-4-5": return "gpt-5.4-mini"
        case "gpt-5.5": return "claude-opus-4-8[1m]"
        case "gpt-5.4": return "claude-sonnet-4-6"
        case "gpt-5.4-mini": return "claude-haiku-4-5"
        default: return nil
        }
    }

    /// Approximate input context-window size (tokens) for a model SKU, used by the
    /// composer's session context-usage indicator. The `[1m]` variant gets the 1M
    /// window; standard Opus 4.8 gets 256K; every other Claude model uses 200K.
    static func contextWindow(for sku: String) -> Int {
        if sku.contains("[1m]") { return 1_000_000 }
        if sku.hasPrefix("claude-opus-4-8") { return 256_000 }
        if sku.hasPrefix("gpt-5") { return 350_000 }  // codex gpt-5.x window (~353K observed)
        if sku.hasPrefix("grok") { return 256_000 }  // grok-build window (inferred)
        return 200_000
    }

    /// Short, grayed annotation shown beside a SKU in the model picker to call out
    /// its context window — "1M" for the `[1m]` Opus 4.8 and "256K" for the
    /// standard one — so the two same-named Opus entries are distinguishable. Nil
    /// for other models.
    static func contextBadge(for sku: String) -> String? {
        switch sku {
        case "claude-opus-4-8[1m]": return "1M"
        case "claude-opus-4-8": return "256K"
        default: return nil
        }
    }

    static let defaultModelKey = "nucleic.defaultModel"
    static let defaultEffortKey = "nucleic.defaultEffort"
    static let defaultAutoKey = "nucleic.defaultAuto"
    static let defaultAutoShipKey = "nucleic.defaultAutoShip"

    static var storedDefaultModel: String {
        UserDefaults.standard.string(forKey: defaultModelKey) ?? fallbackModel
    }
    static var storedDefaultEffort: String {
        UserDefaults.standard.string(forKey: defaultEffortKey) ?? fallbackEffort
    }
    static var storedDefaultAuto: Bool {
        UserDefaults.standard.bool(forKey: defaultAutoKey)
    }
    static var storedDefaultAutoShip: Bool {
        UserDefaults.standard.bool(forKey: defaultAutoShipKey)
    }

    /// The default `(model, effort)` a new chat should use given which provider backends are
    /// **connected** (installed *and* authenticated — see `ProviderAvailability`). Honors the
    /// stored default when its backend is connected, so an explicit choice still wins; otherwise
    /// falls through `providerDefaults` (Claude → Codex → Grok) to the highest-priority connected
    /// provider, so the default is never a model whose backend the user can't reach. With nothing
    /// connected, keeps the stored/`fallback` default so the picker always has *a* selection.
    static func resolvedDefault(connected: Set<BackendID>) -> (model: String, effort: String) {
        let storedModel = storedDefaultModel
        if let backend = BackendID.forModel(storedModel), connected.contains(backend) {
            return (storedModel, clampedEffort(storedDefaultEffort, for: storedModel))
        }
        if let pick = providerDefaults.first(where: { connected.contains($0.backend) }) {
            return (pick.model, pick.effort)
        }
        return (storedModel, storedDefaultEffort)
    }

    /// The wire projection sent to paired phones (SYNC §5.2) so their composer/header pickers
    /// mirror this catalog — same per-backend effort caps, context windows, and grouping —
    /// without re-deriving any backend rules on-device. Built from this single source of truth.
    static var wireCatalog: WireModelCatalog {
        let groups: [[WireModelCatalog.Model]] = modelGroups.map { group in
            group.map { sku in
                WireModelCatalog.Model(
                    sku: sku,
                    displayName: displayName(sku),
                    backend: BackendID.forModel(sku) ?? .claudeCode,
                    contextBadge: contextBadge(for: sku),
                    contextWindow: contextWindow(for: sku),
                    efforts: efforts(for: sku),
                    effortNoun: effortNoun(for: sku))
            }
        }
        // Pretty labels only for the non-identity effort levels (e.g. "auto" → "Auto") plus the
        // orchestra sentinel; the phone shows the raw level for everything else.
        var effortNames: [String: String] = [:]
        for sku in models {
            for level in efforts(for: sku) where effortDisplayName(level) != level {
                effortNames[level] = effortDisplayName(level)
            }
        }
        effortNames[orchestraEffort] = effortDisplayName(orchestraEffort)  // "Orchestra"
        return WireModelCatalog(
            groups: groups,
            effortDisplayNames: effortNames,
            orchestraSentinel: orchestraEffort,
            orchestraRequiresControlNote: orchestraRequiresControlNote,
            fallbackModel: fallbackModel,
            fallbackEffort: fallbackEffort)
    }
}


/// How long a *completed* chat may sit untouched before Nucleic auto-archives it.
/// Persisted as the raw seconds value; `.never` (0) turns auto-archiving off. The
/// `AppStore` only ever sees the resolved `interval`, keeping the core unaware of the
/// UI's specific choices.
enum AutoArchivePolicy: Int, CaseIterable, Identifiable {
    case never = 0
    case after30Minutes = 1800
    case afterHour = 3600
    case after3Hours = 10800
    case afterDay = 86_400
    case afterWeek = 604_800

    var id: Int { rawValue }

    var label: String {
        switch self {
        case .never: "Never"
        case .after30Minutes: "After 30 minutes"
        case .afterHour: "After 1 hour"
        case .after3Hours: "After 3 hours"
        case .afterDay: "After 1 day"
        case .afterWeek: "After 1 week"
        }
    }

    /// The bare duration phrase used in explanatory copy ("30 minutes", "a week").
    var durationPhrase: String {
        switch self {
        case .never: ""
        case .after30Minutes: "30 minutes"
        case .afterHour: "an hour"
        case .after3Hours: "3 hours"
        case .afterDay: "a day"
        case .afterWeek: "a week"
        }
    }

    /// Idle threshold in seconds the `AppStore` sweeps against, or nil to disable.
    var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) }

    static let storageKey = "nucleic.autoArchivePolicy"
    /// Out of the box, completed chats archive after a day; the user can shorten it,
    /// lengthen it, or pick "Never" in Settings.
    static let fallback = AutoArchivePolicy.afterDay

    static var stored: AutoArchivePolicy {
        // No stored key yet → the default behavior; `.never` is a real stored 0.
        guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback }
        return AutoArchivePolicy(rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback
    }
}


/// How long a chat may stay *archived* before Nucleic reclaims its worktree checkout to free
/// disk. The branch — and any uncommitted work, committed as a WIP commit first — is kept, so
/// unarchiving the chat re-creates the worktree exactly. Persisted as the raw seconds value;
/// `.never` (0) turns cleanup off. Like `AutoArchivePolicy`, the `AppStore` only ever sees the
/// resolved `interval`, keeping the core unaware of the UI's specific choices.
enum ArchivedWorktreeCleanupPolicy: Int, CaseIterable, Identifiable {
    case never = 0
    case afterHour = 3600
    case afterDay = 86_400
    case after3Days = 259_200
    case afterWeek = 604_800
    case afterMonth = 2_592_000

    var id: Int { rawValue }

    var label: String {
        switch self {
        case .never: "Never"
        case .afterHour: "After 1 hour"
        case .afterDay: "After 1 day"
        case .after3Days: "After 3 days"
        case .afterWeek: "After 1 week"
        case .afterMonth: "After 1 month"
        }
    }

    /// The bare duration phrase used in explanatory copy ("an hour", "a month").
    var durationPhrase: String {
        switch self {
        case .never: ""
        case .afterHour: "an hour"
        case .afterDay: "a day"
        case .after3Days: "3 days"
        case .afterWeek: "a week"
        case .afterMonth: "a month"
        }
    }

    /// Threshold in seconds the `AppStore` sweeps against, or nil to disable.
    var interval: TimeInterval? { self == .never ? nil : TimeInterval(rawValue) }

    static let storageKey = "nucleic.archivedWorktreeCleanupPolicy"
    /// Out of the box, an archived chat's worktree is reclaimed after a day; the user can
    /// shorten it, lengthen it, or pick "Never" in Settings.
    static let fallback = ArchivedWorktreeCleanupPolicy.afterDay

    static var stored: ArchivedWorktreeCleanupPolicy {
        // No stored key yet → the default behavior; `.never` is a real stored 0.
        guard UserDefaults.standard.object(forKey: storageKey) != nil else { return fallback }
        return ArchivedWorktreeCleanupPolicy(
            rawValue: UserDefaults.standard.integer(forKey: storageKey)) ?? fallback
    }
}
