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

import Foundation

/// Pure HLS playlist parser. Extracts the URIs the preloader must warm into the
/// cache: variant streams (master), media segments with durations, and the
/// auxiliary init/key resources a media playlist depends on.
///
/// Foundation-only and synchronous. URI resolution, the `URI="..."` attribute
/// scanner, and the FairPlay/`data:` scheme guard are reused from
/// `HLSManifestRewriter` so this parser stays byte-for-byte consistent with the
/// rewriter and never duplicates subtly-different URI handling.
enum HLSPlaylistParser {

  struct Variant: Equatable {
    let url: URL
    let bandwidth: Int
  }

  struct Segment: Equatable {
    let url: URL
    let duration: Double
  }

  // MARK: - Master vs media detection

  /// Whether the playlist is a master playlist, i.e. it declares at least one
  /// variant stream via an `#EXT-X-STREAM-INF` tag (RFC 8216 §4.3.4.2). Matched
  /// line-anchored on a trimmed line so the token is never picked up from a
  /// comment or an attribute value. Media playlists (EXTINF /
  /// EXT-X-TARGETDURATION, no STREAM-INF) return `false`.
  static func isMaster(_ manifest: String) -> Bool {
    for line in lines(of: manifest) {
      if line.trimmingCharacters(in: .whitespaces).hasPrefix("#EXT-X-STREAM-INF:") {
        return true
      }
    }
    return false
  }

  // MARK: - Variants (master)

  /// Parse the variant streams of a master playlist, in declared order.
  ///
  /// For each `#EXT-X-STREAM-INF:<attrs>` tag the BANDWIDTH attribute is read as
  /// an integer (defaulting to 0 when absent) and the variant URI is taken from
  /// the next non-empty, non-comment line — a bare URI per RFC 8216 §4.3.4.2 —
  /// resolved against `baseURL`. FairPlay (`skd://`) and `data:` URIs are
  /// skipped, as are lines that fail to resolve.
  static func variants(manifest: String, baseURL: URL) -> [Variant] {
    var result: [Variant] = []
    let parsed = lines(of: manifest)

    var index = 0
    while index < parsed.count {
      let trimmed = parsed[index].trimmingCharacters(in: .whitespaces)
      guard trimmed.hasPrefix("#EXT-X-STREAM-INF:") else {
        index += 1
        continue
      }

      let bandwidth = integerAttribute("BANDWIDTH", on: parsed[index]) ?? 0

      if let uriIndex = nextURIIndex(in: parsed, after: index, skipExtTags: false),
         let resolved = resolvedBareURI(parsed[uriIndex], baseURL: baseURL) {
        result.append(Variant(url: resolved, bandwidth: bandwidth))
        index = uriIndex + 1
      } else {
        index += 1
      }
    }

    return result
  }

  // MARK: - Segments (media)

  /// Parse the media segments of a media playlist, in declared order.
  ///
  /// For each `#EXTINF:<duration>[,<title>]` tag the duration is parsed as the
  /// `Double` before the first comma and the segment URI is taken from the next
  /// non-empty, non-comment, non-tag line, resolved against `baseURL`. A
  /// `#EXT-X-BYTERANGE` (or any other `#`-prefixed tag) may appear between the
  /// EXTINF and the URI and is skipped while locating it. FairPlay (`skd://`)
  /// and `data:` URIs are skipped, as are lines that fail to resolve.
  static func segments(manifest: String, baseURL: URL) -> [Segment] {
    var result: [Segment] = []
    let parsed = lines(of: manifest)

    var index = 0
    while index < parsed.count {
      let trimmed = parsed[index].trimmingCharacters(in: .whitespaces)
      guard trimmed.hasPrefix("#EXTINF:") else {
        index += 1
        continue
      }

      let duration = durationValue(from: trimmed)

      if let uriIndex = nextURIIndex(in: parsed, after: index, skipExtTags: true),
         let resolved = resolvedBareURI(parsed[uriIndex], baseURL: baseURL) {
        result.append(Segment(url: resolved, duration: duration))
        index = uriIndex + 1
      } else {
        index += 1
      }
    }

    return result
  }

  // MARK: - Auxiliary resources (init map + encryption key)

  /// Collect the auxiliary resources a media playlist depends on: the
  /// `#EXT-X-MAP:URI="..."` initialization segment and every
  /// `#EXT-X-KEY:...,URI="..."` decryption key whose METHOD is not NONE.
  ///
  /// URIs are resolved against `baseURL`, returned in first-seen order, and
  /// deduplicated (a key/map commonly repeats across a media playlist).
  /// `#EXT-X-KEY:METHOD=NONE` carries no URI and contributes nothing; FairPlay
  /// (`skd://`) and `data:` key URIs are excluded.
  static func auxiliaryURIs(manifest: String, baseURL: URL) -> [URL] {
    var result: [URL] = []
    var seen: Set<URL> = []

    for line in lines(of: manifest) {
      let trimmed = line.trimmingCharacters(in: .whitespaces)

      let isMap = trimmed.hasPrefix("#EXT-X-MAP:")
      let isKey = trimmed.hasPrefix("#EXT-X-KEY:")
      guard isMap || isKey else { continue }

      // A key with METHOD=NONE has no URI attribute and must be ignored.
      if isKey, methodIsNone(in: line) { continue }

      guard let valueRange = HLSManifestRewriter.uriAttributeValueRange(in: line) else {
        continue
      }
      let value = String(line[valueRange])

      guard !value.isEmpty else { continue }
      guard !HLSManifestRewriter.isPreservedScheme(value) else { continue }
      guard let resolved = HLSManifestRewriter.resolve(value, against: baseURL) else {
        continue
      }

      if seen.insert(resolved).inserted {
        result.append(resolved)
      }
    }

    return result
  }

  // MARK: - Helpers

  /// Split the manifest on `\n`, stripping a trailing `\r` from each line so
  /// CRLF input is tolerated — mirrors `HLSManifestRewriter.rewrite`.
  private static func lines(of manifest: String) -> [String] {
    manifest.components(separatedBy: "\n").map { line in
      line.hasSuffix("\r") ? String(line.dropLast()) : line
    }
  }

  /// Index of the next bare-URI line after `tagIndex`. Blank lines and plain
  /// comment lines (`#` not `#EXT`) are always skipped. `#EXT...` tag lines are
  /// skipped only when `skipExtTags` is true (the segment path, tolerating
  /// interleaved `#EXT-X-BYTERANGE`/`#EXT-X-DISCONTINUITY`/etc.) — but a
  /// following `#EXTINF`, which begins a new segment, always ends the search so
  /// a URI-less EXTINF never steals the next segment's URI. Returns `nil` if no
  /// bare URI is reached.
  private static func nextURIIndex(
    in parsed: [String],
    after tagIndex: Int,
    skipExtTags: Bool
  ) -> Int? {
    var index = tagIndex + 1
    while index < parsed.count {
      let trimmed = parsed[index].trimmingCharacters(in: .whitespaces)
      if trimmed.isEmpty {
        index += 1
        continue
      }
      if trimmed.hasPrefix("#EXT") {
        // A new EXTINF begins another segment: stop so its URI isn't stolen.
        if skipExtTags, !trimmed.hasPrefix("#EXTINF:") {
          index += 1
          continue
        }
        return nil
      }
      if trimmed.hasPrefix("#") {
        // Plain comment (not a tag): skip on both paths and keep scanning.
        index += 1
        continue
      }
      return index
    }
    return nil
  }

  /// Resolve a bare URI line against `baseURL`, skipping preserved schemes
  /// (`skd://`, `data:`) and unresolvable values.
  private static func resolvedBareURI(_ line: String, baseURL: URL) -> URL? {
    let trimmed = line.trimmingCharacters(in: .whitespaces)
    guard !HLSManifestRewriter.isPreservedScheme(trimmed) else { return nil }
    return HLSManifestRewriter.resolve(trimmed, against: baseURL)
  }

  /// Read an unsigned-integer-valued attribute (e.g. BANDWIDTH) from a tag's
  /// attribute list, reusing the rewriter's quote-aware scanner so a comma
  /// inside a quoted value (e.g. `CODECS="a,b"`) preceding the attribute does
  /// not corrupt the match. Returns `nil` when the attribute is absent or its
  /// value does not parse as an integer.
  private static func integerAttribute(_ name: String, on line: String) -> Int? {
    guard let range = HLSManifestRewriter.attributeValueRange(named: name, in: line) else {
      return nil
    }
    let raw = line[range].trimmingCharacters(in: .whitespaces)
    return Int(raw)
  }

  /// Parse the EXTINF duration: the number before the first comma of an
  /// `#EXTINF:<duration>[,<title>]` tag. Returns 0 on malformed input, and
  /// normalises non-finite or negative values to 0 so downstream preload-window
  /// arithmetic is never poisoned by `inf`/`nan` (both of which `Double(_:)`
  /// parses without error).
  private static func durationValue(from trimmed: String) -> Double {
    guard let colon = trimmed.firstIndex(of: ":") else { return 0 }
    let body = trimmed[trimmed.index(after: colon)...]
    let durationPart = body.prefix { $0 != "," }
      .trimmingCharacters(in: .whitespaces)
    guard let value = Double(durationPart), value.isFinite, value >= 0 else { return 0 }
    return value
  }

  /// Whether an `#EXT-X-KEY` tag declares `METHOD=NONE` (case-insensitive),
  /// meaning it carries no URI and references no resource. Reuses the rewriter's
  /// quote-aware scanner so a `,METHOD=NONE` substring inside another
  /// attribute's quoted value (e.g. a key URI query string) is never mistaken
  /// for the real METHOD attribute.
  private static func methodIsNone(in line: String) -> Bool {
    guard let range = HLSManifestRewriter.attributeValueRange(named: "METHOD", in: line) else {
      return false
    }
    let value = line[range].trimmingCharacters(in: .whitespaces)
    return value.caseInsensitiveCompare("NONE") == .orderedSame
  }
}