import Foundation

/// A rebuildable, payload-light companion to a canonical transcript.
///
/// The canonical JSONL remains the source of truth. This sidecar contains only bounded display
/// metadata plus byte ranges back into that file, so a collapsed transcript tail can be assembled
/// without decoding multi-megabyte tool results or hidden native events. A missing, stale, or
/// corrupt index is never a transcript error: callers fall back to ``TranscriptReader`` and may
/// rebuild this derived file at background QoS.
public enum TranscriptRenderIndex {
  public static let currentSchemaVersion = 1
  public static let maximumDisplayLabelBytes = 160
  public static let maximumDisplayDetailBytes = 512
  public static let maximumPlainTextExcerptBytes = 2_048

  /// `transcript.jsonl` -> `transcript.render-index.jsonl`.
  public static func url(for transcriptURL: URL) -> URL {
    transcriptURL.deletingPathExtension().appendingPathExtension("render-index.jsonl")
  }

  public struct Header: Sendable, Codable, Equatable {
    public let schemaVersion: Int
    public let transcriptID: SessionID
    public let backend: BackendID
    public let revertEpoch: UInt64
    /// Offset of the first canonical event (the complete line-0 byte length, including LF).
    public let canonicalHeaderByteLength: UInt64

    public init(
      schemaVersion: Int = TranscriptRenderIndex.currentSchemaVersion,
      transcriptID: SessionID,
      backend: BackendID,
      revertEpoch: UInt64,
      canonicalHeaderByteLength: UInt64
    ) {
      self.schemaVersion = schemaVersion
      self.transcriptID = transcriptID
      self.backend = backend
      self.revertEpoch = revertEpoch
      self.canonicalHeaderByteLength = canonicalHeaderByteLength
    }
  }

  public enum PayloadKind: String, Sendable, Codable, Equatable {
    case none, null, bool, number, string, array, object
  }

  /// O(1)-to-derive shape information for the event's potentially large display payload.
  public struct PayloadShape: Sendable, Codable, Equatable {
    public let kind: PayloadKind
    /// Array element or object-key count. Nil for scalars and events without a display payload.
    public let topLevelCount: Int?
    /// Complete canonical event-line bytes, including its trailing LF when present.
    public let encodedEventBytes: Int

    public init(kind: PayloadKind, topLevelCount: Int?, encodedEventBytes: Int) {
      self.kind = kind
      self.topLevelCount = topLevelCount
      self.encodedEventBytes = encodedEventBytes
    }
  }

  /// Flags needed to project lifecycle state without hydrating the event body.
  public struct LifecycleFlags: Sendable, Codable, Equatable {
    public let isPartial: Bool
    public let isFinished: Bool
    public let resultPresent: Bool
    public let isError: Bool
    public let hiddenByDefault: Bool

    public init(
      isPartial: Bool = false,
      isFinished: Bool = false,
      resultPresent: Bool = false,
      isError: Bool = false,
      hiddenByDefault: Bool = false
    ) {
      self.isPartial = isPartial
      self.isFinished = isFinished
      self.resultPresent = resultPresent
      self.isError = isError
      self.hiddenByDefault = hiddenByDefault
    }
  }

  public enum CheckpointKind: String, Sendable, Codable, Equatable {
    case settledMessage
    case settledTurn
  }

  public struct Checkpoint: Sendable, Codable, Equatable {
    public let kind: CheckpointKind
    public let messageID: String?

    public init(kind: CheckpointKind, messageID: String? = nil) {
      self.kind = kind
      self.messageID = messageID
    }
  }

  public struct Record: Sendable, Codable, Equatable {
    public let sequence: UInt64
    public let timestamp: Date
    /// ``AgentEvent.Kind/tag``. Kept as a string so a newer writer does not make an older
    /// sidecar decoder fail before the schema/version gate can choose a safe fallback.
    public let kind: String
    public let messageID: String?
    public let toolCallID: String?
    public let parentToolCallID: String?
    public let lifecycle: LifecycleFlags

    /// Exact address of the complete event line in canonical JSONL.
    public let canonicalByteOffset: UInt64
    public let canonicalByteLength: Int

    public let toolName: String?
    public let displayLabel: String?
    public let displayDetail: String?
    public let plainTextExcerpt: String?
    public let payloadShape: PayloadShape
    /// Cheap SwiftUI/presentation invalidation token. Sequences are unique within the header's
    /// revert epoch; a revert replaces the whole index and therefore creates a new namespace.
    public let presentationRevision: UInt64
    public let checkpoint: Checkpoint?

    public init(
      sequence: UInt64,
      timestamp: Date,
      kind: String,
      messageID: String? = nil,
      toolCallID: String? = nil,
      parentToolCallID: String? = nil,
      lifecycle: LifecycleFlags,
      canonicalByteOffset: UInt64,
      canonicalByteLength: Int,
      toolName: String? = nil,
      displayLabel: String? = nil,
      displayDetail: String? = nil,
      plainTextExcerpt: String? = nil,
      payloadShape: PayloadShape,
      presentationRevision: UInt64,
      checkpoint: Checkpoint? = nil
    ) {
      self.sequence = sequence
      self.timestamp = timestamp
      self.kind = kind
      self.messageID = messageID
      self.toolCallID = toolCallID
      self.parentToolCallID = parentToolCallID
      self.lifecycle = lifecycle
      self.canonicalByteOffset = canonicalByteOffset
      self.canonicalByteLength = canonicalByteLength
      self.toolName = toolName
      self.displayLabel = displayLabel
      self.displayDetail = displayDetail
      self.plainTextExcerpt = plainTextExcerpt
      self.payloadShape = payloadShape
      self.presentationRevision = presentationRevision
      self.checkpoint = checkpoint
    }
  }

  public struct Validation: Sendable, Equatable {
    public let header: Header
    public let transcriptHeader: SessionHeader
    public let canonicalFileSize: UInt64
    public let lastSequence: UInt64

    public init(
      header: Header, transcriptHeader: SessionHeader,
      canonicalFileSize: UInt64, lastSequence: UInt64
    ) {
      self.header = header
      self.transcriptHeader = transcriptHeader
      self.canonicalFileSize = canonicalFileSize
      self.lastSequence = lastSequence
    }
  }

  public struct ReverseBatch: Sendable, Equatable {
    public let records: [Record]
    /// Byte cursor in the *index* JSONL, not the canonical transcript.
    public let nextByteOffset: Int?

    public init(records: [Record], nextByteOffset: Int?) {
      self.records = records
      self.nextByteOffset = nextByteOffset
    }
  }

  public enum IndexError: Error, Sendable, Equatable, CustomStringConvertible {
    case missing
    case unsupportedSchema(Int)
    case corrupt(String)
    case stale(String)

    public var description: String {
      switch self {
      case .missing: "Transcript render index is missing"
      case .unsupportedSchema(let version): "Unsupported transcript render index schema \(version)"
      case .corrupt(let reason): "Corrupt transcript render index: \(reason)"
      case .stale(let reason): "Stale transcript render index: \(reason)"
      }
    }
  }

  /// Read/validate the sidecar and selectively hydrate canonical events by byte range.
  public struct Reader: Sendable {
    public let transcriptURL: URL
    public let indexURL: URL

    public init(transcriptURL: URL) {
      self.transcriptURL = transcriptURL
      self.indexURL = TranscriptRenderIndex.url(for: transcriptURL)
    }

    /// Validate without decoding any canonical event. Only canonical line 0, file attributes,
    /// and the sidecar's first/last records are visited, so one oversized final tool result does
    /// not become a startup latency barrier.
    public func validate(expectedLastSequence: UInt64? = nil) throws -> Validation {
      guard FileManager.default.fileExists(atPath: indexURL.path) else {
        throw IndexError.missing
      }
      let data = try Data(contentsOf: indexURL, options: .mappedIfSafe)
      guard !data.isEmpty, let firstLF = data.firstIndex(of: 0x0A) else {
        throw IndexError.corrupt("missing header line")
      }
      let decoder = Self.decoder()
      let indexHeader: Header
      do {
        indexHeader = try decoder.decode(Header.self, from: Data(data[..<firstLF]))
      } catch {
        throw IndexError.corrupt("invalid header")
      }
      guard indexHeader.schemaVersion == currentSchemaVersion else {
        throw IndexError.unsupportedSchema(indexHeader.schemaVersion)
      }

      let transcriptHeader = try TranscriptReader(url: transcriptURL).readHeader()
      guard indexHeader.transcriptID == transcriptHeader.sessionID,
        indexHeader.backend == transcriptHeader.backend
      else { throw IndexError.stale("transcript identity changed") }
      guard indexHeader.revertEpoch == transcriptHeader.revertEpoch ?? 0 else {
        throw IndexError.stale("revert epoch changed")
      }

      let canonicalSize = try canonicalFileSize()

      var upper = data.endIndex
      while upper > data.startIndex, data[data.index(before: upper)] == 0x0A {
        upper = data.index(before: upper)
      }
      let lastRecord: Record?
      let recordsStart = data.index(after: firstLF)
      if upper <= recordsStart {
        lastRecord = nil
      } else {
        let recordStart =
          data[recordsStart..<upper].lastIndex(of: 0x0A).map {
            data.index(after: $0)
          } ?? recordsStart
        do {
          lastRecord = try decoder.decode(Record.self, from: Data(data[recordStart..<upper]))
        } catch {
          throw IndexError.corrupt("invalid final record")
        }
      }
      let indexedEnd: UInt64
      if let lastRecord {
        guard lastRecord.canonicalByteLength > 0 else {
          throw IndexError.corrupt("invalid final canonical range")
        }
        let byteLength = UInt64(lastRecord.canonicalByteLength)
        guard lastRecord.canonicalByteOffset <= UInt64.max - byteLength else {
          throw IndexError.corrupt("final canonical range overflows")
        }
        indexedEnd = lastRecord.canonicalByteOffset + byteLength
      } else {
        indexedEnd = indexHeader.canonicalHeaderByteLength
      }
      guard indexedEnd == canonicalSize else {
        throw IndexError.stale("canonical file size changed")
      }
      let lastSequence = lastRecord?.sequence ?? 0
      if let expectedLastSequence, lastSequence != expectedLastSequence {
        throw IndexError.stale("last sequence changed")
      }
      return Validation(
        header: indexHeader, transcriptHeader: transcriptHeader,
        canonicalFileSize: canonicalSize, lastSequence: lastSequence)
    }

    /// Read a bounded page of lightweight records from the sidecar tail.
    public func readReverseBatch(
      beforeByteOffset: Int? = nil,
      maximumRecords: Int = 48,
      maximumEncodedBytes: Int = 128 * 1024
    ) throws -> ReverseBatch {
      let recordLimit = max(1, maximumRecords)
      let byteLimit = max(1, maximumEncodedBytes)
      let data = try Data(contentsOf: indexURL, options: .mappedIfSafe)
      guard let headerLF = data.firstIndex(of: 0x0A) else {
        throw IndexError.corrupt("missing header line")
      }
      let recordsStart = data.index(after: headerLF)
      var upper =
        beforeByteOffset.map {
          data.index(data.startIndex, offsetBy: min(max(0, $0), data.count))
        } ?? data.endIndex
      while upper > recordsStart, data[data.index(before: upper)] == 0x0A {
        upper = data.index(before: upper)
      }

      let decoder = Self.decoder()
      var newestFirst: [Record] = []
      newestFirst.reserveCapacity(recordLimit)
      var encodedBytes = 0
      var exhausted = false
      while upper > recordsStart {
        if Task.isCancelled { throw CancellationError() }
        let newline = data[recordsStart..<upper].lastIndex(of: 0x0A)
        let lower = newline.map { data.index(after: $0) } ?? recordsStart
        var line = data[lower..<upper]
        if line.last == 0x0D { line = line.dropLast() }
        upper = newline ?? recordsStart
        guard !line.isEmpty else { continue }
        do {
          newestFirst.append(try decoder.decode(Record.self, from: Data(line)))
        } catch {
          throw IndexError.corrupt("invalid record")
        }
        encodedBytes += line.count
        if newestFirst.count >= recordLimit || encodedBytes >= byteLimit { break }
      }
      if upper <= recordsStart { exhausted = true }
      let next = exhausted ? nil : data.distance(from: data.startIndex, to: upper)
      return ReverseBatch(records: newestFirst.reversed(), nextByteOffset: next)
    }

    /// Decode one complete canonical event by its indexed range and verify that the address
    /// still names the indexed sequence/kind. This is the primitive used by disclosure, copy,
    /// export, debug detail, and any future controller-demand hydration.
    public func hydrate(_ record: Record) throws -> AgentEvent {
      let handle = try FileHandle(forReadingFrom: transcriptURL)
      defer { try? handle.close() }
      return try hydrate(
        record, from: handle, canonicalFileSize: try canonicalFileSize(), decoder: Self.decoder())
    }

    /// Build preview events while preserving the lazy-payload contract: prose is exact, small
    /// structural events are exact, and tool-result/raw bodies remain bounded skeletons even
    /// when their canonical lines are multi-megabyte. The authoritative controller may later
    /// replace these values with complete history.
    public func previewEvents(
      for records: [Record], validation: Validation,
      maximumStructuralEventBytes: Int = 64 * 1024
    ) throws -> [AgentEvent] {
      let handle = try FileHandle(forReadingFrom: transcriptURL)
      defer { try? handle.close() }
      let decoder = Self.decoder()
      var result: [AgentEvent] = []
      result.reserveCapacity(records.count)
      for record in records {
        if Task.isCancelled { throw CancellationError() }
        let alwaysHydrateText =
          record.kind == "userText"
          || record.kind == "assistantText" || record.kind == "thinking"
        let payloadMustStayLazy = record.kind == "toolResult" || record.kind == "raw"
        if !payloadMustStayLazy,
          alwaysHydrateText || record.canonicalByteLength <= maximumStructuralEventBytes
        {
          result.append(
            try hydrate(
              record, from: handle, canonicalFileSize: validation.canonicalFileSize,
              decoder: decoder))
        } else {
          result.append(record.skeletonEvent(header: validation.transcriptHeader))
        }
      }
      return result
    }

    private func hydrate(
      _ record: Record, from handle: FileHandle, canonicalFileSize fileSize: UInt64,
      decoder: JSONDecoder
    ) throws -> AgentEvent {
      guard record.canonicalByteLength > 0 else {
        throw IndexError.corrupt("empty canonical range")
      }
      let byteLength = UInt64(record.canonicalByteLength)
      guard record.canonicalByteOffset <= fileSize,
        byteLength <= fileSize - record.canonicalByteOffset
      else { throw IndexError.stale("canonical range is outside the file") }
      try handle.seek(toOffset: record.canonicalByteOffset)
      var bytes = Data()
      while bytes.count < record.canonicalByteLength {
        let readCount = min(256 * 1024, record.canonicalByteLength - bytes.count)
        guard let chunk = try handle.read(upToCount: readCount),
          !chunk.isEmpty
        else { throw IndexError.stale("canonical range is truncated") }
        bytes.append(chunk)
      }
      if bytes.last == 0x0A { bytes.removeLast() }
      if bytes.last == 0x0D { bytes.removeLast() }
      let event: AgentEvent
      do {
        event = try decoder.decode(AgentEvent.self, from: bytes)
      } catch {
        throw IndexError.stale("canonical range no longer decodes")
      }
      guard event.seq == record.sequence, event.kind.tag == record.kind else {
        throw IndexError.stale("canonical range no longer matches record")
      }
      return event
    }

    private func canonicalFileSize() throws -> UInt64 {
      let attributes = try FileManager.default.attributesOfItem(atPath: transcriptURL.path)
      guard let sizeNumber = attributes[.size] as? NSNumber else {
        throw IndexError.corrupt("canonical file size unavailable")
      }
      return sizeNumber.uint64Value
    }

    fileprivate static func decoder() -> JSONDecoder {
      let decoder = JSONDecoder()
      decoder.dateDecodingStrategy = .iso8601
      return decoder
    }
  }

  /// Rebuild the complete sidecar into a temporary file, then atomically install it only if the
  /// canonical byte size is unchanged. Supplying already-decoded events avoids a second recursive
  /// payload decode in the normal startup fallback path.
  public static func rebuild(transcriptURL: URL, events knownEvents: [AgentEvent]? = nil) throws {
    let canonical = try Data(contentsOf: transcriptURL, options: .mappedIfSafe)
    guard let headerLF = canonical.firstIndex(of: 0x0A) else {
      throw IndexError.corrupt("canonical transcript has no header line")
    }
    let decoder = Reader.decoder()
    let transcriptHeader: SessionHeader
    do {
      transcriptHeader = try decoder.decode(SessionHeader.self, from: Data(canonical[..<headerLF]))
    } catch {
      throw IndexError.corrupt("canonical transcript header does not decode")
    }
    let canonicalHeaderLength = canonical.distance(
      from: canonical.startIndex, to: canonical.index(after: headerLF))
    let indexHeader = Header(
      transcriptID: transcriptHeader.sessionID,
      backend: transcriptHeader.backend,
      revertEpoch: transcriptHeader.revertEpoch ?? 0,
      canonicalHeaderByteLength: UInt64(canonicalHeaderLength))

    let destination = url(for: transcriptURL)
    try FileManager.default.createDirectory(
      at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
    let temporary = destination.deletingLastPathComponent().appendingPathComponent(
      ".\(destination.lastPathComponent).\(UUID().uuidString).tmp")
    FileManager.default.createFile(atPath: temporary.path, contents: nil)
    let handle = try FileHandle(forWritingTo: temporary)
    var installed = false
    defer {
      try? handle.close()
      if !installed { try? FileManager.default.removeItem(at: temporary) }
    }

    let encoder = makeEncoder()
    var output = try encoder.encode(indexHeader)
    output.append(0x0A)
    func flushIfNeeded(_ force: Bool = false) throws {
      if force || output.count >= 256 * 1024 {
        try handle.write(contentsOf: output)
        output.removeAll(keepingCapacity: true)
      }
    }

    var eventIndex = 0
    var lineStart = canonical.index(after: headerLF)
    while lineStart < canonical.endIndex {
      if eventIndex.isMultiple(of: 256), Task.isCancelled { throw CancellationError() }
      let newline = canonical[lineStart...].firstIndex(of: 0x0A)
      let contentEnd = newline ?? canonical.endIndex
      var content = canonical[lineStart..<contentEnd]
      if content.last == 0x0D { content = content.dropLast() }
      let lineEnd = newline.map { canonical.index(after: $0) } ?? canonical.endIndex
      if !content.isEmpty {
        let event: AgentEvent
        if let knownEvents {
          guard eventIndex < knownEvents.count else {
            throw IndexError.stale("decoded event count does not match canonical file")
          }
          event = knownEvents[eventIndex]
        } else {
          event = try decoder.decode(AgentEvent.self, from: Data(content))
        }
        let byteOffset = canonical.distance(from: canonical.startIndex, to: lineStart)
        let byteLength = canonical.distance(from: lineStart, to: lineEnd)
        let record = Record.make(
          event: event, canonicalByteOffset: UInt64(byteOffset),
          canonicalByteLength: byteLength)
        output.append(try encoder.encode(record))
        output.append(0x0A)
        try flushIfNeeded()
        eventIndex += 1
      }
      lineStart = lineEnd
    }
    if let knownEvents, eventIndex != knownEvents.count {
      throw IndexError.stale("decoded event count does not match canonical file")
    }
    try flushIfNeeded(true)
    try handle.synchronize()
    try handle.close()

    let currentSize =
      (try FileManager.default.attributesOfItem(atPath: transcriptURL.path)[.size]
      as? NSNumber)?.uint64Value
    guard currentSize == UInt64(canonical.count) else {
      throw IndexError.stale("canonical transcript changed during rebuild")
    }
    let manager = FileManager.default
    if manager.fileExists(atPath: destination.path) {
      _ = try manager.replaceItemAt(destination, withItemAt: temporary)
    } else {
      try manager.moveItem(at: temporary, to: destination)
    }
    installed = true
  }

  /// Delete only the rebuildable sidecar. Canonical transcript bytes are never touched.
  public static func invalidate(for transcriptURL: URL) throws {
    let indexURL = url(for: transcriptURL)
    if FileManager.default.fileExists(atPath: indexURL.path) {
      try FileManager.default.removeItem(at: indexURL)
    }
  }

  static func makeEncoder() -> JSONEncoder {
    let encoder = JSONEncoder()
    encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
    encoder.dateEncodingStrategy = .iso8601
    return encoder
  }
}

// MARK: - Record derivation and preview skeletons

extension TranscriptRenderIndex.Record {
  fileprivate static func make(
    event: AgentEvent, canonicalByteOffset: UInt64, canonicalByteLength: Int
  ) -> Self {
    var messageID: String?
    var toolCallID: String?
    var parentToolCallID: String?
    var lifecycle = TranscriptRenderIndex.LifecycleFlags()
    var toolName: String?
    var displayLabel: String?
    var displayDetail: String?
    var excerpt: String?
    var payload: JSONValue?
    var checkpoint: TranscriptRenderIndex.Checkpoint?

    switch event.kind {
    case .sessionStarted:
      lifecycle = .init(isFinished: true)
    case .userText(let chunk), .assistantText(let chunk), .thinking(let chunk):
      messageID = chunk.messageID
      parentToolCallID = chunk.parentToolCallID
      lifecycle = .init(isPartial: chunk.isPartial, isFinished: !chunk.isPartial)
      excerpt = bounded(chunk.text, bytes: TranscriptRenderIndex.maximumPlainTextExcerptBytes)
      payload = .string(chunk.text)
      if !chunk.isPartial {
        checkpoint = .init(kind: .settledMessage, messageID: messageID)
      }
    case .toolCallStarted(let call), .toolCallCompleted(let call):
      toolCallID = call.toolCallID
      parentToolCallID = call.parentToolCallID
      toolName = call.name
      displayLabel = bounded(
        SandboxToolDisplay.label(for: call.name) ?? call.name,
        bytes: TranscriptRenderIndex.maximumDisplayLabelBytes, singleLine: true)
      displayDetail = toolDetail(call).map {
        bounded($0, bytes: TranscriptRenderIndex.maximumDisplayDetailBytes, singleLine: true)
      }
      if case .toolCallCompleted = event.kind {
        lifecycle = .init(isFinished: true)
      }
      payload = call.input
    case .toolCallInputDelta(let delta):
      toolCallID = delta.toolCallID
      lifecycle = .init(isPartial: true)
      excerpt = bounded(
        delta.partialJSON, bytes: TranscriptRenderIndex.maximumPlainTextExcerptBytes)
      payload = .string(delta.partialJSON)
    case .toolResult(let result):
      toolCallID = result.toolCallID
      lifecycle = .init(
        isFinished: true, resultPresent: true, isError: result.isError)
      if let text = result.content.stringValue {
        excerpt = bounded(text, bytes: TranscriptRenderIndex.maximumPlainTextExcerptBytes)
      }
      payload = result.content
    case .fileChange(let change):
      toolCallID = change.toolCallID
      displayLabel = "Edit"
      displayDetail = bounded(
        change.path, bytes: TranscriptRenderIndex.maximumDisplayDetailBytes, singleLine: true)
      lifecycle = .init(isFinished: true)
      payload = .string(change.path)
    case .approvalRequested(let request):
      toolCallID = request.toolCallID
      toolName = request.toolName
      displayLabel = bounded(
        request.title.isEmpty
          ? (SandboxToolDisplay.label(for: request.toolName) ?? request.toolName)
          : request.title,
        bytes: TranscriptRenderIndex.maximumDisplayLabelBytes, singleLine: true)
      displayDetail = toolDetail(
        ToolCall(
          toolCallID: request.toolCallID ?? "approval-\(event.seq)", name: request.toolName,
          input: request.input)
      ).map {
        bounded($0, bytes: TranscriptRenderIndex.maximumDisplayDetailBytes, singleLine: true)
      }
      payload = request.input
    case .approvalResolved:
      lifecycle = .init(isFinished: true)
    case .usage, .rateLimit, .codexUsage:
      lifecycle = .init(isFinished: true, hiddenByDefault: true)
    case .turnCompleted:
      lifecycle = .init(isFinished: true, hiddenByDefault: true)
      checkpoint = .init(kind: .settledTurn)
    case .runFinished(let finished):
      lifecycle = .init(isFinished: true, isError: finished.outcome == .errored)
      excerpt = finished.finalText.map {
        bounded($0, bytes: TranscriptRenderIndex.maximumPlainTextExcerptBytes)
      }
      if let finalText = finished.finalText { payload = .string(finalText) }
      checkpoint = .init(kind: .settledTurn)
    case .error(let error):
      lifecycle = .init(isFinished: true, isError: true)
      excerpt = bounded(error.message, bytes: TranscriptRenderIndex.maximumPlainTextExcerptBytes)
      payload = error.native ?? .string(error.message)
    case .note(let note):
      toolCallID = note.toolCallID
      lifecycle = .init(isFinished: true)
      excerpt = bounded(note.text, bytes: TranscriptRenderIndex.maximumPlainTextExcerptBytes)
      payload = .string(note.text)
    case .raw(let raw):
      lifecycle = .init(isFinished: true, hiddenByDefault: true)
      payload = raw.native
    }

    return Self(
      sequence: event.seq,
      timestamp: event.at,
      kind: event.kind.tag,
      messageID: messageID,
      toolCallID: toolCallID,
      parentToolCallID: parentToolCallID,
      lifecycle: lifecycle,
      canonicalByteOffset: canonicalByteOffset,
      canonicalByteLength: canonicalByteLength,
      toolName: toolName,
      displayLabel: displayLabel,
      displayDetail: displayDetail,
      plainTextExcerpt: excerpt,
      payloadShape: shape(of: payload, encodedEventBytes: canonicalByteLength),
      presentationRevision: event.seq,
      checkpoint: checkpoint)
  }

  fileprivate func skeletonEvent(header: SessionHeader) -> AgentEvent {
    let skeletonKind: AgentEvent.Kind
    switch kind {
    case "userText":
      skeletonKind = .userText(
        TextChunk(
          messageID: messageID ?? "index-\(sequence)",
          text: plainTextExcerpt ?? "", isPartial: lifecycle.isPartial,
          parentToolCallID: parentToolCallID))
    case "assistantText":
      skeletonKind = .assistantText(
        TextChunk(
          messageID: messageID ?? "index-\(sequence)",
          text: plainTextExcerpt ?? "", isPartial: lifecycle.isPartial,
          parentToolCallID: parentToolCallID))
    case "thinking":
      skeletonKind = .thinking(
        TextChunk(
          messageID: messageID ?? "index-\(sequence)",
          text: plainTextExcerpt ?? "", isPartial: lifecycle.isPartial,
          parentToolCallID: parentToolCallID))
    case "toolCallStarted", "toolCallCompleted":
      let call = ToolCall(
        toolCallID: toolCallID ?? "index-\(sequence)",
        name: toolName ?? displayLabel ?? "Tool",
        input: Self.skeletonToolInput(name: toolName, detail: displayDetail),
        parentToolCallID: parentToolCallID)
      skeletonKind = kind == "toolCallCompleted" ? .toolCallCompleted(call) : .toolCallStarted(call)
    case "toolCallInputDelta":
      skeletonKind = .toolCallInputDelta(
        ToolInputDelta(
          toolCallID: toolCallID ?? "index-\(sequence)",
          partialJSON: plainTextExcerpt ?? ""))
    case "toolResult":
      // A nonempty bounded placeholder preserves the collapsed row's has-output affordance.
      skeletonKind = .toolResult(
        ToolResult(
          toolCallID: toolCallID ?? "index-\(sequence)",
          content: .string(plainTextExcerpt ?? "Output available"),
          isError: lifecycle.isError))
    case "error":
      skeletonKind = .error(
        AgentError(
          recoverable: false, message: plainTextExcerpt ?? "Agent error"))
    case "note":
      skeletonKind = .note(
        NoteEvent(
          text: plainTextExcerpt ?? "Activity", toolCallID: toolCallID))
    default:
      // These are normally hidden debug/telemetry events. A raw null preserves ordering and
      // row identity without inventing user-visible semantic fields.
      skeletonKind = .raw(Raw(native: .null))
    }
    return AgentEvent(
      sessionID: header.sessionID, seq: sequence, at: timestamp,
      backend: header.backend, nativeType: nil, kind: skeletonKind)
  }

  fileprivate static func shape(
    of value: JSONValue?, encodedEventBytes: Int
  ) -> TranscriptRenderIndex.PayloadShape {
    let kind: TranscriptRenderIndex.PayloadKind
    let count: Int?
    switch value {
    case nil:
      kind = .none
      count = nil
    case .null:
      kind = .null
      count = nil
    case .bool:
      kind = .bool
      count = nil
    case .number:
      kind = .number
      count = nil
    case .string:
      kind = .string
      count = nil
    case .array(let values):
      kind = .array
      count = values.count
    case .object(let values):
      kind = .object
      count = values.count
    }
    return .init(kind: kind, topLevelCount: count, encodedEventBytes: encodedEventBytes)
  }

  fileprivate static func toolDetail(_ call: ToolCall) -> String? {
    let preferredKeys: [String] =
      switch call.name {
      case "Bash": ["command"]
      case "Read", "Edit", "MultiEdit", "Write": ["file_path"]
      case "NotebookEdit": ["notebook_path"]
      case "Grep", "Glob": ["pattern"]
      case "WebFetch": ["url"]
      case "WebSearch": ["query"]
      case "Task", "Agent": ["description", "prompt"]
      case MCPApprovalServer.qualifiedHostExecToolName: ["command"]
      case MCPApprovalServer.qualifiedOrchestraSubagentToolName: ["task", "prompt"]
      default: ["command", "file_path", "path", "query", "pattern", "description", "task"]
      }
    for key in preferredKeys {
      if let summary = scalarSummary(call.input[key]) { return summary }
    }
    return scalarSummary(call.input)
  }

  fileprivate static func scalarSummary(_ value: JSONValue?) -> String? {
    switch value {
    case .string(let string): return string
    case .bool(let bool): return bool ? "true" : "false"
    case .number(let number): return String(number)
    case .null: return "null"
    case .array(let values): return "\(values.count) items"
    case .object(let values): return "\(values.count) fields"
    case nil: return nil
    }
  }

  fileprivate static func skeletonToolInput(name: String?, detail: String?) -> JSONValue {
    guard let detail else { return .object([:]) }
    let key: String =
      switch name {
      case "Bash": "command"
      case "Read", "Edit", "MultiEdit", "Write": "file_path"
      case "NotebookEdit": "notebook_path"
      case "Grep", "Glob": "pattern"
      case "WebFetch": "url"
      case "WebSearch": "query"
      case "Task", "Agent": "description"
      case MCPApprovalServer.qualifiedHostExecToolName: "command"
      case MCPApprovalServer.qualifiedOrchestraSubagentToolName: "task"
      default: "detail"
      }
    return .object([key: .string(detail)])
  }

  fileprivate static func bounded(_ string: String, bytes: Int, singleLine: Bool = false) -> String
  {
    let clipped = String(decoding: string.utf8.prefix(max(0, bytes)), as: UTF8.self)
    guard singleLine else { return clipped }
    return clipped.replacingOccurrences(of: "\n", with: " ")
      .replacingOccurrences(of: "\r", with: " ")
  }
}

/// Append-only sidecar handle owned exclusively by ``TranscriptWriter``'s serial actor executor.
/// Failures disable/delete the derived index but never turn a successful canonical append into an
/// application error.
final class TranscriptRenderIndexAppender {
  private let transcriptURL: URL
  private let indexURL: URL
  private let handle: FileHandle
  private let encoder: JSONEncoder
  private(set) var canonicalOffset: UInt64

  init(newTranscriptAt transcriptURL: URL, header: SessionHeader, canonicalHeaderBytes: Int) throws
  {
    self.transcriptURL = transcriptURL
    self.indexURL = TranscriptRenderIndex.url(for: transcriptURL)
    self.encoder = TranscriptRenderIndex.makeEncoder()
    self.canonicalOffset = UInt64(canonicalHeaderBytes)
    let indexHeader = TranscriptRenderIndex.Header(
      transcriptID: header.sessionID, backend: header.backend,
      revertEpoch: header.revertEpoch ?? 0,
      canonicalHeaderByteLength: UInt64(canonicalHeaderBytes))
    var line = try encoder.encode(indexHeader)
    line.append(0x0A)
    try line.write(to: indexURL, options: .atomic)
    self.handle = try FileHandle(forWritingTo: indexURL)
    try self.handle.seekToEnd()
  }

  init?(appendingTo transcriptURL: URL, expectedLastSequence: UInt64) {
    do {
      let reader = TranscriptRenderIndex.Reader(transcriptURL: transcriptURL)
      let validation = try reader.validate(expectedLastSequence: expectedLastSequence)
      self.transcriptURL = transcriptURL
      self.indexURL = reader.indexURL
      self.encoder = TranscriptRenderIndex.makeEncoder()
      self.canonicalOffset = validation.canonicalFileSize
      self.handle = try FileHandle(forWritingTo: reader.indexURL)
      try self.handle.seekToEnd()
    } catch {
      try? TranscriptRenderIndex.invalidate(for: transcriptURL)
      return nil
    }
  }

  func append(_ event: AgentEvent, canonicalByteLength: Int) throws {
    let record = TranscriptRenderIndex.Record.make(
      event: event, canonicalByteOffset: canonicalOffset,
      canonicalByteLength: canonicalByteLength)
    var line = try encoder.encode(record)
    line.append(0x0A)
    try handle.write(contentsOf: line)
    canonicalOffset += UInt64(canonicalByteLength)
  }

  func sync() throws { try handle.synchronize() }
  func close() throws { try handle.close() }
}
