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

import Foundation

/// Pure, incremental de-interleaver for an ICY (SHOUTcast/Icecast) byte stream.
///
/// An ICY body interleaves audio and metadata: every `interval` bytes of audio
/// are followed by a single length byte `L`, then `L * 16` bytes of metadata
/// (NUL-padded), then the next `interval` bytes of audio, and so on. `L == 0`
/// means "no metadata this interval".
///
/// `consume(_:)` is fed raw upstream bytes as they arrive (chunk boundaries can
/// fall anywhere) and returns the clean audio to forward/cache plus any complete
/// metadata blocks decoded to strings. Metadata bytes never appear in `audio`.
struct ICYStreamDeinterleaver {

  struct MetadataBlock: Equatable {
    let audioByteOffset: Int64
    let value: String
  }

  private enum Phase {
    case audio(remaining: Int)
    case length
    case metadata(remaining: Int)
  }

  private let interval: Int
  private var phase: Phase
  private var metaBuffer = Data()
  private var audioBytesEmitted: Int64 = 0

  /// - Parameter interval: the `icy-metaint` value; must be > 0.
  init(interval: Int) {
    precondition(interval > 0, "ICY interval must be > 0")
    self.interval = interval
    self.phase = .audio(remaining: interval)
  }

  /// Feed the next slice of upstream bytes. Returns the de-interleaved audio and
  /// any metadata blocks that completed within this slice.
  mutating func consume(_ data: Data) -> (audio: Data, metadata: [MetadataBlock]) {
    var audio = Data()
    var metadata: [MetadataBlock] = []
    var idx = data.startIndex

    while idx < data.endIndex {
      switch phase {
      case .audio(let remaining):
        let available = data.distance(from: idx, to: data.endIndex)
        let take = min(remaining, available)
        let end = data.index(idx, offsetBy: take)
        audio.append(data[idx..<end])
        audioBytesEmitted += Int64(take)
        idx = end
        let left = remaining - take
        phase = left == 0 ? .length : .audio(remaining: left)

      case .length:
        let blocks = Int(data[idx])
        idx = data.index(after: idx)
        if blocks == 0 {
          phase = .audio(remaining: interval)
        } else {
          metaBuffer.removeAll(keepingCapacity: true)
          phase = .metadata(remaining: blocks * 16)
        }

      case .metadata(let remaining):
        let available = data.distance(from: idx, to: data.endIndex)
        let take = min(remaining, available)
        let end = data.index(idx, offsetBy: take)
        metaBuffer.append(data[idx..<end])
        idx = end
        let left = remaining - take
        if left == 0 {
          let decoded = Self.decode(metaBuffer)
          if !decoded.isEmpty {
            metadata.append(MetadataBlock(audioByteOffset: audioBytesEmitted, value: decoded))
          }
          phase = .audio(remaining: interval)
        } else {
          phase = .metadata(remaining: left)
        }
      }
    }

    return (audio, metadata)
  }

  /// Decode a metadata block's bytes: strip trailing NUL padding, then try UTF-8,
  /// falling back to Windows-1252 (common SHOUTcast practice for `Café`-style
  /// titles) and finally ISO-8859-1 (maps every byte, never fails) so Latin-1
  /// titles are never silently dropped.
  static func decode(_ block: Data) -> String {
    var end = block.endIndex
    while end > block.startIndex, block[block.index(before: end)] == 0 {
      end = block.index(before: end)
    }
    let trimmed = block[block.startIndex..<end]
    if trimmed.isEmpty { return "" }
    if let s = String(data: trimmed, encoding: .utf8) { return s }
    if let s = String(data: trimmed, encoding: .windowsCP1252) { return s }
    return String(data: trimmed, encoding: .isoLatin1) ?? ""
  }
}
