import Foundation
import Testing

@testable import NucleicCore

@Suite struct TranscriptRenderIndexTests {
  private func event(_ kind: AgentEvent.Kind, seq: UInt64 = 0) -> AgentEvent {
    AgentEvent(
      sessionID: SessionID(rawValue: "index-session"), seq: seq,
      at: Date(timeIntervalSince1970: 1_750_000_000), backend: .claudeCode,
      nativeType: "test", kind: kind)
  }

  private func fixture() -> (url: URL, header: SessionHeader) {
    let url = FileManager.default.temporaryDirectory
      .appendingPathComponent("nucleic-index-tests-\(UUID().uuidString)/transcript.jsonl")
    let header = SessionHeader(
      sessionID: SessionID(rawValue: "index-session"), backend: .claudeCode,
      worktree: "/tmp/wt", createdAt: Date(timeIntervalSince1970: 1_750_000_000))
    return (url, header)
  }

  @Test func writerMaintainsBoundedIndexAndHydratesCanonicalRanges() async throws {
    let (url, header) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let writer = try TranscriptWriter(url: url, header: header)
    let command = String(repeating: "echo x ", count: 1_000)
    let call = try await writer.append(
      event(
        .toolCallCompleted(
          ToolCall(
            toolCallID: "tool-1", name: "Bash", input: ["command": .string(command)]))))
    let largeResult = String(repeating: "r", count: 200_000) + "UNINDEXED-TAIL-SENTINEL"
    let result = try await writer.append(
      event(
        .toolResult(
          ToolResult(
            toolCallID: "tool-1", content: .string(largeResult), isError: false))))
    let reply = try await writer.append(
      event(
        .assistantText(
          TextChunk(
            messageID: "reply", text: "Finished", isPartial: false))))
    try await writer.close()

    let reader = TranscriptRenderIndex.Reader(transcriptURL: url)
    let validation = try reader.validate(expectedLastSequence: 3)
    #expect(validation.header.schemaVersion == TranscriptRenderIndex.currentSchemaVersion)
    #expect(validation.header.transcriptID == header.sessionID)
    let batch = try reader.readReverseBatch(maximumRecords: 10)
    #expect(batch.nextByteOffset == nil)
    #expect(batch.records.map(\.sequence) == [1, 2, 3])

    let callRecord = try #require(batch.records.first { $0.sequence == call.seq })
    #expect(callRecord.kind == "toolCallCompleted")
    #expect(callRecord.toolCallID == "tool-1")
    #expect(callRecord.toolName == "Bash")
    #expect(callRecord.displayLabel == "Nash")
    #expect(
      (callRecord.displayDetail?.utf8.count ?? 0)
        <= TranscriptRenderIndex.maximumDisplayDetailBytes)
    #expect(callRecord.payloadShape.kind == .object)
    #expect(callRecord.payloadShape.topLevelCount == 1)

    let resultRecord = try #require(batch.records.first { $0.sequence == result.seq })
    #expect(resultRecord.lifecycle.resultPresent)
    #expect(!resultRecord.lifecycle.isError)
    #expect(resultRecord.payloadShape.kind == .string)
    #expect(
      (resultRecord.plainTextExcerpt?.utf8.count ?? 0)
        <= TranscriptRenderIndex.maximumPlainTextExcerptBytes)
    #expect(try reader.hydrate(resultRecord) == result)
    #expect(try reader.hydrate(try #require(batch.records.last)) == reply)

    let preview = try reader.previewEvents(for: batch.records, validation: validation)
    guard case .toolResult(let previewResult) = preview[1].kind else {
      Issue.record("expected a tool-result preview skeleton")
      return
    }
    #expect(
      previewResult.content.stringValue?.utf8.count
        == TranscriptRenderIndex.maximumPlainTextExcerptBytes)
    #expect(preview[2] == reply)

    let canonicalBytes = try Data(contentsOf: url).count
    let indexData = try Data(contentsOf: TranscriptRenderIndex.url(for: url))
    #expect(indexData.count < canonicalBytes / 10)
    #expect(!String(decoding: indexData, as: UTF8.self).contains("UNINDEXED-TAIL-SENTINEL"))
  }

  @Test func reversePagingUsesIndexCursorsAndPreservesOrder() async throws {
    let (url, header) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let writer = try TranscriptWriter(url: url, header: header)
    for index in 1...23 {
      try await writer.append(
        event(
          .assistantText(
            TextChunk(
              messageID: "m\(index)", text: "event \(index)", isPartial: false))))
    }
    try await writer.close()

    let reader = TranscriptRenderIndex.Reader(transcriptURL: url)
    _ = try reader.validate(expectedLastSequence: 23)
    var offset: Int?
    var reconstructed: [TranscriptRenderIndex.Record] = []
    var sizes: [Int] = []
    repeat {
      let page = try reader.readReverseBatch(
        beforeByteOffset: offset, maximumRecords: 5,
        maximumEncodedBytes: 1_000_000)
      sizes.append(page.records.count)
      reconstructed.insert(contentsOf: page.records, at: 0)
      offset = page.nextByteOffset
    } while offset != nil

    #expect(sizes == [5, 5, 5, 5, 3])
    #expect(reconstructed.map(\.sequence) == Array(1...23).map(UInt64.init))
  }

  @Test func emptyTranscriptIndexValidatesWithoutARecord() async throws {
    let (url, header) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let writer = try TranscriptWriter(url: url, header: header)
    try await writer.close()

    let reader = TranscriptRenderIndex.Reader(transcriptURL: url)
    let validation = try reader.validate(expectedLastSequence: 0)
    #expect(validation.lastSequence == 0)
    #expect(try reader.readReverseBatch().records.isEmpty)
  }

  @Test func staleIndexIsRejectedWhenCanonicalSizeChanges() async throws {
    let (url, header) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let writer = try TranscriptWriter(url: url, header: header)
    try await writer.append(
      event(
        .assistantText(
          TextChunk(
            messageID: "m", text: "hello", isPartial: false))))
    try await writer.close()

    let reader = TranscriptRenderIndex.Reader(transcriptURL: url)
    _ = try reader.validate(expectedLastSequence: 1)
    let handle = try FileHandle(forWritingTo: url)
    try handle.seekToEnd()
    try handle.write(contentsOf: Data(" \n".utf8))
    try handle.close()
    #expect(throws: TranscriptRenderIndex.IndexError.self) {
      _ = try reader.validate(expectedLastSequence: 1)
    }
  }

  @Test func unsupportedSchemaAndCorruptRangesFailClosed() async throws {
    let (url, header) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let writer = try TranscriptWriter(url: url, header: header)
    try await writer.append(
      event(.assistantText(TextChunk(messageID: "m", text: "hello", isPartial: false))))
    try await writer.close()

    let reader = TranscriptRenderIndex.Reader(transcriptURL: url)
    let record = try #require(try reader.readReverseBatch().records.last)
    let indexURL = TranscriptRenderIndex.url(for: url)
    let original = String(decoding: try Data(contentsOf: indexURL), as: UTF8.self)
    let futureSchema = original.replacingOccurrences(
      of: "\"schemaVersion\":1", with: "\"schemaVersion\":999")
    try Data(futureSchema.utf8).write(to: indexURL, options: .atomic)
    #expect(throws: TranscriptRenderIndex.IndexError.unsupportedSchema(999)) {
      _ = try reader.validate(expectedLastSequence: 1)
    }

    let corruptRange = original.replacingOccurrences(
      of: "\"canonicalByteLength\":\(record.canonicalByteLength)",
      with: "\"canonicalByteLength\":-1")
    try Data(corruptRange.utf8).write(to: indexURL, options: .atomic)
    #expect(throws: TranscriptRenderIndex.IndexError.self) {
      _ = try reader.validate(expectedLastSequence: 1)
    }
  }

  @Test func absentIndexRebuildsFromAlreadyDecodedEvents() async throws {
    let (url, header) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let writer = try TranscriptWriter(url: url, header: header)
    try await writer.append(
      event(
        .assistantText(
          TextChunk(
            messageID: "m", text: "hello", isPartial: false))))
    try await writer.close()
    let decoded = try TranscriptReader(url: url).read().events

    try TranscriptRenderIndex.invalidate(for: url)
    #expect(!FileManager.default.fileExists(atPath: TranscriptRenderIndex.url(for: url).path))
    try TranscriptRenderIndex.rebuild(transcriptURL: url, events: decoded)

    let reader = TranscriptRenderIndex.Reader(transcriptURL: url)
    #expect(try reader.validate(expectedLastSequence: 1).lastSequence == 1)
    let record = try #require(try reader.readReverseBatch().records.first)
    #expect(try reader.hydrate(record) == decoded[0])
  }

  @Test func reopenedWriterContinuesAValidatedIndex() async throws {
    let (url, header) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let firstWriter = try TranscriptWriter(url: url, header: header)
    try await firstWriter.append(
      event(.assistantText(TextChunk(messageID: "m1", text: "one", isPartial: false))))
    try await firstWriter.close()

    let resumedWriter = try TranscriptWriter(appendingTo: url, lastSeq: 1)
    try await resumedWriter.append(
      event(.assistantText(TextChunk(messageID: "m2", text: "two", isPartial: false))))
    try await resumedWriter.close()

    let reader = TranscriptRenderIndex.Reader(transcriptURL: url)
    #expect(try reader.validate(expectedLastSequence: 2).lastSequence == 2)
    let records = try reader.readReverseBatch(maximumRecords: 10).records
    #expect(records.map(\.sequence) == [1, 2])
    #expect(
      try records.map { try reader.hydrate($0) }
        == TranscriptReader(url: url).read().events)
  }

  @Test func truncateRebuildsIndexForNewEpochAndOffsets() async throws {
    let (url, originalHeader) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let writer = try TranscriptWriter(url: url, header: originalHeader)
    var appended: [AgentEvent] = []
    for index in 1...5 {
      appended.append(
        try await writer.append(
          event(
            .assistantText(
              TextChunk(
                messageID: "m\(index)", text: "event \(index)", isPartial: false)))))
    }
    var revertedHeader = originalHeader
    revertedHeader.revertEpoch = 1
    try await writer.truncate(to: Array(appended.prefix(3)), header: revertedHeader)
    try await writer.append(
      event(
        .assistantText(
          TextChunk(
            messageID: "replacement", text: "new", isPartial: false))))
    try await writer.close()

    let reader = TranscriptRenderIndex.Reader(transcriptURL: url)
    let validation = try reader.validate(expectedLastSequence: 4)
    #expect(validation.header.revertEpoch == 1)
    let records = try reader.readReverseBatch(maximumRecords: 10).records
    #expect(records.map(\.sequence) == [1, 2, 3, 4])
    let canonical = try TranscriptReader(url: url).read().events
    #expect(try records.map { try reader.hydrate($0) } == canonical)
  }

  @Test func canonicalHeaderRewriteInvalidatesDerivedIndex() async throws {
    let (url, originalHeader) = fixture()
    defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
    let writer = try TranscriptWriter(url: url, header: originalHeader)
    try await writer.append(
      event(
        .assistantText(
          TextChunk(
            messageID: "m", text: "hello", isPartial: false))))
    try await writer.close()
    #expect(FileManager.default.fileExists(atPath: TranscriptRenderIndex.url(for: url).path))

    let replacement = TranscriptHeaderRewriter.destinationHeader(
      from: originalHeader, worktree: "/new/worktree",
      nativeTranscriptPath: nil, backendSessionID: nil)
    try TranscriptHeaderRewriter.rewrite(at: url, to: replacement)

    #expect(!FileManager.default.fileExists(atPath: TranscriptRenderIndex.url(for: url).path))
    #expect(try TranscriptReader(url: url).read().events.count == 1)
  }
}
