import Foundation
import Testing
import WireProtocol
@testable import WebTerm

/// T-iOS-27 · Diff 查看器 —— `DiffViewModel`（phase 状态机 + 行平铺）。
///
/// 覆盖面（Steps 测试先行）：
/// - `DiffResult{files,staged,truncated}` → 呈现：文件头 → hunk 头 → 行，
///   平铺为惰性列表的行模型（巨 diff 不合成单个 Text 块）；
/// - truncated → banner 数据位（空态与非空态都要携带）；
/// - staged/unstaged 切换 → 以新 staged 重新 fetch；同值不重复请求；
/// - path 非法（400/404）→ 显式友好错误 phase，其余错误 → 可重试的 unavailable；
/// - binary 文件短路（镜像 web renderDiffFile 的 early return）、renamed 路径标签。
@MainActor
@Suite("DiffViewModel")
struct DiffViewModelTests {
    // MARK: - Fixtures

    private nonisolated static func line(_ kind: DiffLineKind, _ text: String) -> DiffLine {
        DiffLine(kind: kind, text: text)
    }

    private nonisolated static func modifiedFile() -> DiffFile {
        DiffFile(
            oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified,
            added: 1, removed: 1, binary: false,
            hunks: [DiffHunk(header: "@@ -1,2 +1,2 @@", lines: [
                line(.removed, "old"),
                line(.added, "new"),
            ])]
        )
    }

    private nonisolated static func result(
        files: [DiffFile], staged: Bool = false, truncated: Bool = false
    ) -> DiffResult {
        DiffResult(files: files, staged: staged, truncated: truncated)
    }

    /// 记录每次 fetch 收到的 staged 参数（@Sendable 闭包内可变状态 → actor）。
    private actor FetchRecorder {
        private(set) var stagedArgs: [Bool] = []
        func record(_ staged: Bool) { stagedArgs = stagedArgs + [staged] }
    }

    // MARK: - Phase 状态机

    @Test("初始：phase = .loading、staged = false（默认工作区视图）")
    func initialState() {
        let vm = DiffViewModel(fetch: { _ in Self.result(files: []) })

        #expect(vm.phase == .loading)
        #expect(vm.staged == false)
    }

    @Test("load 成功（非空）→ .loaded，行序：文件头 → hunk 头 → 行，id 稳定递增")
    func loadFlattensRowsInOrder() async throws {
        let vm = DiffViewModel(fetch: { _ in Self.result(files: [Self.modifiedFile()]) })

        await vm.load()

        guard case .loaded(let presentation) = vm.phase else {
            Issue.record("期望 .loaded，实际 \(vm.phase)")
            return
        }
        #expect(presentation.truncated == false)
        #expect(presentation.rows.map(\.id) == [0, 1, 2, 3]) // Identifiable：惰性列表身份
        #expect(presentation.rows.map(\.kind) == [
            .fileHeader(DiffFileHeader(
                pathLabel: "src/a.ts", added: 1, removed: 1, status: .modified
            )),
            .hunkHeader("@@ -1,2 +1,2 @@"),
            .line(kind: .removed, text: "old"),
            .line(kind: .added, text: "new"),
        ])
    }

    @Test("空 files → .empty（truncated 数据位透传）", arguments: [false, true])
    func emptyFilesBecomeEmptyPhase(truncated: Bool) async {
        let vm = DiffViewModel(fetch: { _ in
            Self.result(files: [], truncated: truncated)
        })

        await vm.load()

        #expect(vm.phase == .empty(truncated: truncated))
    }

    @Test("truncated + 非空 → .loaded 且 presentation.truncated = true（banner 提示位）")
    func truncatedFlagSurvivesIntoPresentation() async throws {
        let vm = DiffViewModel(fetch: { _ in
            Self.result(files: [Self.modifiedFile()], truncated: true)
        })

        await vm.load()

        guard case .loaded(let presentation) = vm.phase else {
            Issue.record("期望 .loaded，实际 \(vm.phase)")
            return
        }
        #expect(presentation.truncated == true)
    }

    // MARK: - 行平铺规则（镜像 web renderDiffFile）

    @Test("binary 文件：文件头 + 二进制占位，hunk 全部短路（web early return 同款）")
    func binaryFileShortCircuitsHunks() {
        let binary = DiffFile(
            oldPath: "logo.png", newPath: "logo.png", status: .binary,
            added: 0, removed: 0, binary: true,
            hunks: [DiffHunk(header: "@@ junk @@", lines: [Self.line(.added, "x")])]
        )

        let rows = DiffViewModel.makeRows(files: [binary])

        #expect(rows.map(\.kind) == [
            .fileHeader(DiffFileHeader(
                pathLabel: "logo.png", added: 0, removed: 0, status: .binary
            )),
            .binaryNotice,
        ])
    }

    @Test("renamed 且新旧路径不同 → 路径标签 “old → new”（web 同款箭头）")
    func renamedFileShowsArrowLabel() {
        let renamed = DiffFile(
            oldPath: "old/name.ts", newPath: "new/name.ts", status: .renamed,
            added: 0, removed: 0, binary: false, hunks: []
        )

        let rows = DiffViewModel.makeRows(files: [renamed])

        guard case .fileHeader(let header)? = rows.first?.kind else {
            Issue.record("期望 fileHeader，实际 \(String(describing: rows.first))")
            return
        }
        #expect(header.pathLabel == "old/name.ts → new/name.ts")
    }

    @Test("untracked（0 hunk、非 binary）→ 仅文件头行")
    func untrackedFileIsHeaderOnly() {
        let untracked = DiffFile(
            oldPath: "notes.md", newPath: "notes.md", status: .untracked,
            added: 0, removed: 0, binary: false, hunks: []
        )

        let rows = DiffViewModel.makeRows(files: [untracked])

        #expect(rows.count == 1)
        #expect(rows.first?.kind == .fileHeader(DiffFileHeader(
            pathLabel: "notes.md", added: 0, removed: 0, status: .untracked
        )))
    }

    // MARK: - 错误映射（400/404 → 友好错误，任务 Steps）

    @Test("DiffFetchError → Failure 映射：400/非法请求 → pathInvalid，404 → notFound，其余 → unavailable")
    func fetchErrorsMapToFailures() async {
        struct RandomError: Error {}
        let cases: [(any Error, DiffViewModel.Failure)] = [
            (DiffFetchError.pathInvalid, .pathInvalid),
            (DiffFetchError.invalidRequest, .pathInvalid),
            (DiffFetchError.projectNotFound, .notFound),
            (DiffFetchError.invalidResponse, .unavailable),
            (DiffFetchError.unexpectedStatus(500), .unavailable),
            (RandomError(), .unavailable),
        ]
        for (thrown, expected) in cases {
            let vm = DiffViewModel(fetch: { _ in throw thrown })

            await vm.load()

            #expect(vm.phase == .failed(expected))
        }
    }

    @Test("重试：失败后再次 load 成功 → .loaded（错误态可恢复）")
    func retryAfterFailureRecovers() async {
        struct Boom: Error {}
        let recorder = FetchRecorder()
        let vm = DiffViewModel(fetch: { staged in
            await recorder.record(staged)
            if await recorder.stagedArgs.count == 1 { throw Boom() }
            return Self.result(files: [Self.modifiedFile()])
        })

        await vm.load()
        #expect(vm.phase == .failed(.unavailable))

        await vm.load() // DiffScreen「重试」按钮走的就是这条路径

        guard case .loaded = vm.phase else {
            Issue.record("期望 .loaded，实际 \(vm.phase)")
            return
        }
    }

    // MARK: - staged/unstaged 切换（re-fetch 语义）

    @Test("setStaged(true) → 以 staged=true 重新 fetch；同值再设 → 不重复请求")
    func setStagedRefetchesOnlyOnChange() async {
        let recorder = FetchRecorder()
        let vm = DiffViewModel(fetch: { staged in
            await recorder.record(staged)
            return Self.result(files: [], staged: staged)
        })
        await vm.load()

        await vm.setStaged(true)
        await vm.setStaged(true)  // 同值：必须是 no-op
        await vm.setStaged(false)

        #expect(vm.staged == false)
        #expect(await recorder.stagedArgs == [false, true, false])
    }

    // MARK: - 用户可见文案（中文具名常量；空态 ≠ 错误态）

    @Test("文案常量非空且空态/错误态互不相同")
    func copyConstantsAreDistinct() {
        #expect(!DiffCopy.title.isEmpty)
        #expect(!DiffCopy.truncatedBanner.isEmpty)
        #expect(!DiffCopy.emptyTitle.isEmpty)
        #expect(!DiffCopy.binaryFile.isEmpty)
        #expect(!DiffCopy.retry.isEmpty)
        #expect(DiffCopy.emptyTitle != DiffCopy.failedUnavailable)
        #expect(DiffCopy.failedPathInvalid != DiffCopy.failedNotFound)
    }

    @Test("状态标签全函数映射（含全部六种 FileStatus，中文）")
    func statusLabelsAreTotalAndChinese() {
        let all: [DiffFileStatus] = [
            .modified, .added, .deleted, .renamed, .binary, .untracked,
        ]
        for status in all {
            #expect(!DiffStatusStyle.label(for: status).isEmpty)
        }
        #expect(DiffStatusStyle.label(for: .added) == "新增")
        #expect(DiffStatusStyle.label(for: .deleted) == "删除")
    }
}
