///|
pub(all) struct Magnet {
  v1 : String?
  v2 : String?
  name : String?
  trackers : Array[String]
}

///|
fn hex_digit(n : Int32) -> Int32 {
  if n >= 48 && n <= 57 {
    n - 48
  } else if n >= 65 && n <= 70 {
    n - 55
  } else if n >= 97 && n <= 102 {
    n - 87
  } else {
    -1
  }
}

///|
fn normalized_hex(text : String, length : Int32) -> String raise CrateError {
  if text.length() != length {
    reject("magnet", "wrong info hash length")
  }
  for b in @utf8.encode(text) {
    if hex_digit(b.to_int()) < 0 {
      reject("magnet", "invalid hexadecimal info hash")
    }
  }
  text.to_lower()
}

///|
fn uri_encode(text : String) -> String {
  let out : Array[Byte] = []
  for byte in @utf8.encode(text) {
    let c = byte.to_int()
    if (c >= 65 && c <= 90) ||
      (c >= 97 && c <= 122) ||
      (c >= 48 && c <= 57) ||
      [45, 46, 95, 126].contains(c) {
      out.push(byte)
    } else {
      out.push(37)
      out.push(b"0123456789ABCDEF"[c / 16])
      out.push(b"0123456789ABCDEF"[c % 16])
    }
  }
  @utf8.decode_lossy(Bytes::from_array(out))
}

///|
fn uri_decode(text : String) -> String raise CrateError {
  let bytes = @utf8.encode(text)
  let out : Array[Byte] = []
  let mut i = 0
  while i < bytes.length() {
    if bytes[i] == 37 {
      if i + 2 >= bytes.length() {
        reject("magnet", "incomplete percent escape")
      }
      let a = hex_digit(bytes[i + 1].to_int())
      let b = hex_digit(bytes[i + 2].to_int())
      if a < 0 || b < 0 {
        reject("magnet", "invalid percent escape")
      }
      out.push((a * 16 + b).to_byte())
      i += 3
    } else {
      out.push(bytes[i])
      i += 1
    }
  }
  @utf8.decode(Bytes::from_array(out)) catch {
    _ => {
      reject("magnet", "invalid UTF-8 URI value")
      ""
    }
  }
}

///|
fn decode_base32_hash(text : String) -> String raise CrateError {
  if text.length() != 32 {
    reject("magnet", "wrong base32 hash length")
  }
  let bytes : Array[Byte] = []
  let mut bits = 0
  let mut value = 0
  for c in @utf8.encode(text.to_upper()) {
    let c = c.to_int()
    let digit = if c >= 65 && c <= 90 {
      c - 65
    } else if c >= 50 && c <= 55 {
      c - 24
    } else {
      -1
    }
    if digit < 0 {
      reject("magnet", "invalid base32 hash")
    }
    value = (value << 5) | digit
    bits += 5
    if bits >= 8 {
      bits -= 8
      bytes.push(((value >> bits) & 255).to_byte())
      value = value & ((1 << bits) - 1)
    }
  }
  @crypto.bytes_to_hex_string(Bytes::from_array(bytes))
}

///|
pub fn format_magnet(magnet : Magnet) -> String raise CrateError {
  let params : Array[String] = []
  match magnet.v1 {
    Some(h) => params.push("xt=urn:btih:" + normalized_hex(h, 40))
    None => ()
  }
  match magnet.v2 {
    Some(h) => params.push("xt=urn:btmh:1220" + normalized_hex(h, 64))
    None => ()
  }
  if params.is_empty() {
    reject("magnet", "at least one info hash is required")
  }
  match magnet.name {
    Some(name) => params.push("dn=" + uri_encode(name))
    None => ()
  }
  for url in magnet.trackers {
    check_url(url, true)
    params.push("tr=" + uri_encode(url))
  }
  let uri = "magnet:?" + params.join("&")
  if uri.length() > 65536 {
    reject("limit", "magnet URI too large")
  }
  uri
}

///|
pub fn parse_magnet(uri : String) -> Magnet raise CrateError {
  if !uri.has_prefix("magnet:?") || uri.length() > 65536 || uri.contains("#") {
    reject("magnet", "invalid magnet URI")
  }
  let query = uri.strip_prefix("magnet:?").unwrap()
  let mut v1 : String? = None
  let mut v2 : String? = None
  let mut name : String? = None
  let trackers : Array[String] = []
  for pair in query.split("&") {
    let (key, value) = match pair.split_once("=") {
      Some(p) => p
      None => {
        reject("magnet", "query field lacks equals sign")
        ("", "")
      }
    }
    let value = uri_decode(value.to_owned())
    if key == "xt" && value.has_prefix("urn:btih:") {
      let h = value.strip_prefix("urn:btih:").unwrap().to_owned()
      let h = if h.length() == 32 {
        decode_base32_hash(h)
      } else {
        normalized_hex(h, 40)
      }
      if v1 is Some(old) && old != h {
        reject("magnet", "conflicting v1 hashes")
      }
      v1 = Some(h)
    } else if key == "xt" && value.has_prefix("urn:btmh:") {
      let h = value.strip_prefix("urn:btmh:").unwrap().to_owned()
      if !h.has_prefix("1220") {
        reject("magnet", "unsupported multihash")
      }
      let h = normalized_hex(h.strip_prefix("1220").unwrap().to_owned(), 64)
      if v2 is Some(old) && old != h {
        reject("magnet", "conflicting v2 hashes")
      }
      v2 = Some(h)
    } else if key == "dn" {
      if name is Some(_) {
        reject("magnet", "duplicate display name")
      }
      name = Some(value)
    } else if key == "tr" {
      check_url(value, true)
      trackers.push(value)
    }
  }
  if v1 is None && v2 is None {
    reject("magnet", "no supported info hash")
  }
  { v1, v2, name, trackers, }
}

///|
pub fn torrent_magnet(data : Bytes) -> String raise CrateError {
  let info = inspect_torrent(data)
  let trackers : Array[String] = []
  for tier in info.trackers {
    for url in tier {
      if !trackers.contains(url) {
        trackers.push(url)
      }
    }
  }
  format_magnet({
    v1: info.info_hash_v1,
    v2: info.info_hash_v2,
    name: Some(info.name),
    trackers,
  })
}