import Foundation
import WireProtocol

// T-iOS-27 · App-layer fetcher for `GET /projects/diff?path=&staged=` —— the
// read-only structured git diff (server route src/server.ts:601-618, parser
// src/http/diff.ts). iOS mirrors the web split: parsing lives ONLY server-side;
// this file fetches + tolerantly decodes, the screen renders verbatim.
//
// 为什么不在 APIClient 包里：APIClient 是 T-iOS-38 单一 owner（PLAN §6 Owns
// 铁律），本任务不得改包。此 fetcher 走同一枚注入的 `HTTPTransport`，语义与
// APIClient 路由构建完全同构 —— **回收进 APIClient 是 T-iOS-38 owner 的
// follow-up**（届时本文件的路由/编码/解码可整体搬迁，测试随行）。
//
// Origin 铁律（plan §3.4/§5.1，iff-G）：`/projects/diff` 是 RO 端点 —— 路由层
// 没有 requireAllowedOrigin（src/server.ts:601 注释 "no Origin guard; same
// threat model as /projects"），因此请求**绝不携带 Origin**。若服务器未来把它
// 改为 G，测试先红而不是靠巧合通过。
//
// 服务器是不可信输入源（plan §4）：解码逐层宽容，镜像 web 的
// normalizeDiffResult（public/diff.ts:38-102）—— 顶层三键缺一 → 整体无效；
// 畸形 file/hunk/line 逐条丢弃；未知枚举值降级（kind → .context、status →
// .modified），绝不 crash。

// MARK: - 模型（镜像 src/types.ts:445-479；App 内部，待 T-iOS-38 回收）

/// One diff line's semantic kind. Unknown wire values degrade to `.context`
/// (the web renderer's `?? 'df-context'` fallback, public/diff.ts:192).
enum DiffLineKind: String, Sendable, Equatable {
    case added, removed, context, hunk, meta
}

struct DiffLine: Sendable, Equatable {
    let kind: DiffLineKind
    /// Verbatim server bytes (marker already stripped server-side). UNTRUSTED
    /// display input — render via `Text(verbatim:)` only, never Markdown/links.
    let text: String
}

struct DiffHunk: Sendable, Equatable {
    let header: String
    let lines: [DiffLine]
}

/// File-level change status. Unknown wire values degrade to `.modified`
/// (display-only field; the web keeps the raw string for a CSS class).
enum DiffFileStatus: String, Sendable, Equatable {
    case modified, added, deleted, renamed, binary, untracked
}

struct DiffFile: Sendable, Equatable {
    let oldPath: String
    let newPath: String
    let status: DiffFileStatus
    let added: Int
    let removed: Int
    let binary: Bool
    let hunks: [DiffHunk]
}

struct DiffResult: Sendable, Equatable {
    let files: [DiffFile]
    let staged: Bool
    let truncated: Bool
}

// MARK: - 宽容解码（Decodable 在 extension 里，保留成员逐一初始化器）

extension DiffLine: Decodable {
    private enum CodingKeys: String, CodingKey { case kind, text }

    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        // kind/text 必须是字符串，否则整行丢弃（镜像 normalizeLine）。
        let rawKind = try container.decode(String.self, forKey: .kind)
        text = try container.decode(String.self, forKey: .text)
        kind = DiffLineKind(rawValue: rawKind) ?? .context
    }
}

extension DiffHunk: Decodable {
    private enum CodingKeys: String, CodingKey { case header, lines }

    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        header = try container.decode(String.self, forKey: .header)
        let boxes = try container.decode([DiffLossyBox<DiffLine>].self, forKey: .lines)
        lines = boxes.compactMap(\.value)
    }
}

extension DiffFile: Decodable {
    private enum CodingKeys: String, CodingKey {
        case oldPath, newPath, status, added, removed, binary, hunks
    }

    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        oldPath = try container.decode(String.self, forKey: .oldPath)
        newPath = try container.decode(String.self, forKey: .newPath)
        added = try container.decode(Int.self, forKey: .added)
        removed = try container.decode(Int.self, forKey: .removed)
        binary = try container.decode(Bool.self, forKey: .binary)
        let rawStatus = try container.decode(String.self, forKey: .status)
        status = DiffFileStatus(rawValue: rawStatus) ?? .modified
        let boxes = try container.decode([DiffLossyBox<DiffHunk>].self, forKey: .hunks)
        hunks = boxes.compactMap(\.value)
    }
}

extension DiffResult: Decodable {
    private enum CodingKeys: String, CodingKey { case files, staged, truncated }

    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        staged = try container.decode(Bool.self, forKey: .staged)
        truncated = try container.decode(Bool.self, forKey: .truncated)
        let boxes = try container.decode([DiffLossyBox<DiffFile>].self, forKey: .files)
        files = boxes.compactMap(\.value)
    }
}

/// Per-element tolerance shim: a malformed element becomes nil instead of
/// failing the whole array. Local twin of APIClient's internal `LossyBox`
/// (not importable across the package boundary) — folds together with the
/// fetcher in the T-iOS-38 follow-up.
private struct DiffLossyBox<Wrapped: Decodable>: Decodable {
    let value: Wrapped?

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

// MARK: - 错误分类学（route 层语义，src/server.ts:602-618）

enum DiffFetchError: Error, Equatable {
    /// 空 path / 无法编码 / URL 构建失败 —— 客户端先拒，零网络。
    case invalidRequest
    /// 服务器 400：`path query parameter is required`。
    case pathInvalid
    /// 服务器 404：SEC-H7 三叉校验失败（绝对路径 + 目录 + 有 .git）。
    case projectNotFound
    /// 200 但响应体不是 DiffResult 形状（不可信输入源兜底）。
    case invalidResponse
    /// 其余状态（含 500 `failed to read diff`）。
    case unexpectedStatus(Int)
}

// MARK: - DiffFetcher

/// Fetch a repo's structured diff over the injected transport. Immutable
/// value; one instance per (endpoint) — the screen's VM closes over it.
struct DiffFetcher: Sendable {
    let endpoint: HostEndpoint
    private let http: any HTTPTransport

    /// Route constants（无魔法字符串）。`stagedFlag`：服务器精确匹配
    /// `req.query['staged'] === '1'`（src/server.ts:613）——必须发 `1`/`0`。
    /// （对照：web fetchDiff 发 `true`/`false`，永远匹配不上 `'1'` ——
    /// staged 开关在 web 端静默失效，已在任务日志上报，不属本任务 Owns。）
    private enum Route {
        static let path = "/projects/diff"
        static let pathKey = "path"
        static let stagedKey = "staged"
        static let stagedOn = "1"
        static let stagedOff = "0"
        static let methodGet = "GET"
    }

    private enum Status {
        static let ok = 200
        static let badRequest = 400
        static let notFound = 404
    }

    /// Strict RFC 3986 unreserved set —— 与 APIClient `Endpoints.
    /// unreservedCharacters` 同值同理（裸 `+` 会被 Express qs 解码成空格，
    /// `&`/`=` 会拆参）；该常量在包内 internal 不可 import，T-iOS-38 回收时合并。
    private static let unreservedCharacters = CharacterSet(
        charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
    )

    init(endpoint: HostEndpoint, http: any HTTPTransport) {
        self.endpoint = endpoint
        self.http = http
    }

    /// `GET /projects/diff?path=&staged=` → 宽容解码后的 `DiffResult`。
    /// 空 path 客户端先拒（镜像服务器 400 规则），不产生网络 I/O。
    func fetch(path: String, staged: Bool) async throws -> DiffResult {
        guard !path.isEmpty, let request = buildRequest(path: path, staged: staged) else {
            throw DiffFetchError.invalidRequest
        }
        let (data, response) = try await http.send(request)
        switch response.statusCode {
        case Status.ok:
            guard let result = try? JSONDecoder().decode(DiffResult.self, from: data) else {
                throw DiffFetchError.invalidResponse
            }
            return result
        case Status.badRequest:
            throw DiffFetchError.pathInvalid
        case Status.notFound:
            throw DiffFetchError.projectNotFound
        default:
            throw DiffFetchError.unexpectedStatus(response.statusCode)
        }
    }

    /// Build the RO GET —— 与 APIClient `APIRoute.urlRequest(for:)` 同派生
    /// 哲学：baseURL 只取 scheme/host/port，path/query 整体替换，fragment 与
    /// 凭据丢弃。**不 stamp Origin**（iff-G，见文件头）。
    private func buildRequest(path: String, staged: Bool) -> URLRequest? {
        guard let encodedPath = path.addingPercentEncoding(
            withAllowedCharacters: Self.unreservedCharacters
        ) else { return nil }
        guard var components = URLComponents(
            url: endpoint.baseURL, resolvingAgainstBaseURL: true
        ) else { return nil }
        components.path = Route.path
        components.query = nil
        components.fragment = nil
        components.user = nil
        components.password = nil
        let stagedValue = staged ? Route.stagedOn : Route.stagedOff
        components.percentEncodedQuery =
            "\(Route.pathKey)=\(encodedPath)&\(Route.stagedKey)=\(stagedValue)"
        guard let url = components.url else { return nil }

        var request = URLRequest(url: url)
        request.httpMethod = Route.methodGet
        return request
    }
}
