import Foundation
import WireProtocol

// MARK: - LiveSessionInfo

/// One running (or just-exited) server session from `GET /live-sessions`
/// (read-only discovery — NO Origin header, src/server.ts:257-259). Mirrors
/// `src/types.ts:246-256` field-for-field; `telemetry` is `?: … | null` there
/// and stays optional here.
///
/// Decoding is tolerant at the UNTRUSTED server boundary (plan §4):
/// - identity/geometry (`id`/`createdAt`/`clientCount`/`exited`/`cols`/`rows`)
///   are required — an entry missing them is dropped by `decodeList`;
/// - an unrecognized `status` string maps to `.unknown` — a future server
///   status value must not make a running session invisible in the list;
/// - wrong-typed/absent `cwd`/`telemetry` degrade to `nil`, never fail the entry.
public struct LiveSessionInfo: Sendable, Equatable {
    public let id: UUID
    /// Server `Date.now()` at spawn (ms since epoch).
    public let createdAt: Int
    /// Devices currently attached (JOIN/mirror semantics, src/session/manager.ts).
    public let clientCount: Int
    public let status: ClaudeStatus
    public let exited: Bool
    public let cwd: String?
    /// Current PTY size (latest-writer-wins on the server).
    public let cols: Int
    public let rows: Int
    /// Latest statusLine telemetry, if any (B2).
    public let telemetry: StatusTelemetry?
    /// Server ms timestamp of the last PTY output (== createdAt until first
    /// output). OPTIONAL additive field (T-iOS-37, src/types.ts:259-261) —
    /// pre-P1 servers omit it; nil means "no unread data source" (T-iOS-23).
    public let lastOutputAt: Int?

    public init(
        id: UUID,
        createdAt: Int,
        clientCount: Int,
        status: ClaudeStatus,
        exited: Bool,
        cwd: String?,
        cols: Int,
        rows: Int,
        telemetry: StatusTelemetry?,
        lastOutputAt: Int? = nil
    ) {
        self.id = id
        self.createdAt = createdAt
        self.clientCount = clientCount
        self.status = status
        self.exited = exited
        self.cwd = cwd
        self.cols = cols
        self.rows = rows
        self.telemetry = telemetry
        self.lastOutputAt = lastOutputAt
    }
}

extension LiveSessionInfo: Decodable {
    private enum CodingKeys: String, CodingKey {
        case id, createdAt, clientCount, status, exited, cwd, cols, rows, telemetry
        case lastOutputAt
    }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(UUID.self, forKey: .id)
        createdAt = try container.decode(Int.self, forKey: .createdAt)
        clientCount = try container.decode(Int.self, forKey: .clientCount)
        exited = try container.decode(Bool.self, forKey: .exited)
        cols = try container.decode(Int.self, forKey: .cols)
        rows = try container.decode(Int.self, forKey: .rows)
        // Unknown/missing status → .unknown, never drop the whole entry.
        let rawStatus = try? container.decode(String.self, forKey: .status)
        status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
        // Optional/nullable fields: wrong type degrades to nil (tolerant).
        cwd = try? container.decode(String.self, forKey: .cwd)
        telemetry = try? container.decode(StatusTelemetry.self, forKey: .telemetry)
        lastOutputAt = try? container.decode(Int.self, forKey: .lastOutputAt)
    }

    /// Decode a `/live-sessions` response body.
    /// - Non-JSON / non-array top level → `APIClientError.invalidResponseBody`
    ///   (the pairing probe maps this to `httpOkButNotWebTerminal` — "端口对吗?").
    /// - Malformed elements are dropped one by one, mirroring
    ///   `TimelineEvent.decodeList`'s per-element tolerance.
    static func decodeList(from data: Data) throws -> [LiveSessionInfo] {
        guard let entries = try? JSONDecoder().decode([LossyEntry].self, from: data) else {
            throw APIClientError.invalidResponseBody
        }
        return entries.compactMap(\.value)
    }

    /// Per-element tolerance shim: a malformed element becomes `nil` instead of
    /// failing the whole array decode (same pattern as WireProtocol's helper).
    private struct LossyEntry: Decodable {
        let value: LiveSessionInfo?

        init(from decoder: any Decoder) {
            value = try? LiveSessionInfo(from: decoder)
        }
    }
}

// MARK: - SessionPreview

/// `GET /live-sessions/:id/preview` response (src/server.ts:314-326): the tail
/// of the session's ring buffer for a read-only thumbnail. `data` is opaque
/// ANSI/UTF-8 — feed it to a terminal, never parse it. Read-only → NO Origin.
public struct SessionPreview: Sendable, Equatable, Decodable {
    public let id: UUID
    public let cols: Int
    public let rows: Int
    public let data: String

    public init(id: UUID, cols: Int, rows: Int, data: String) {
        self.id = id
        self.cols = cols
        self.rows = rows
        self.data = data
    }
}

// MARK: - UiConfig

/// `GET /config/ui` response (src/server.ts:609-612, src/types.ts:503).
/// Reserved for a future permission-mode switcher (plan §3.4) — it filters the
/// high-risk raw `auto` mode by `allowAutoMode`; the plan-gate three-way UI
/// does NOT consume this.
public struct UiConfig: Sendable, Equatable, Decodable {
    public let allowAutoMode: Bool

    public init(allowAutoMode: Bool) {
        self.allowAutoMode = allowAutoMode
    }
}

// MARK: - HookDecision

/// The two verdicts `POST /hook/decision` accepts — anything else is a 400
/// (src/server.ts:513-517).
public enum HookDecision: String, Sendable, Equatable, CaseIterable {
    case allow, deny
}

// MARK: - APIClientError

/// Typed failures for `APIClient` calls (explicit error handling, plan §4).
/// Transport-level errors thrown by `HTTPTransport.send` propagate UNwrapped —
/// `PairingError.classify` reads their NSError shape during pairing.
public enum APIClientError: Error, Equatable, Sendable {
    /// The request could not be built from the endpoint (should never happen
    /// for a validated `HostEndpoint`; surfaced instead of force-unwrapping).
    case invalidRequest
    /// A 2xx arrived but the body is not the endpoint's shape. For
    /// `/live-sessions` this is the pairing probe's "the port speaks HTTP but
    /// is not web-terminal" signal.
    case invalidResponseBody
    /// 404 on a `/live-sessions/:id/*` route — the session is gone
    /// (exited / reaped / already killed).
    case sessionNotFound
    /// 403 from a G route's Origin guard (src/server.ts:332-339).
    case forbidden
    /// 403 from `POST /hook/decision` (src/server.ts:522-525): the capability
    /// token is missing / mismatched / STALE — tokens are single-use and
    /// expiring by design (SEC-C1/M1). User-facing 话术 in `message`.
    case decisionRejected
    /// 429: the endpoint is rate-limited per IP by fixed server policy —
    /// `POST /hook/decision` ≤ 10/min (src/server.ts:72,504-508);
    /// `POST|DELETE /push/apns-token` ≤ 5/min (mirrors `/push/subscribe`,
    /// src/server.ts:73,461-466). Per-endpoint limits live in each call's
    /// doc comment; the user copy stays limit-agnostic.
    case rateLimited
    /// The APNs device token is not 64–160 hex chars (frozen T-iOS-20 wire
    /// rule) — rejected client-side BEFORE any network I/O, or echoed by the
    /// server as a 400.
    case invalidApnsToken
    /// 400 from `GET /projects/detail` — the `path` query parameter is
    /// missing/empty (src/server.ts:295-298). Also raised client-side for an
    /// empty path, before any network I/O.
    case projectPathInvalid
    /// 404 from `GET /projects/detail` — no project at that path
    /// (src/server.ts:301-304; moved/deleted/not a directory).
    case projectNotFound
    /// 500 from `GET /projects/detail` — the server failed reading the repo
    /// (src/server.ts:306-309, body `{error}`).
    case projectDetailUnavailable
    /// Any other non-success status code.
    case unexpectedStatus(Int)

    /// User-facing message (UI 层可直接展示的话术，plan §4 错误处理).
    public var message: String {
        switch self {
        case .invalidRequest:
            "无法构造请求（主机地址异常）。"
        case .invalidResponseBody:
            "服务器响应不是预期格式——端口对吗？"
        case .sessionNotFound:
            "会话已不存在（可能已退出或被清理）。"
        case .forbidden:
            "服务器拒绝了此来源（Origin 校验未通过）。"
        case .decisionRejected:
            "审批令牌已过期或已被处理——请回到终端里直接批准/拒绝。"
        case .rateLimited:
            "操作过于频繁，服务器已限流，请稍后再试。"
        case .invalidApnsToken:
            "推送注册令牌格式异常，请重启 App 重新注册推送。"
        case .projectPathInvalid:
            "项目路径为空或不合法。"
        case .projectNotFound:
            "项目不存在（路径可能已移动或删除）。"
        case .projectDetailUnavailable:
            "读取项目详情失败，请稍后再试。"
        case .unexpectedStatus(let status):
            "服务器返回了意外状态码 \(status)。"
        }
    }
}
