//
// Copyright (c) Double Symmetry GmbH
// Commercial use requires a license. See https://rntp.dev/pricing
//

import XCTest
@testable import PlayerCore

final class ICYStreamDeinterleaverTests: XCTestCase {

  // MARK: - Framing helpers

  /// `interval` bytes of audio filled with `byte`.
  private func audio(_ byte: UInt8, count: Int) -> Data {
    Data(repeating: byte, count: count)
  }

  /// Build a raw ICY body. Each segment is exactly `interval` audio bytes plus a
  /// length byte and an optional (NUL-padded) metadata block.
  private func icyBody(interval: Int, segments: [(audio: Data, meta: String?)]) -> Data {
    var body = Data()
    for seg in segments {
      precondition(seg.audio.count == interval, "audio must be exactly one interval")
      body.append(seg.audio)
      if let meta = seg.meta {
        let metaBytes = Data(meta.utf8)
        let blocks = (metaBytes.count + 15) / 16
        body.append(UInt8(blocks))
        var padded = metaBytes
        padded.append(Data(count: blocks * 16 - metaBytes.count))
        body.append(padded)
      } else {
        body.append(UInt8(0))
      }
    }
    return body
  }

  // MARK: - Tests

  func testSplitsAudioAndMetadata() {
    let a = audio(0xAA, count: 16)
    let body = icyBody(interval: 16, segments: [(a, "StreamTitle='A - B';")])
    var d = ICYStreamDeinterleaver(interval: 16)
    let (audioOut, meta) = d.consume(body)
    XCTAssertEqual(audioOut, a)
    XCTAssertEqual(meta.map(\.value), ["StreamTitle='A - B';"])
    XCTAssertEqual(meta.map(\.audioByteOffset), [16])
  }

  func testEmptyBlockYieldsNoMetadata() {
    let a = audio(0x11, count: 16)
    let body = icyBody(interval: 16, segments: [(a, nil)])
    var d = ICYStreamDeinterleaver(interval: 16)
    let (audioOut, meta) = d.consume(body)
    XCTAssertEqual(audioOut, a)
    XCTAssertTrue(meta.isEmpty)
  }

  func testBlockSpanningTwoChunks() {
    let a = audio(0x22, count: 16)
    let body = icyBody(interval: 16, segments: [(a, "StreamTitle='Split';")])
    // Split mid-metadata: after audio + length byte + a few metadata bytes.
    let cut = 16 + 1 + 4
    var d = ICYStreamDeinterleaver(interval: 16)
    let (a1, m1) = d.consume(body.prefix(cut))
    let (a2, m2) = d.consume(body.suffix(from: body.index(body.startIndex, offsetBy: cut)))
    XCTAssertEqual(a1 + a2, a)
    XCTAssertEqual((m1 + m2).map(\.value), ["StreamTitle='Split';"])
    XCTAssertEqual((m1 + m2).map(\.audioByteOffset), [16])
  }

  func testLengthByteOnChunkBoundary() {
    let a = audio(0x33, count: 16)
    let body = icyBody(interval: 16, segments: [(a, "StreamTitle='Edge';")])
    // Cut exactly after the audio, before the length byte.
    var d = ICYStreamDeinterleaver(interval: 16)
    let (a1, m1) = d.consume(body.prefix(16))
    let (a2, m2) = d.consume(body.suffix(from: body.index(body.startIndex, offsetBy: 16)))
    XCTAssertEqual(a1 + a2, a)
    XCTAssertEqual((m1 + m2).map(\.value), ["StreamTitle='Edge';"])
  }

  func testMultipleBlocksInOneChunk() {
    let a1 = audio(0x44, count: 8)
    let a2 = audio(0x55, count: 8)
    let body = icyBody(interval: 8, segments: [(a1, "StreamTitle='One';"), (a2, "StreamTitle='Two';")])
    var d = ICYStreamDeinterleaver(interval: 8)
    let (audioOut, meta) = d.consume(body)
    XCTAssertEqual(audioOut, a1 + a2)
    XCTAssertEqual(meta.map(\.value), ["StreamTitle='One';", "StreamTitle='Two';"])
    XCTAssertEqual(meta.map(\.audioByteOffset), [8, 16])
  }

  func testAudioByteAccountingResets() {
    let a1 = audio(0x01, count: 4)
    let a2 = audio(0x02, count: 4)
    let a3 = audio(0x03, count: 4)
    let body = icyBody(interval: 4, segments: [(a1, nil), (a2, "StreamTitle='M';"), (a3, nil)])
    var d = ICYStreamDeinterleaver(interval: 4)
    let (audioOut, meta) = d.consume(body)
    XCTAssertEqual(audioOut, a1 + a2 + a3)
    XCTAssertEqual(audioOut.count, 12)
    XCTAssertEqual(meta.map(\.value), ["StreamTitle='M';"])
    XCTAssertEqual(meta.map(\.audioByteOffset), [8])
  }

  func testWindows1252Fallback() {
    // "StreamTitle='Café';" with é as a single 0xE9 byte (CP1252 / Latin-1),
    // which is invalid UTF-8 and must not be dropped.
    var metaBytes = Data("StreamTitle='Caf".utf8)
    metaBytes.append(0xE9)                       // é
    metaBytes.append(Data("';".utf8))
    let blocks = (metaBytes.count + 15) / 16
    var body = audio(0x66, count: 16)
    body.append(UInt8(blocks))
    var padded = metaBytes
    padded.append(Data(count: blocks * 16 - metaBytes.count))
    body.append(padded)

    var d = ICYStreamDeinterleaver(interval: 16)
    let (_, meta) = d.consume(body)
    XCTAssertEqual(meta.count, 1)
    XCTAssertTrue(meta.first?.value.contains("Café") == true, "decoded: \(meta.first?.value ?? "nil")")
  }

  func testOversizedBlockPaddingStripped() {
    // A short title padded out to a full 16-byte block should decode without
    // trailing NULs.
    let a = audio(0x77, count: 16)
    let body = icyBody(interval: 16, segments: [(a, "StreamTitle='X';")])
    var d = ICYStreamDeinterleaver(interval: 16)
    let (_, meta) = d.consume(body)
    XCTAssertEqual(meta.map(\.value), ["StreamTitle='X';"])
    XCTAssertFalse(meta.first?.value.contains("\0") == true)
  }
}
