// WebSocketClient.swift
// Starscream wrapper that speaks the IC-1 WebSocket protocol.
//
// URL pattern: ws(s)://<host>/sessions/<id>/stream?token=<token>
//
// Design note: Starscream 4.0.x also declares a *protocol* named
// `WebSocketClient` (the abstract socket interface). Our concrete class
// shares that name. Inside this file we qualify Starscream's protocol as
// `Starscream.WebSocketClient` to resolve the ambiguity; everywhere else in
// the app our class is the only visible `WebSocketClient`.

import Combine
import Foundation
import Starscream

// MARK: - ConnectionState

/// The lifecycle state of a `WebSocketClient` connection.
public enum ConnectionState: Sendable {
    case disconnected
    case connecting
    case connected
}

// MARK: - WebSocketClientError

/// Errors thrown by `WebSocketClient.send(_:)`.
public enum WebSocketClientError: Error, Sendable {
    /// The socket is not in the `.connected` state.
    case notConnected
    /// JSON encoding of the outgoing frame failed.
    case encodingFailed(any Error)
}

// MARK: - WebSocketClient

/// IC-1 WebSocket client.
///
/// All public state is isolated to the main actor.
/// Starscream's delegate callbacks (delivered on `DispatchQueue.main` by
/// default) are hopped back through a `Task { @MainActor in … }` in
/// `DelegateAdapter` before any mutation occurs, keeping the actor
/// boundary explicit.
@MainActor
public final class WebSocketClient {

    // MARK: - Published subjects

    /// Emits every successfully decoded binary frame received from the server.
    public let incomingBinary = PassthroughSubject<BinaryFrame, Never>()

    /// Emits every successfully decoded JSON frame received from the server.
    public let incomingJSON = PassthroughSubject<ServerToClient, Never>()

    /// Tracks the current lifecycle state.
    public let connectionState = CurrentValueSubject<ConnectionState, Never>(.disconnected)

    // MARK: - Private state

    /// The active Starscream socket. `WebSocket` is the concrete Starscream
    /// class; no name collision here.
    private var socket: WebSocket?

    /// Non-isolated trampoline that holds a weak back-reference to us.
    private let delegateAdapter = DelegateAdapter()

    // MARK: - Init

    public init() {
        delegateAdapter.owner = self
    }

    // MARK: - Connect / Disconnect

    /// Opens a new WebSocket connection to `url`, tearing down any existing
    /// socket first.
    ///
    /// After the `connected` state is published, call
    /// `send(.resume(lastSeq:))` to attach the IC-1 stream.
    public func connect(url: URL) {
        socket?.disconnect()
        socket = nil

        var request = URLRequest(url: url)
        request.timeoutInterval = 10

        let ws = WebSocket(request: request)
        // Starscream delivers delegate calls on DispatchQueue.main by default.
        ws.delegate = delegateAdapter
        socket = ws

        connectionState.send(.connecting)
        ws.connect()
    }

    /// Gracefully closes the current socket.
    public func disconnect() {
        socket?.disconnect()
        socket = nil
        connectionState.send(.disconnected)
    }

    // MARK: - Send

    /// Encodes `frame` as JSON and writes it to the open socket.
    ///
    /// - Throws: `WebSocketClientError.notConnected` when not connected,
    ///           `WebSocketClientError.encodingFailed` on JSON encoding error.
    public func send(_ frame: ClientToServer) async throws {
        guard let socket, connectionState.value == .connected else {
            throw WebSocketClientError.notConnected
        }
        let json: String
        do {
            json = try FrameCodec.encode(frame)
        } catch {
            throw WebSocketClientError.encodingFailed(error)
        }
        socket.write(string: json)
    }

    // MARK: - Internal event handling (always on main actor)

    fileprivate func handle(event: WebSocketEvent) {
        switch event {

        case .connected:
            connectionState.send(.connected)

        case .disconnected(let reason, let code):
            // Both clean server-initiated and transport-level disconnects
            // collapse to the same client state.
            _ = (reason, code)
            connectionState.send(.disconnected)

        case .text(let string):
            do {
                let frame = try FrameCodec.decode(string)
                incomingJSON.send(frame)
            } catch {
                #if DEBUG
                print("[WebSocketClient] JSON decode error: \(error)\nPayload: \(string)")
                #endif
            }

        case .binary(let data):
            if let frame = BinaryFrame.decode(data) {
                incomingBinary.send(frame)
            } else {
                #if DEBUG
                print("[WebSocketClient] Binary frame too short (\(data.count) bytes) — ignored.")
                #endif
            }

        case .cancelled:
            connectionState.send(.disconnected)

        case .error(let error):
            #if DEBUG
            let desc = error.map { "\($0)" } ?? "unknown"
            print("[WebSocketClient] Socket error: \(desc)")
            #endif
            connectionState.send(.disconnected)

        case .peerClosed:
            connectionState.send(.disconnected)

        case .ping, .pong, .viabilityChanged, .reconnectSuggested:
            break
        }
    }
}

// MARK: - DelegateAdapter

/// Non-isolated trampoline between Starscream's `WebSocketDelegate` callbacks
/// and our `@MainActor`-isolated `WebSocketClient`.
///
/// **Why a separate class?**
/// `WebSocketDelegate` is not `@MainActor`, so Swift 6 strict-concurrency
/// disallows directly satisfying it from a `@MainActor final class` without
/// `nonisolated`. A nested non-isolated class that hops explicitly via
/// `Task { @MainActor in … }` keeps the intent clear and compiler-clean.
///
/// **Naming note:** Starscream 4.0.x declares `WebSocketClient` as a *protocol*
/// (the abstract socket interface). The `didReceive` method's `client` parameter
/// therefore has type `any Starscream.WebSocketClient`, qualified to disambiguate
/// from our own concrete `WebSocketClient` class.
private final class DelegateAdapter: WebSocketDelegate, @unchecked Sendable {

    weak var owner: WebSocketClient?

    func didReceive(event: WebSocketEvent, client: any Starscream.WebSocketClient) {
        guard let owner else { return }
        // DelegateAdapter is @unchecked Sendable; silence Sendable check on
        // Starscream's WebSocketEvent which doesn't conform but is safe here.
        nonisolated(unsafe) let e = event
        Task { @MainActor [owner] in
            owner.handle(event: e)
        }
    }
}
