///| Upload-pack protocol helpers (v0/v2)

///|
pub(all) enum ProtocolVersion {
  V0
  V2
} derive(Eq)

///|
pub fn detect_protocol_version(
  data : Bytes,
) -> ProtocolVersion raise @bit.GitError {
  let lines = pktline_decode(data)
  for item in lines {
    let (line_bytes, is_flush) = item
    if is_flush || line_bytes.length() == 0 {
      continue
    }
    let line = bytes_to_string(line_bytes)
    if line.has_prefix("#") {
      continue
    }
    if line.has_prefix("version 2") {
      return ProtocolVersion::V2
    }
  }
  ProtocolVersion::V0
}

///|
pub fn parse_v2_advertised_caps(
  data : Bytes,
) -> Array[String] raise @bit.GitError {
  let result : Array[String] = []
  let lines = pktline_decode(data)
  for item in lines {
    let (line_bytes, is_flush) = item
    if is_flush || line_bytes.length() == 0 {
      continue
    }
    let line = line_bytes |> bytes_to_string |> trim_newline
    if line.has_prefix("#") || line.has_prefix("version ") {
      continue
    }
    result.push(line)
  }
  result
}

///|
pub fn parse_v0_advertised_caps(
  data : Bytes,
) -> Array[String] raise @bit.GitError {
  let result : Array[String] = []
  let decoded = pktline_decode(data)
  for item in decoded {
    let (line_bytes, is_flush) = item
    if is_flush || line_bytes.length() == 0 {
      continue
    }
    let line = bytes_to_string(line_bytes)
    match line.find("\u0000") {
      None => continue
      Some(idx) => {
        let caps_str = trim_newline(
          String::unsafe_substring(line, start=idx + 1, end=line.length()),
        )
        let parts = split_by_space(caps_str)
        for p in parts {
          result.push(p)
        }
        break
      }
    }
  }
  result
}

///|
pub fn parse_symrefs_from_capabilities(
  caps : Array[String],
) -> Map[String, String] {
  let symrefs : Map[String, String] = Map([])
  for cap in caps {
    if cap.has_prefix("symref=") {
      let raw = String::unsafe_substring(cap, start=7, end=cap.length())
      match raw.find(":") {
        None => ()
        Some(idx) => {
          let source = String::unsafe_substring(raw, start=0, end=idx)
          let target = String::unsafe_substring(
            raw,
            start=idx + 1,
            end=raw.length(),
          )
          symrefs[source] = target
        }
      }
      continue
    }
    if cap.has_prefix("symref-target:") {
      let target = String::unsafe_substring(cap, start=14, end=cap.length())
      symrefs["HEAD"] = target
      continue
    }
  }
  symrefs
}

///|
pub fn build_ls_refs_request(agent : String, prefixes : Array[String]) -> Bytes {
  let chunks : Array[Bytes] = []
  chunks.push(pktline_encode("command=ls-refs\n"))
  chunks.push(pktline_encode("agent=\{agent}\n"))
  chunks.push(pktline_encode("object-format=sha1\n"))
  chunks.push(pktline_delim())
  chunks.push(pktline_encode("peel\n"))
  chunks.push(pktline_encode("symrefs\n"))
  chunks.push(pktline_encode("unborn\n"))
  for prefix in prefixes {
    chunks.push(pktline_encode("ref-prefix \{prefix}\n"))
  }
  chunks.push(pktline_flush())
  concat_bytes(chunks)
}

///|
pub struct LsRefsResult {
  refs : Array[(@bit.ObjectId, String)]
  symrefs : Map[String, String]
}

///|
pub fn parse_ls_refs_response(data : Bytes) -> LsRefsResult raise @bit.GitError {
  let refs : Array[(@bit.ObjectId, String)] = []
  let symrefs : Map[String, String] = Map([])
  let lines = pktline_decode(data)
  for item in lines {
    let (line_bytes, is_flush) = item
    if is_flush || line_bytes.length() == 0 {
      continue
    }
    let line = line_bytes |> bytes_to_string |> trim_newline
    let parts = split_by_space(line)
    if parts.length() < 2 {
      continue
    }
    let id = @bit.ObjectId::from_hex(parts[0])
    let name = parts[1]
    refs.push((id, name))
    for i in 2.. Array[@bit.ObjectId] {
  let wants : Array[@bit.ObjectId] = []
  let zero = @bit.ObjectId::zero()
  match symrefs.get("HEAD") {
    Some(target) =>
      for item in refs {
        let (id, name) = item
        if name == target && id != zero {
          wants.push(id)
          return wants
        }
      }
    None => ()
  }
  for item in refs {
    let (id, name) = item
    if name == "HEAD" && id != zero {
      wants.push(id)
      return wants
    }
  }
  if refs.length() > 0 {
    for item in refs {
      let (id, _) = item
      if id != zero {
        wants.push(id)
        break
      }
    }
  }
  wants
}

///|
pub fn select_default_ref(
  refs : Array[(@bit.ObjectId, String)],
  symrefs : Map[String, String],
) -> (String, @bit.ObjectId)? {
  let zero = @bit.ObjectId::zero()
  match symrefs.get("HEAD") {
    Some(target) =>
      for item in refs {
        let (id, name) = item
        if name == target && id != zero {
          return Some((name, id))
        }
      }
    None => ()
  }
  for item in refs {
    let (id, name) = item
    if name == "HEAD" && id != zero {
      return Some((name, id))
    }
  }
  for item in refs {
    let (id, name) = item
    if id != zero {
      return Some((name, id))
    }
  }
  None
}

///|
/// Filter specification for partial clone
pub(all) enum FilterSpec {
  NoFilter // No filter (full clone)
  BlobNone // blob:none - exclude all blobs
  BlobLimit(Int64) // blob:limit= - exclude blobs > n bytes
  TreeDepth(Int) // tree: - limit tree depth
  SparseOid(String) // sparse:oid= - sparse checkout filter
  SparsePathUnsupported // sparse:path= - dropped for security
}

///|
pub fn FilterSpec::to_string(self : FilterSpec) -> String {
  match self {
    NoFilter => ""
    BlobNone => "blob:none"
    BlobLimit(n) => "blob:limit=\{n}"
    TreeDepth(d) => "tree:\{d}"
    SparseOid(oid) => "sparse:oid=\{oid}"
    SparsePathUnsupported => "sparse:path"
  }
}

///|
pub fn FilterSpec::is_partial(self : FilterSpec) -> Bool {
  match self {
    NoFilter => false
    _ => true
  }
}

///|
/// Build a fetch request for protocol v2.
/// - depth > 0: shallow clone with that depth
/// - filter: partial clone filter specification
/// - haves: commit tips already present locally
/// - shallow: local commits whose parents are not present
pub fn build_fetch_request_v2(
  agent : String,
  wants : Array[@bit.ObjectId],
  depth : Int,
  filter? : FilterSpec = FilterSpec::NoFilter,
  haves? : Array[@bit.ObjectId] = [],
  shallow? : Array[@bit.ObjectId] = [],
) -> Bytes {
  let chunks : Array[Bytes] = []
  chunks.push(pktline_encode("command=fetch\n"))
  chunks.push(pktline_encode("agent=\{agent}\n"))
  chunks.push(pktline_encode("object-format=sha1\n"))
  chunks.push(pktline_delim())
  // The current pack parser does not preload local objects as external delta
  // bases, so negotiated fetches must remain self-contained.
  if haves.length() == 0 {
    chunks.push(pktline_encode("thin-pack\n"))
  }
  chunks.push(pktline_encode("no-progress\n"))
  chunks.push(pktline_encode("ofs-delta\n"))
  for id in shallow {
    chunks.push(pktline_encode("shallow \{id.to_hex()}\n"))
  }
  if depth > 0 {
    chunks.push(pktline_encode("deepen \{depth}\n"))
  }
  // Partial clone filter
  if filter.is_partial() {
    chunks.push(pktline_encode("filter \{filter.to_string()}\n"))
  }
  for want in wants {
    chunks.push(pktline_encode("want \{want.to_hex()}\n"))
  }
  for have in haves {
    chunks.push(pktline_encode("have \{have.to_hex()}\n"))
  }
  chunks.push(pktline_encode("done\n"))
  chunks.push(pktline_flush())
  concat_bytes(chunks)
}

///|
/// Build the shared protocol v0/v1 fetch negotiation request.
///
/// Protocol v1 changes reference advertisement, but uses the v0 request
/// grammar. Capabilities and extension lines are emitted only when advertised.
pub fn build_fetch_request_v0(
  agent : String,
  wants : Array[@bit.ObjectId],
  advertised : Array[String],
  depth? : Int = 0,
  filter? : FilterSpec = FilterSpec::NoFilter,
  haves? : Array[@bit.ObjectId] = [],
  shallow? : Array[@bit.ObjectId] = [],
) -> Bytes {
  if wants.length() == 0 {
    return pktline_flush()
  }
  let uses_shallow = shallow.length() > 0 || depth > 0
  let caps = select_v0_caps(
    agent,
    advertised,
    uses_haves=haves.length() > 0,
    uses_shallow~,
    uses_filter=filter.is_partial(),
  )
  let chunks : Array[Bytes] = []
  let first = if caps.length() > 0 {
    "want \{wants[0].to_hex()} \{caps}\n"
  } else {
    "want \{wants[0].to_hex()}\n"
  }
  chunks.push(pktline_encode(first))
  for i in 1.. 0 {
      chunks.push(pktline_encode("deepen \{depth}\n"))
    }
  }
  if filter.is_partial() && advertised.contains("filter") {
    chunks.push(pktline_encode("filter \{filter.to_string()}\n"))
  }
  chunks.push(pktline_flush())
  for have in haves {
    chunks.push(pktline_encode("have \{have.to_hex()}\n"))
  }
  chunks.push(pktline_encode("done\n"))
  chunks.push(pktline_flush())
  concat_bytes(chunks)
}

///|
pub struct FetchPackResult {
  pack : Bytes
  shallow : Array[@bit.ObjectId]
  unshallow : Array[@bit.ObjectId]
}

///|
pub fn parse_fetch_response_v2(
  data : Bytes,
) -> FetchPackResult raise @bit.GitError {
  let lines = pktline_decode(data)
  let mut in_pack = false
  let chunks : Array[Byte] = []
  let shallow : Array[@bit.ObjectId] = []
  let unshallow : Array[@bit.ObjectId] = []
  for item in lines {
    let (line_bytes, is_flush) = item
    if is_flush {
      if in_pack {
        break
      }
      continue
    }
    if line_bytes.length() == 0 {
      continue
    }
    if !in_pack {
      let line = line_bytes |> bytes_to_string |> trim_newline
      parse_shallow_response_line(line, shallow, unshallow)
      if line == "packfile" {
        in_pack = true
      }
      continue
    }
    // side-band: channel byte + payload
    let channel = line_bytes[0].to_int()
    if channel == 1 {
      for i in 1.. payload[i])),
      )
      let msg = bytes_to_string_from(safe, 0)
      raise @bit.GitError::ProtocolError("upload-pack error: \{msg}")
    }
  }
  {
    pack: Bytes::from_array(FixedArray::makei(chunks.length(), i => chunks[i])),
    shallow,
    unshallow,
  }
}

///|
pub fn extract_pack_from_v2_response(data : Bytes) -> Bytes raise @bit.GitError {
  parse_fetch_response_v2(data).pack
}

///|
pub fn parse_fetch_response_v0(
  data : Bytes,
) -> FetchPackResult raise @bit.GitError {
  let lines = pktline_decode(data)
  let chunks : Array[Byte] = []
  let shallow : Array[@bit.ObjectId] = []
  let unshallow : Array[@bit.ObjectId] = []
  for item in lines {
    let (line_bytes, is_flush) = item
    if is_flush || line_bytes.length() == 0 {
      continue
    }
    let channel = line_bytes[0].to_int()
    if channel == 's' || channel == 'u' {
      let line = line_bytes |> bytes_to_string |> trim_newline
      parse_shallow_response_line(line, shallow, unshallow)
    } else if channel == 1 {
      for i in 1.. payload[i])),
      )
      let msg = bytes_to_string_from(safe, 0)
      raise @bit.GitError::ProtocolError("upload-pack error: \{msg}")
    }
  }
  {
    pack: Bytes::from_array(FixedArray::makei(chunks.length(), i => chunks[i])),
    shallow,
    unshallow,
  }
}

///|
pub fn extract_pack_from_v0_response(data : Bytes) -> Bytes raise @bit.GitError {
  parse_fetch_response_v0(data).pack
}

///|
fn parse_shallow_response_line(
  line : String,
  shallow : Array[@bit.ObjectId],
  unshallow : Array[@bit.ObjectId],
) -> Unit raise @bit.GitError {
  let (prefix, output) = if line.has_prefix("shallow ") {
    ("shallow ", shallow)
  } else if line.has_prefix("unshallow ") {
    ("unshallow ", unshallow)
  } else {
    return
  }
  let hex = String::unsafe_substring(
    line,
    start=prefix.length(),
    end=line.length(),
  )
  if hex.length() != 40 {
    raise @bit.GitError::ProtocolError(
      "Invalid upload-pack shallow object id: \{hex}",
    )
  }
  let id = @bit.ObjectId::from_hex(hex) catch {
    _ =>
      raise @bit.GitError::ProtocolError(
        "Invalid upload-pack shallow object id: \{hex}",
      )
  }
  output.push(id)
}

///|
fn select_v0_caps(
  agent : String,
  advertised : Array[String],
  uses_haves~ : Bool,
  uses_shallow~ : Bool,
  uses_filter~ : Bool,
) -> String {
  let caps : Array[String] = []
  if advertised.contains("side-band-64k") {
    caps.push("side-band-64k")
  } else if advertised.contains("side-band") {
    caps.push("side-band")
  }
  let wanted = ["thin-pack", "ofs-delta", "no-progress", "include-tag"]
  for cap in wanted {
    if cap == "thin-pack" && uses_haves {
      continue
    }
    if advertised.contains(cap) {
      caps.push(cap)
    }
  }
  if uses_shallow && advertised.contains("shallow") {
    caps.push("shallow")
  }
  if uses_filter && advertised.contains("filter") {
    caps.push("filter")
  }
  for cap in advertised {
    if cap.has_prefix("agent=") {
      caps.push("agent=\{agent}")
      break
    }
  }
  caps.join(" ")
}

///|
fn trim_newline(s : String) -> String {
  if s.has_suffix("\n") {
    String::unsafe_substring(s, start=0, end=s.length() - 1)
  } else {
    s
  }
}

///|
fn bytes_to_string(data : Bytes) -> String {
  let result = StringBuilder::new()
  for b in data {
    result.write_char(b.to_int().unsafe_to_char())
  }
  result.to_string()
}

///|
fn bytes_to_string_from(data : Bytes, start : Int) -> String {
  if start >= data.length() {
    return ""
  }
  let result = StringBuilder::new()
  for i in start.. Array[String] {
  let parts : Array[String] = []
  let mut current = StringBuilder::new()
  for c in s {
    if c == ' ' {
      if current.to_string().length() > 0 {
        parts.push(current.to_string())
        current = StringBuilder::new()
      }
    } else {
      current.write_char(c)
    }
  }
  if current.to_string().length() > 0 {
    parts.push(current.to_string())
  }
  parts
}

///|
fn concat_bytes(chunks : Array[Bytes]) -> Bytes {
  let result : Array[Byte] = []
  for chunk in chunks {
    for b in chunk {
      result.push(b)
    }
  }
  Bytes::from_array(FixedArray::makei(result.length(), i => result[i]))
}