import APIClient
import Foundation
import HostRegistry
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm

/// T-iOS-26 · ProjectsViewModel（列表 + favourites/collapse 的 /prefs 往返）。
///
/// 覆盖面（Steps 测试先行）：
/// - load：`GET /projects` + `GET /prefs` → 分组行 + 收藏/折叠状态；
/// - **merge 关键测试**：PUT body 必须保留 web/未来服务器写入的未知顶层键
///   （UiPrefs 的 unknown-key-preserving API —— PUT 整体替换服务器 blob，
///   丢键=清库）；
/// - prefs 加载失败 → 项目照常显示、明确文案，且**绝不 PUT**（空底盘上写会
///   清掉服务器收藏）；
/// - PUT 失败 → 本地改动保留 + 同步失败文案；
/// - 搜索中强制展开所有分组（镜像 web renderGrid 的 searching 规则）；
/// - "在此仓库开新会话" → ProjectOpenRequest{cwd + claude\r}；相对路径拒绝。
///
/// 全部 HTTP 走真 `APIClient` over `FakeHTTPTransport`（零真实网络/等待）。
@MainActor
@Suite("ProjectsViewModel")
struct ProjectsViewModelTests {
    private nonisolated static let base = "http://192.168.1.5:3000"

    private nonisolated static let projectsJSON = """
    [{"name":"Billo.Platform.api","path":"/repos/api","isGit":true,"branch":"main",\
    "dirty":true,"lastActiveMs":200,"sessions":[]},\
    {"name":"Billo.Platform.web","path":"/repos/web","isGit":true,\
    "lastActiveMs":100,"sessions":[]}]
    """

    /// 未知顶层键 `webOnly` 是 merge 测试的探针。
    private nonisolated static let prefsJSON = """
    {"favourites":["/repos/web"],"collapsed":{"Billo.Platform":true},\
    "webOnly":{"theme":"dark"}}
    """

    // MARK: - Harness

    private struct Fixture {
        let http: FakeHTTPTransport
        let host: HostRegistry.Host
        let viewModel: ProjectsViewModel
        let projectsURL: URL
        let prefsURL: URL
    }

    private func makeFixture() throws -> Fixture {
        let baseURL = try #require(URL(string: Self.base))
        let endpoint = try #require(HostEndpoint(baseURL: baseURL))
        let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
        let http = FakeHTTPTransport()
        return Fixture(
            http: http,
            host: host,
            viewModel: ProjectsViewModel(host: host, http: http),
            projectsURL: try #require(URL(string: Self.base + "/projects")),
            prefsURL: try #require(URL(string: Self.base + "/prefs"))
        )
    }

    private func loadHappyPath(_ fixture: Fixture) async throws {
        await fixture.http.queueSuccess(
            url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
        )
        await fixture.http.queueSuccess(
            url: fixture.prefsURL, body: Data(Self.prefsJSON.utf8)
        )
        await fixture.viewModel.load()
    }

    private func recordedPutBodies(_ fixture: Fixture) async throws -> [[String: Any]] {
        let requests = await fixture.http.recordedRequests
        return try requests
            .filter { $0.httpMethod == "PUT" }
            .map { request in
                let body = try #require(request.httpBody)
                let json = try JSONSerialization.jsonObject(with: body)
                return try #require(json as? [String: Any])
            }
    }

    // MARK: - load

    @Test("load 成功：项目解码 + prefs 应用（收藏排序、折叠状态、分组）")
    func loadAppliesProjectsAndPrefs() async throws {
        let fixture = try makeFixture()

        try await loadHappyPath(fixture)

        #expect(fixture.viewModel.hasLoadedOnce)
        #expect(fixture.viewModel.fetchErrorMessage == nil)
        #expect(fixture.viewModel.prefsErrorMessage == nil)
        #expect(fixture.viewModel.favourites == ["/repos/web"])
        #expect(fixture.viewModel.collapsedGroups == ["Billo.Platform": true])
        // 分组：两个同 namespace 项目成一组；收藏的 web 排在前（虽然更旧）。
        let groups = fixture.viewModel.groups
        #expect(groups.count == 1)
        #expect(groups[0].key == "Billo.Platform")
        #expect(groups[0].projects.map(\.path) == ["/repos/web", "/repos/api"])
        #expect(fixture.viewModel.isCollapsed(groups[0]))
    }

    @Test("load：/projects 传输失败 → 显式错误文案；重试成功后清除且列表可用")
    func loadProjectsFailureIsExplicitAndRetryable() async throws {
        let fixture = try makeFixture()
        await fixture.http.queueFailure(
            url: fixture.projectsURL, error: URLError(.notConnectedToInternet)
        )
        await fixture.http.queueSuccess(
            url: fixture.prefsURL, body: Data(Self.prefsJSON.utf8)
        )

        await fixture.viewModel.load()

        #expect(fixture.viewModel.fetchErrorMessage != nil)
        #expect(fixture.viewModel.hasLoadedOnce == false)

        await fixture.http.queueSuccess(
            url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
        )
        await fixture.viewModel.refresh()

        #expect(fixture.viewModel.fetchErrorMessage == nil)
        #expect(fixture.viewModel.hasLoadedOnce)
        #expect(fixture.viewModel.groups.isEmpty == false)
    }

    @Test("refresh 只重取 /projects，不再打 /prefs（prefs 留在内存，镜像 web init）")
    func refreshDoesNotRefetchPrefs() async throws {
        let fixture = try makeFixture()
        try await loadHappyPath(fixture)
        await fixture.http.queueSuccess(
            url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
        )

        await fixture.viewModel.refresh()

        let prefsGets = await fixture.http.recordedRequests.filter {
            $0.url == fixture.prefsURL && $0.httpMethod == "GET"
        }
        #expect(prefsGets.count == 1)
    }

    // MARK: - prefs 失败路径（clobber 防线）

    @Test("prefs 加载失败：项目照常显示 + 明确文案；随后 toggle 只改本地、绝不 PUT")
    func prefsLoadFailureNeverPuts() async throws {
        let fixture = try makeFixture()
        await fixture.http.queueSuccess(
            url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
        )
        await fixture.http.queueFailure(
            url: fixture.prefsURL, error: URLError(.timedOut)
        )
        await fixture.viewModel.load()

        #expect(fixture.viewModel.prefsErrorMessage == ProjectsCopy.prefsLoadFailed)
        #expect(fixture.viewModel.groups.isEmpty == false)

        await fixture.viewModel.toggleFavourite(path: "/repos/api")

        #expect(fixture.viewModel.favourites == ["/repos/api"]) // 本地仍生效
        let puts = await fixture.http.recordedRequests.filter { $0.httpMethod == "PUT" }
        #expect(puts.isEmpty) // 空底盘上 PUT = 清掉服务器 blob，绝不允许
    }

    // MARK: - favourites / collapse 往返（THE merge 测试）

    @Test("toggleFavourite：PUT body 保留未知键 webOnly + collapsed 原样 + Origin 铁律")
    func toggleFavouritePreservesUnknownKeysInPutBody() async throws {
        let fixture = try makeFixture()
        try await loadHappyPath(fixture)
        let echo = """
        {"favourites":["/repos/web","/repos/api"],"collapsed":{"Billo.Platform":true}}
        """
        await fixture.http.queueSuccess(
            method: "PUT", url: fixture.prefsURL, body: Data(echo.utf8)
        )

        await fixture.viewModel.toggleFavourite(path: "/repos/api")

        let bodies = try await recordedPutBodies(fixture)
        #expect(bodies.count == 1)
        let body = try #require(bodies.first)
        #expect(body["favourites"] as? [String] == ["/repos/web", "/repos/api"])
        #expect(body["collapsed"] as? [String: Bool] == ["Billo.Platform": true])
        #expect(body["webOnly"] as? [String: String] == ["theme": "dark"]) // 未知键保留
        let put = try #require(
            await fixture.http.recordedRequests.first { $0.httpMethod == "PUT" }
        )
        #expect(
            put.value(forHTTPHeaderField: "Origin") == fixture.host.endpoint.originHeader
        )
        // 采纳服务器 echo 为新真相。
        #expect(fixture.viewModel.favourites == ["/repos/web", "/repos/api"])
        #expect(fixture.viewModel.prefsSyncErrorMessage == nil)
    }

    @Test("toggleFavourite 取消收藏：从数组移除，其余顺序不变")
    func toggleFavouriteRemoves() async throws {
        let fixture = try makeFixture()
        try await loadHappyPath(fixture)
        await fixture.http.queueSuccess(
            method: "PUT", url: fixture.prefsURL,
            body: Data(#"{"favourites":[],"collapsed":{"Billo.Platform":true}}"#.utf8)
        )

        await fixture.viewModel.toggleFavourite(path: "/repos/web")

        let body = try #require(try await recordedPutBodies(fixture).first)
        #expect(body["favourites"] as? [String] == [])
    }

    @Test("toggleCollapsed：展开只删自己的 key；favourites 与未知键原样保留")
    func toggleCollapsedRemovesOnlyItsKey() async throws {
        let fixture = try makeFixture()
        try await loadHappyPath(fixture)
        await fixture.http.queueSuccess(
            method: "PUT", url: fixture.prefsURL,
            body: Data(#"{"favourites":["/repos/web"],"collapsed":{}}"#.utf8)
        )

        await fixture.viewModel.toggleCollapsed(key: "Billo.Platform") // 展开

        let body = try #require(try await recordedPutBodies(fixture).first)
        #expect(body["collapsed"] as? [String: Bool] == [:])
        #expect(body["favourites"] as? [String] == ["/repos/web"])
        #expect(body["webOnly"] as? [String: String] == ["theme": "dark"])
        #expect(fixture.viewModel.collapsedGroups == [:])
    }

    @Test("PUT 失败：本地改动保留 + 同步失败文案（下次 toggle 重试）")
    func putFailureKeepsLocalChangeAndSurfacesError() async throws {
        let fixture = try makeFixture()
        try await loadHappyPath(fixture)
        await fixture.http.queueFailure(
            method: "PUT", url: fixture.prefsURL, error: URLError(.timedOut)
        )

        await fixture.viewModel.toggleFavourite(path: "/repos/api")

        #expect(fixture.viewModel.favourites == ["/repos/web", "/repos/api"])
        #expect(fixture.viewModel.prefsSyncErrorMessage != nil)
    }

    // MARK: - 折叠与搜索

    @Test("isCollapsed：搜索中强制展开（结果绝不藏在折叠 caret 后）")
    func searchingForcesSectionsOpen() async throws {
        let fixture = try makeFixture()
        try await loadHappyPath(fixture)
        let group = try #require(fixture.viewModel.groups.first)
        #expect(fixture.viewModel.isCollapsed(group))

        fixture.viewModel.searchText = "api"

        let searched = try #require(fixture.viewModel.groups.first)
        #expect(fixture.viewModel.isCollapsed(searched) == false)
    }

    // MARK: - 在此仓库开新会话

    @Test("requestOpenClaude：绝对路径 → OpenRequest{host,cwd,claude\\r}")
    func requestOpenClaudeMintsRequest() async throws {
        let fixture = try makeFixture()

        fixture.viewModel.requestOpenClaude(cwd: "/repos/api")

        let request = try #require(fixture.viewModel.openRequest)
        #expect(request.host == fixture.host)
        #expect(request.cwd == "/repos/api")
        #expect(request.bootstrapInput == ProjectLaunch.claudeBootstrapInput)
        #expect(ProjectLaunch.claudeBootstrapInput == "claude\r") // Enter 是 \r 不是 \n
    }

    @Test("requestOpenClaude：相对路径拒绝（服务器数据不可信）→ 无请求 + 文案")
    func requestOpenClaudeRejectsRelativePath() async throws {
        let fixture = try makeFixture()

        fixture.viewModel.requestOpenClaude(cwd: "repos/api")

        #expect(fixture.viewModel.openRequest == nil)
        #expect(fixture.viewModel.openErrorMessage == ProjectsCopy.openClaudeInvalidPath)
    }
}
