import Foundation

/// One `available` row of `sks bridge subagent-models list --json`: an
/// model the current connection's catalog knows.
struct SubagentModelOption: Equatable {
    let publicId: String
    let displayName: String
    /// false for a saved or drafted model the catalog no longer lists.
    var listed = true
    var reasoningEfforts: [String]? = nil

    var menuTitle: String {
        guard listed else { return "\(publicId)  ·  not in the current catalog" }
        return displayName == publicId ? publicId : "\(displayName)  ·  \(publicId)"
    }
}

struct SubagentModelSettings: Equatable {
    let profile: String
    let configured: Bool
    let editable: Bool
    let models: [SubagentModelEntry]
    let error: String?
    let warnings: [String]

    var title: String {
        profile == "codex_lb" ? "Codex-LB" : profile == "openai" ? "OpenAI OAuth" : "OpenRouter Only"
    }

    static func decode(_ value: Any) -> SubagentModelSettings? {
        guard let raw = value as? [String: Any],
              raw["schema"] as? String == "sks.subagent-model-settings.v1",
              let profile = raw["profile"] as? String, ["openrouter_only", "codex_lb", "openai"].contains(profile),
              let configured = raw["configured"] as? Bool,
              let editable = raw["editable"] as? Bool,
              let rows = raw["subagent_models"] as? [Any] else { return nil }
        let models = rows.compactMap(SubagentModelEntry.decode)
        guard models.count == rows.count else { return nil }
        return SubagentModelSettings(profile: profile, configured: configured, editable: editable,
            models: models, error: OpenRouterOnlyJSON.text(raw["error"]), warnings: OpenRouterOnlyJSON.strings(raw["warnings"]))
    }
}

/// `subagent-models.list` / `subagent-models.set` answers: the mode with its
/// saved list, and (when reported) the models the row popups can offer.
struct SubagentModelsSnapshot: Equatable {
    static let operations: Set<String> = ["subagent-models.list", "subagent-models.set"]

    let mode: OpenRouterOnlyState
    /// nil when the answer did not include `available` (keep the previous list).
    let available: [SubagentModelOption]?
    let settings: SubagentModelSettings?

    var profile: String { settings?.profile ?? "openrouter_only" }
    var models: [SubagentModelEntry] { settings?.models ?? mode.subagentModels }
    var editable: Bool { settings?.editable ?? mode.enabled }
    /// Older CLIs only have the OpenRouter list and do not accept a profile binding.
    var boundProfile: String? { settings?.profile }

    static func decode(_ payload: [String: Any]) -> SubagentModelsSnapshot? {
        guard payload["schema"] as? String == OpenRouterOnlyCommand.resultSchema,
              let operation = payload["operation"] as? String, operations.contains(operation),
              let mode = OpenRouterOnlyState.decode(payload) else { return nil }
        let result = payload["result"] as? [String: Any]
        let settings = result?["subagent_model_settings"].flatMap(SubagentModelSettings.decode)
        if result?["subagent_model_settings"] != nil && settings == nil { return nil }
        var seen = Set<String>()
        let available = (result?["available"] as? [Any]).map { rows in
            rows.compactMap { value -> SubagentModelOption? in
                guard let row = value as? [String: Any],
                      let id = OpenRouterOnlyJSON.text(row["public_id"]),
                      SubagentModelRules.isModelId(id, profile: settings?.profile ?? "openrouter_only"),
                      seen.insert(id.lowercased()).inserted else { return nil }
                return SubagentModelOption(publicId: id, displayName: OpenRouterOnlyJSON.text(row["display_name"]) ?? id,
                    reasoningEfforts: (row["reasoning_efforts"] as? [Any]).map { OpenRouterOnlyJSON.strings($0) })
            }
        }
        return SubagentModelsSnapshot(mode: mode, available: available, settings: settings)
    }
}

/// Pure edits of the unapplied list. The page keeps one draft array and
/// always sends the complete list, never a patch.
enum SubagentModelDraft {
    static let effortTitles = ["Default", "low", "medium", "high", "xhigh"]

    static func effortTitles(for entry: SubagentModelEntry, available: [SubagentModelOption], profile: String) -> [String] {
        let supported = available.first { $0.publicId == entry.model }?.reasoningEfforts
            ?? (profile == "openrouter_only" ? SubagentModelRules.efforts : SubagentModelRules.nativeEfforts)
        var titles = ["Default"] + supported.filter { SubagentModelRules.nativeEfforts.contains($0) }
        if let effort = entry.reasoningEffort, !titles.contains(effort) { titles.append(effort) }
        return titles
    }

    /// Popup rows: every available model, then saved or drafted models the CLI
    /// no longer lists, so a popup never misstates a choice.
    static func popupOptions(available: [SubagentModelOption], entries: [SubagentModelEntry]) -> [SubagentModelOption] {
        var seen = Set(available.map { $0.publicId.lowercased() })
        let missing = entries.filter { seen.insert($0.model.lowercased()).inserted }
            .map { SubagentModelOption(publicId: $0.model, displayName: $0.model, listed: false) }
        return available + missing
    }

    static func entryKey(_ entry: SubagentModelEntry) -> String {
        entry.model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + "|" + (entry.reasoningEffort ?? "")
    }

    /// Prefer a new model, then an unused effort of an existing model.
    static func adding(_ entries: [SubagentModelEntry], available: [SubagentModelOption]) -> [SubagentModelEntry]? {
        guard entries.count < SubagentModelRules.maxModels else { return nil }
        let used = Set(entries.map { $0.model.lowercased() })
        if let next = available.first(where: { !used.contains($0.publicId.lowercased()) }) {
            return entries + [SubagentModelEntry(model: next.publicId, criteria: "", reasoningEffort: nil, isDefault: entries.isEmpty, routable: nil)]
        }
        let keys = Set(entries.map(entryKey))
        for model in available {
            let efforts: [String?] = [nil] + (model.reasoningEfforts ?? SubagentModelRules.efforts).map { Optional($0) }
            for effort in efforts {
                let next = SubagentModelEntry(model: model.publicId, criteria: "", reasoningEffort: effort, isDefault: entries.isEmpty, routable: nil)
                if !keys.contains(entryKey(next)) { return entries + [next] }
            }
        }
        return nil
    }

    static func removing(_ entries: [SubagentModelEntry], at index: Int) -> [SubagentModelEntry] {
        guard entries.indices.contains(index) else { return entries }
        var next = entries
        next.remove(at: index)
        return withOneDefault(next)
    }

    static func settingDefault(_ entries: [SubagentModelEntry], at index: Int) -> [SubagentModelEntry] {
        guard entries.indices.contains(index) else { return entries }
        return entries.enumerated().map { offset, entry in
            var copy = entry
            copy.isDefault = offset == index
            return copy
        }
    }

    /// Same rule as the CLI: the first default wins, else the first row.
    static func withOneDefault(_ entries: [SubagentModelEntry]) -> [SubagentModelEntry] {
        let first = entries.firstIndex(where: \.isDefault) ?? 0
        return entries.enumerated().map { offset, entry in
            var copy = entry
            copy.isDefault = offset == first
            return copy
        }
    }

    static func effort(title: String?) -> String? {
        guard let title, SubagentModelRules.nativeEfforts.contains(title) else { return nil }
        return title
    }

    /// The CLI caps criteria at 240 UTF-16 units (JavaScript `slice`). Capping
    /// whole characters to the same budget keeps its slice a no-op, so the
    /// saved text reads back exactly. While typing, spaces are kept.
    static func limitCriteria(_ text: String) -> String {
        let limit = SubagentModelRules.maxCriteriaCharacters
        guard text.utf16.count > limit else { return text }
        var kept = ""
        for character in text {
            guard kept.utf16.count + String(character).utf16.count <= limit else { break }
            kept.append(character)
        }
        return kept
    }

    /// JavaScript `\s` plus the C0 controls and DEL the CLI folds into spaces.
    private static let criteriaSeparators: CharacterSet = {
        var set = CharacterSet(charactersIn: "\u{0}"..."\u{20}")
        set.insert(charactersIn: "\u{7F}\u{A0}\u{1680}\u{2028}\u{2029}\u{202F}\u{205F}\u{3000}\u{FEFF}")
        set.insert(charactersIn: "\u{2000}"..."\u{200A}")
        return set
    }()

    /// Same cleanup as the CLI's cleanCriteria: controls and whitespace runs
    /// become one space, then trim and cap.
    static func cleanCriteria(_ text: String) -> String {
        let collapsed = text.components(separatedBy: criteriaSeparators)
            .filter { !$0.isEmpty }
            .joined(separator: " ")
        return limitCriteria(collapsed)
    }

    static func normalizedForSubmit(_ entries: [SubagentModelEntry]) -> [SubagentModelEntry] {
        withOneDefault(entries.map { entry in
            var copy = entry.submitted
            copy.model = entry.model.trimmingCharacters(in: .whitespacesAndNewlines)
            copy.criteria = cleanCriteria(entry.criteria)
            return copy
        })
    }

    /// Local mirror of normalizeSubagentModelList; codes carry the row index.
    static func issues(_ entries: [SubagentModelEntry], profile: String = "openrouter_only", available: [SubagentModelOption]? = nil) -> [String] {
        var seen = Set<String>()
        var issues: [String] = []
        for (index, entry) in entries.enumerated() {
            let model = entry.model.trimmingCharacters(in: .whitespacesAndNewlines)
            let efforts = profile == "openrouter_only" ? SubagentModelRules.efforts : SubagentModelRules.nativeEfforts
            if !SubagentModelRules.isModelId(model, profile: profile) {
                issues.append("subagent_model_id_invalid:\(index)")
            } else if !seen.insert(entryKey(entry)).inserted {
                issues.append("subagent_model_duplicate:\(index)")
            } else if let effort = entry.reasoningEffort, !efforts.contains(effort) {
                issues.append("subagent_model_effort_invalid:\(index)")
            } else if profile != "openrouter_only", let available {
                if let option = available.first(where: { $0.publicId == model }) {
                    if let effort = entry.reasoningEffort, let supported = option.reasoningEfforts, !supported.contains(effort) {
                        issues.append("subagent_model_effort_unsupported:\(index)")
                    }
                } else { issues.append("subagent_model_not_available:\(index)") }
            }
            if entry.criteriaUnreadable {
                issues.append("subagent_model_criteria_redacted:\(index)")
            } else if index >= SubagentModelRules.maxModels {
                issues.append("subagent_model_list_too_long:\(index)")
            }
        }
        return issues
    }

    /// A redacted answer row takes the text this page already holds when that
    /// text redacts to exactly the answer; any other redacted row stays
    /// unreadable, and `issues` keeps it from being applied.
    static func restoringCriteria(_ incoming: [SubagentModelEntry], known: [SubagentModelEntry], redact: (String) -> String) -> [SubagentModelEntry] {
        incoming.map { entry in
            guard entry.criteriaUnreadable, let match = known.first(where: {
                entryKey($0) == entryKey(entry) && !$0.criteriaUnreadable
                    && (redact(cleanCriteria($0.criteria)) == entry.criteria || fitsRedacted(cleanCriteria($0.criteria), answer: entry.criteria))
            }) else { return entry }
            var copy = entry
            copy.criteria = cleanCriteria(match.criteria)
            return copy
        }
    }

    /// The CLI and Control Center redact with different patterns; either way
    /// each "[redacted]" stands for some text the known criteria must supply.
    static func fitsRedacted(_ text: String, answer: String) -> Bool {
        let marker = NSRegularExpression.escapedPattern(for: "[redacted]")
        let pattern = "^" + NSRegularExpression.escapedPattern(for: answer).replacingOccurrences(of: marker, with: ".+?") + "$"
        guard answer.contains("[redacted]"), let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return false }
        return regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil
    }

    static func isDirty(draft: [SubagentModelEntry], saved: [SubagentModelEntry]) -> Bool {
        normalizedForSubmit(draft) != normalizedForSubmit(saved)
    }

    /// stdin for `bridge subagent-models set --stdin --json`.
    static func stdinPayload(_ entries: [SubagentModelEntry], profile: String? = nil) -> String? {
        let rows: [[String: Any]] = normalizedForSubmit(entries).map { entry in
            [
                "model": entry.model,
                "criteria": entry.criteria,
                "reasoning_effort": entry.reasoningEffort.map { $0 as Any } ?? NSNull(),
                "default": entry.isDefault
            ]
        }
        var payload: [String: Any] = ["subagent_models": rows]
        if let profile { payload["profile"] = profile }
        guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys, .withoutEscapingSlashes]),
              let text = String(data: data, encoding: .utf8) else { return nil }
        return text + "\n"
    }
}
