import Foundation
import WireProtocol

// T-iOS-38 · Projects contract increments (both RO — NO Origin header):
// - `GET /projects` (src/server.ts:262-269) → `[ProjectInfo]`, mirrors
//   src/types.ts:273-281 field-for-field. The server replies `[]` even on
//   internal failure — a non-array body is therefore "not web-terminal".
// - `GET /projects/detail?path=` (src/server.ts:293-310) → `ProjectDetail`
//   (src/types.ts:296-306). `path` percent-encoding happens ONCE, in the
//   builder. 400/404/500 `{error}` map to explicit typed errors.
// Tolerant decoding at the untrusted server boundary (plan §4): unknown
// fields ignored, malformed entries dropped one by one, never crash.

// MARK: - ProjectSessionRef

/// One running session belonging to a project (src/types.ts:262-269; the
/// server mirrors LiveSessionInfo fields into this ref).
public struct ProjectSessionRef: Sendable, Equatable {
    public let id: UUID
    /// Derived label (last cwd segment, e.g. repo dir name); optional.
    public let title: String?
    public let status: ClaudeStatus
    public let clientCount: Int
    public let createdAt: Int
    public let exited: Bool

    public init(
        id: UUID, title: String?, status: ClaudeStatus,
        clientCount: Int, createdAt: Int, exited: Bool
    ) {
        self.id = id
        self.title = title
        self.status = status
        self.clientCount = clientCount
        self.createdAt = createdAt
        self.exited = exited
    }
}

extension ProjectSessionRef: Decodable {
    private enum CodingKeys: String, CodingKey {
        case id, title, status, clientCount, createdAt, exited
    }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(UUID.self, forKey: .id)
        clientCount = try container.decode(Int.self, forKey: .clientCount)
        createdAt = try container.decode(Int.self, forKey: .createdAt)
        exited = try container.decode(Bool.self, forKey: .exited)
        // Same tolerance as LiveSessionInfo: a future status value must not
        // drop the ref; a wrong-typed title degrades to nil.
        let rawStatus = try? container.decode(String.self, forKey: .status)
        status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown
        title = try? container.decode(String.self, forKey: .title)
    }
}

// MARK: - ProjectInfo

/// A discovered project (git repo or recently-used cwd) — src/types.ts:273-281.
/// Note: there is NO `namespace` field on the wire; namespace grouping is a
/// web-client concept that only surfaces as `UiPrefs.collapsed` group-keys.
public struct ProjectInfo: Sendable, Equatable {
    public let name: String
    public let path: String
    public let isGit: Bool
    public let branch: String?
    /// Uncommitted changes; only present when the server runs the dirty check.
    public let dirty: Bool?
    /// Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key.
    public let lastActiveMs: Int?
    public let sessions: [ProjectSessionRef]

    public init(
        name: String, path: String, isGit: Bool, branch: String?,
        dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef]
    ) {
        self.name = name
        self.path = path
        self.isGit = isGit
        self.branch = branch
        self.dirty = dirty
        self.lastActiveMs = lastActiveMs
        self.sessions = sessions
    }
}

extension ProjectInfo: Decodable {
    private enum CodingKeys: String, CodingKey {
        case name, path, isGit, branch, dirty, lastActiveMs, sessions
    }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        path = try container.decode(String.self, forKey: .path)
        isGit = try container.decode(Bool.self, forKey: .isGit)
        branch = try? container.decode(String.self, forKey: .branch)
        dirty = try? container.decode(Bool.self, forKey: .dirty)
        lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs)
        sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
    }

    /// Decode a `/projects` response body — non-array top level →
    /// `invalidResponseBody`; malformed elements dropped one by one (same
    /// pattern as `LiveSessionInfo.decodeList`).
    static func decodeList(from data: Data) throws -> [ProjectInfo] {
        guard let entries = try? JSONDecoder().decode([LossyBox<ProjectInfo>].self, from: data) else {
            throw APIClientError.invalidResponseBody
        }
        return entries.compactMap(\.value)
    }
}

// MARK: - WorktreeInfo / ProjectDetail

/// One `git worktree list --porcelain` entry (src/types.ts:284-292).
public struct WorktreeInfo: Sendable, Equatable {
    public let path: String
    /// Branch name; nil on detached HEAD.
    public let branch: String?
    public let head: String?
    public let isMain: Bool
    public let isCurrent: Bool
    public let locked: Bool?
    public let prunable: Bool?

    public init(
        path: String, branch: String?, head: String?,
        isMain: Bool, isCurrent: Bool, locked: Bool?, prunable: Bool?
    ) {
        self.path = path
        self.branch = branch
        self.head = head
        self.isMain = isMain
        self.isCurrent = isCurrent
        self.locked = locked
        self.prunable = prunable
    }
}

extension WorktreeInfo: Decodable {
    private enum CodingKeys: String, CodingKey {
        case path, branch, head, isMain, isCurrent, locked, prunable
    }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        path = try container.decode(String.self, forKey: .path)
        branch = try? container.decode(String.self, forKey: .branch)
        head = try? container.decode(String.self, forKey: .head)
        isMain = (try? container.decode(Bool.self, forKey: .isMain)) ?? false
        isCurrent = (try? container.decode(Bool.self, forKey: .isCurrent)) ?? false
        locked = try? container.decode(Bool.self, forKey: .locked)
        prunable = try? container.decode(Bool.self, forKey: .prunable)
    }
}

/// Detailed view of one project (src/types.ts:296-306).
public struct ProjectDetail: Sendable, Equatable {
    public let name: String
    public let path: String
    public let isGit: Bool
    public let branch: String?
    public let dirty: Bool?
    public let worktrees: [WorktreeInfo]
    public let sessions: [ProjectSessionRef]
    public let hasClaudeMd: Bool
    /// CLAUDE.md content (server-truncated for display) when present.
    public let claudeMd: String?

    public init(
        name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?,
        worktrees: [WorktreeInfo], sessions: [ProjectSessionRef],
        hasClaudeMd: Bool, claudeMd: String?
    ) {
        self.name = name
        self.path = path
        self.isGit = isGit
        self.branch = branch
        self.dirty = dirty
        self.worktrees = worktrees
        self.sessions = sessions
        self.hasClaudeMd = hasClaudeMd
        self.claudeMd = claudeMd
    }
}

extension ProjectDetail: Decodable {
    private enum CodingKeys: String, CodingKey {
        case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd
    }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        path = try container.decode(String.self, forKey: .path)
        isGit = try container.decode(Bool.self, forKey: .isGit)
        branch = try? container.decode(String.self, forKey: .branch)
        dirty = try? container.decode(Bool.self, forKey: .dirty)
        hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false
        claudeMd = try? container.decode(String.self, forKey: .claudeMd)
        worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees)
        sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions)
    }
}

// MARK: - Lossy decoding helpers (shared per-element tolerance)

/// Per-element tolerance shim: a malformed element becomes nil instead of
/// failing the whole array (same pattern as WireProtocol's TimelineEvent).
struct LossyBox<Wrapped: Decodable>: Decodable {
    let value: Wrapped?

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

enum LossyList {
    /// Decode `[Element]` at `key`, dropping malformed elements; a missing or
    /// wrong-typed array degrades to `[]`.
    static func decode<Element: Decodable, Key: CodingKey>(
        _ type: Element.Type,
        in container: KeyedDecodingContainer<Key>,
        forKey key: Key
    ) -> [Element] {
        let boxes = (try? container.decode([LossyBox<Element>].self, forKey: key)) ?? []
        return boxes.compactMap(\.value)
    }
}

// MARK: - Routes + client calls

extension Endpoints {
    /// `GET /projects` — RO, no Origin (src/server.ts:262-269).
    static func projects() -> APIRoute {
        APIRoute(method: .get, path: "/projects", originPolicy: .readOnly, body: nil)
    }

    /// `GET /projects/detail?path=` — RO, no Origin (src/server.ts:293-310).
    /// The ONE place `path` gets percent-encoded (strict unreserved-only set —
    /// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough).
    /// nil = the path could not be encoded (surfaced as `.invalidRequest`).
    static func projectDetail(path: String) -> APIRoute? {
        guard let encoded = path.addingPercentEncoding(
            withAllowedCharacters: unreservedCharacters
        ) else { return nil }
        return APIRoute(
            method: .get, path: "/projects/detail", originPolicy: .readOnly,
            body: nil, percentEncodedQuery: "path=\(encoded)"
        )
    }
}

extension APIClient {
    /// `GET /projects` — the host's discovered projects with their running
    /// sessions merged in. RO → no Origin.
    public func projects() async throws -> [ProjectInfo] {
        let (data, response) = try await perform(Endpoints.projects())
        guard response.statusCode == HTTPStatus.ok else {
            throw APIClientError.unexpectedStatus(response.statusCode)
        }
        return try ProjectInfo.decodeList(from: data)
    }

    /// `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one
    /// project. RO → no Origin. An empty path is rejected client-side
    /// (mirror of the server's 400 rule) before any network I/O;
    /// 400/404/500 map to `.projectPathInvalid` / `.projectNotFound` /
    /// `.projectDetailUnavailable`.
    public func projectDetail(path: String) async throws -> ProjectDetail {
        guard !path.isEmpty else {
            throw APIClientError.projectPathInvalid
        }
        guard let route = Endpoints.projectDetail(path: path) else {
            throw APIClientError.invalidRequest
        }
        let (data, response) = try await perform(route)
        switch response.statusCode {
        case HTTPStatus.ok:
            guard let detail = try? JSONDecoder().decode(ProjectDetail.self, from: data) else {
                throw APIClientError.invalidResponseBody
            }
            return detail
        case HTTPStatus.badRequest:
            throw APIClientError.projectPathInvalid
        case HTTPStatus.notFound:
            throw APIClientError.projectNotFound
        case HTTPStatus.internalServerError:
            throw APIClientError.projectDetailUnavailable
        default:
            throw APIClientError.unexpectedStatus(response.statusCode)
        }
    }
}
