import Foundation
import WireProtocol

// T-iOS-38 · cross-device Projects UI prefs (`GET /prefs` RO · `PUT /prefs` G):
// an OPAQUE-BUT-VALIDATED JSON object round-trip. Known keys mirror the web
// client loosely (public/prefs.ts sanitizePrefs / src/types.ts:493-496:
// `favourites: string[]`, `collapsed: {group-key: true}`); ALL OTHER top-level
// keys are preserved verbatim across decode → mutate → encode, so an iOS PUT
// can never clobber prefs written by the web client or a future server.
// (PUT replaces the WHOLE blob server-side, src/server.ts:278-288 — key
// preservation is therefore correctness, not politeness.)

// MARK: - JSONValue (internal round-trip carrier)

/// Minimal JSON tree — the storage format that lets unknown prefs keys
/// round-trip losslessly. Integers keep a dedicated case so `42` never
/// re-encodes as `42.0`.
enum JSONValue: Sendable, Equatable, Codable {
    case null
    case bool(Bool)
    case integer(Int)
    case number(Double)
    case string(String)
    case array([JSONValue])
    case object([String: JSONValue])

    init(from decoder: any Decoder) throws {
        let container = try decoder.singleValueContainer()
        if container.decodeNil() {
            self = .null
        } else if let bool = try? container.decode(Bool.self) {
            self = .bool(bool)
        } else if let int = try? container.decode(Int.self) {
            self = .integer(int)
        } else if let double = try? container.decode(Double.self) {
            self = .number(double)
        } else if let string = try? container.decode(String.self) {
            self = .string(string)
        } else if let array = try? container.decode([JSONValue].self) {
            self = .array(array)
        } else if let object = try? container.decode([String: JSONValue].self) {
            self = .object(object)
        } else {
            throw DecodingError.dataCorruptedError(
                in: container, debugDescription: "not a JSON value"
            )
        }
    }

    func encode(to encoder: any Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .null: try container.encodeNil()
        case .bool(let value): try container.encode(value)
        case .integer(let value): try container.encode(value)
        case .number(let value): try container.encode(value)
        case .string(let value): try container.encode(value)
        case .array(let value): try container.encode(value)
        case .object(let value): try container.encode(value)
        }
    }
}

// MARK: - UiPrefs

/// The prefs blob. Immutable snapshot: `withFavourites`/`withCollapsed` return
/// NEW copies that replace exactly one known key and carry every other key
/// through untouched (THE round-trip correctness trap — tested).
public struct UiPrefs: Sendable, Equatable {
    /// Full decoded top-level object — known keys included, unknown keys opaque.
    private let storage: [String: JSONValue]

    private enum Key {
        static let favourites = "favourites"
        static let collapsed = "collapsed"
    }

    init(storage: [String: JSONValue]) {
        self.storage = storage
    }

    /// Fresh prefs (e.g. first write from a device that never fetched).
    public init(favourites: [String] = [], collapsed: [String: Bool] = [:]) {
        self.init(storage: [
            Key.favourites: .array(favourites.map(JSONValue.string)),
            Key.collapsed: .object(collapsed.mapValues(JSONValue.bool)),
        ])
    }

    // MARK: Known keys (sanitized views — mirror public/prefs.ts sanitizePrefs)

    /// Favourited project paths (★): non-empty strings, de-duplicated,
    /// original order kept. Wrong-typed entries are dropped, not fatal.
    public var favourites: [String] {
        guard case .array(let items)? = storage[Key.favourites] else { return [] }
        var seen = Set<String>()
        var out: [String] = []
        for case .string(let path) in items where !path.isEmpty && seen.insert(path).inserted {
            out.append(path)
        }
        return out
    }

    /// Namespace group-key → collapsed. Only literal `true` values count
    /// (expanded is the default; both web and server sanitizers agree).
    public var collapsed: [String: Bool] {
        guard case .object(let entries)? = storage[Key.collapsed] else { return [:] }
        var out: [String: Bool] = [:]
        for (key, value) in entries where !key.isEmpty {
            if case .bool(true) = value { out[key] = true }
        }
        return out
    }

    // MARK: Immutable mutation (unknown keys carried through verbatim)

    /// New snapshot with `favourites` replaced; every other key untouched.
    public func withFavourites(_ favourites: [String]) -> UiPrefs {
        var next = storage
        next[Key.favourites] = .array(favourites.map(JSONValue.string))
        return UiPrefs(storage: next)
    }

    /// New snapshot with `collapsed` replaced; every other key untouched.
    public func withCollapsed(_ collapsed: [String: Bool]) -> UiPrefs {
        var next = storage
        next[Key.collapsed] = .object(collapsed.mapValues(JSONValue.bool))
        return UiPrefs(storage: next)
    }

    // MARK: Wire round-trip

    /// Decode a `/prefs` body. nil = top level is not a JSON object — callers
    /// surface `.invalidResponseBody` LOUDLY instead of degrading to empty
    /// prefs (an empty-based PUT would wipe the server blob).
    public static func decode(from data: Data) -> UiPrefs? {
        guard let storage = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
            return nil
        }
        return UiPrefs(storage: storage)
    }

    /// Encode the FULL blob (known + unknown keys) for `PUT /prefs`.
    public func encodeBody() throws -> Data {
        try JSONEncoder().encode(storage)
    }
}

// MARK: - Routes + client calls

extension Endpoints {
    static let prefsPath = "/prefs"

    /// `GET /prefs` — RO, no Origin (src/server.ts:273-275; no secrets, just
    /// paths + booleans).
    static func getPrefs() -> APIRoute {
        APIRoute(method: .get, path: prefsPath, originPolicy: .readOnly, body: nil)
    }

    /// `PUT /prefs` — G (CSRF Origin guard). Server-enforced body limit
    /// **64 KB** (`express.json({limit:'64kb'})`, src/server.ts:278); the body
    /// is sanitized server-side and echoed back as the 200 response.
    static func putPrefs(_ prefs: UiPrefs) throws -> APIRoute {
        APIRoute(
            method: .put, path: prefsPath, originPolicy: .guarded,
            body: try prefs.encodeBody()
        )
    }
}

extension APIClient {
    /// `GET /prefs` — the cross-device favourites/collapse blob. RO → no
    /// Origin. A non-object body throws `.invalidResponseBody` (never
    /// silently degrades — see `UiPrefs.decode`).
    public func prefs() async throws -> UiPrefs {
        let (data, response) = try await perform(Endpoints.getPrefs())
        guard response.statusCode == HTTPStatus.ok else {
            throw APIClientError.unexpectedStatus(response.statusCode)
        }
        guard let prefs = UiPrefs.decode(from: data) else {
            throw APIClientError.invalidResponseBody
        }
        return prefs
    }

    /// `PUT /prefs` — replace the whole blob (body ≤ 64 KB, server rule).
    /// G → Origin byte-equal; 403 = Origin guard. Returns the server's
    /// sanitized echo — treat IT as the new source of truth, not the input.
    @discardableResult
    public func putPrefs(_ prefs: UiPrefs) async throws -> UiPrefs {
        let (data, response) = try await perform(try Endpoints.putPrefs(prefs))
        switch response.statusCode {
        case HTTPStatus.ok:
            guard let echoed = UiPrefs.decode(from: data) else {
                throw APIClientError.invalidResponseBody
            }
            return echoed
        case HTTPStatus.forbidden:
            throw APIClientError.forbidden
        default:
            throw APIClientError.unexpectedStatus(response.statusCode)
        }
    }
}
