//
// OpenRocky — Voice-first AI Agent
// https://github.com/openrocky
//
// Developed by everettjf with the assistance of Claude Code and Codex.
// Date: 2026-05-04
// Copyright (c) 2026 everettjf. All rights reserved.
//

import Foundation

/// User-configured Model Context Protocol server. Persisted in UserDefaults via
/// `OpenRockyMCPStore`. Tools advertised by the server are surfaced through the
/// chat toolbox under the `mcp-{label}-{tool}` namespace, so any chat provider
/// (not just OpenAI) can call them — distinct from the OpenAI Responses API
/// `Tool.MCPTool` path which is provider-locked.
struct OpenRockyMCPServer: Codable, Identifiable, Equatable, Sendable {
    var id: String
    /// Stable short label used as a namespace prefix in tool names. Lower-case,
    /// alphanumeric + dashes. Disambiguates tools when two servers expose tools
    /// with overlapping names.
    var label: String
    /// Streamable-HTTP endpoint (modern MCP transport). Stdio / WebSocket are
    /// out of scope for the iOS sandbox.
    var endpointURL: String
    /// Optional bearer token sent as `Authorization: Bearer <token>`.
    var bearerToken: String?
    /// Extra headers (e.g. multi-tenant identifiers, vendor-specific keys).
    var extraHeaders: [String: String]
    var isEnabled: Bool
    /// Optional allowlist of tool names. When empty the full advertised set is
    /// exposed; when non-empty only matching names pass through.
    var allowedToolNames: [String]
    /// Cached tool catalog from the most recent successful `tools/list`. Lets
    /// the chat sub-agent build its tool definitions cold without paying the
    /// network round-trip every turn.
    var cachedTools: [CachedTool]
    /// Time of the last successful `tools/list` refresh, for the settings UI.
    var lastRefreshedAt: Date?

    init(
        id: String = UUID().uuidString,
        label: String,
        endpointURL: String,
        bearerToken: String? = nil,
        extraHeaders: [String: String] = [:],
        isEnabled: Bool = true,
        allowedToolNames: [String] = [],
        cachedTools: [CachedTool] = [],
        lastRefreshedAt: Date? = nil
    ) {
        self.id = id
        self.label = label
        self.endpointURL = endpointURL
        self.bearerToken = bearerToken
        self.extraHeaders = extraHeaders
        self.isEnabled = isEnabled
        self.allowedToolNames = allowedToolNames
        self.cachedTools = cachedTools
        self.lastRefreshedAt = lastRefreshedAt
    }

    /// One advertised tool, captured in the form we need to round-trip through
    /// the chat-tool catalog. The schema is held as raw JSON because MCP servers
    /// can advertise inputSchemas that JSONSchema (our local subset) doesn't
    /// model — we pass them through verbatim and rely on the chat provider to
    /// translate.
    struct CachedTool: Codable, Equatable, Sendable {
        var name: String
        var description: String
        /// JSON-encoded input schema as advertised by the MCP server. Stored as
        /// String so we keep the raw shape (additionalProperties, oneOf, etc.)
        /// without having to model every JSON Schema feature locally.
        var inputSchemaJSON: String
    }
}

// MARK: - Name sanitization

extension OpenRockyMCPServer {
    /// Tool name as exposed to the chat model: `mcp-{label}-{tool}`. The
    /// underlying server label is sanitized so the resulting identifier matches
    /// OpenAI's allowed-character rules (a-z, A-Z, 0-9, _, -, max 64 chars).
    static func sanitizedLabel(_ raw: String) -> String {
        let lower = raw.lowercased()
        let scrubbed = lower.unicodeScalars.map { scalar -> Character in
            let isAlpha = (scalar >= "a" && scalar <= "z") || (scalar >= "0" && scalar <= "9") || scalar == "-"
            return isAlpha ? Character(scalar) : "-"
        }
        var result = String(scrubbed)
        while result.contains("--") {
            result = result.replacingOccurrences(of: "--", with: "-")
        }
        result = result.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
        return result.isEmpty ? "server" : String(result.prefix(20))
    }

    static func sanitizedToolName(serverLabel: String, toolName: String) -> String {
        let safeLabel = sanitizedLabel(serverLabel)
        let scrubbed = toolName.unicodeScalars.map { scalar -> Character in
            let isOK = (scalar >= "a" && scalar <= "z") || (scalar >= "A" && scalar <= "Z")
                || (scalar >= "0" && scalar <= "9") || scalar == "_" || scalar == "-"
            return isOK ? Character(scalar) : "_"
        }
        let safeTool = String(scrubbed)
        let combined = "mcp-\(safeLabel)-\(safeTool)"
        // OpenAI's hard limit on tool names is 64 chars; truncate from the
        // tool side so the namespace prefix stays readable.
        return String(combined.prefix(64))
    }

    /// Reverses sanitizedToolName: given a namespaced tool name, returns the
    /// (server label, original tool name) pair if the prefix matches. The
    /// caller still has to look up the actual server by label, since the
    /// sanitized label may collide on edge inputs (e.g. two servers both
    /// labelled "github" / "GitHub-Enterprise").
    static func parseNamespacedToolName(_ name: String) -> (sanitizedLabel: String, toolName: String)? {
        guard name.hasPrefix("mcp-") else { return nil }
        let body = String(name.dropFirst(4))
        guard let dashIdx = body.firstIndex(of: "-") else { return nil }
        let label = String(body[..<dashIdx])
        let tool = String(body[body.index(after: dashIdx)...])
        guard !label.isEmpty, !tool.isEmpty else { return nil }
        return (sanitizedLabel: label, toolName: tool)
    }
}
