///|
/// A physical thin-archive record. Regular records refer to external files;
/// their size is the declared external file size, not an inline payload length.
pub(all) struct ThinMember {
  name : Bytes
  kind : MemberKind
  header_offset : Int32
  size : Int32
  metadata : Metadata
  archive_member_offset : Int32?
} derive(Eq, Debug)

///|
/// GNU thin archive metadata and validated symbol references, without file I/O.
struct ThinArchive {
  archive : Archive
  references : Map[Int32, Int32]
} derive(Debug)

///|
pub extend ThinMember with Eq::{equal, not_equal}

///|
pub extend ThinMember with Debug::{to_repr}

///|
pub extend ThinArchive with Debug::{to_repr}

///|
pub fn ThinMember::name_text(self : ThinMember) -> String {
  @utf8.decode_lossy(self.name[:], ignore_bom=false)
}

///|
pub fn ThinMember::name_hex(self : ThinMember) -> String {
  hex_bytes(self.name)
}

///|
/// Count physical records, including inline symbol and name tables.
pub fn ThinArchive::member_count(self : ThinArchive) -> Int32 {
  self.archive.records.length()
}

///|
pub fn ThinArchive::byte_length(self : ThinArchive) -> Int32 {
  self.archive.data.length()
}

///|
pub fn ThinArchive::get_member(
  self : ThinArchive,
  index : Int32,
) -> ThinMember? {
  self.archive.records
  .get(index)
  .map(entry => {
    name: entry.name,
    kind: entry.kind,
    header_offset: entry.header_offset,
    size: entry.size,
    metadata: entry.metadata,
    archive_member_offset: self.references.get(index),
  })
}

///|
/// Return copies; symbol targets use the same physical indices as get_member.
pub fn ThinArchive::symbol_indexes(self : ThinArchive) -> Array[SymbolIndex] {
  self.archive.symbol_indexes()
}

///|
/// Only special records have inline bytes. Regular records and invalid indices
/// return None; use load_member to obtain an external member's contents.
pub fn ThinArchive::inline_bytes(self : ThinArchive, index : Int32) -> Bytes? {
  guard self.archive.records.get(index) is Some(entry) else { return None }
  if entry.kind == Regular {
    None
  } else {
    self.archive.member_bytes(index)
  }
}

///|
fn parse_thin_records(
  data : Bytes,
  max_members : Int32,
  max_name_bytes : Int32,
) -> (Archive, Map[Int32, Int32]) raise InvalidArchive {
  if data.length() < 8 || data[:8] != b"!\n"[:] {
    reject("AR001", 0, "expected GNU thin archive magic")
  }
  let records : Array[Member] = []
  let references : Map[Int32, Int32] = Map([])
  let pending_names : Array[(Int32, Int32)] = []
  let wanted_names : Map[Int32, Unit] = Map([])
  let mut long_table : (Int32, Int32)? = None
  let mut offset = 8
  while offset < data.length() {
    if records.length() >= max_members {
      reject("AR008", offset, "archive member limit exceeded")
    }
    if data.length() - offset < 60 {
      reject("AR002", offset, "truncated thin member header")
    }
    if data[offset + 58] != b'`' || data[offset + 59] != b'\n' {
      reject("AR003", offset + 58, "invalid member header trailer")
    }
    let mut name_end = offset + 16
    while name_end > offset && data[name_end - 1] == b' ' {
      name_end -= 1
    }
    let raw_name = data[offset:name_end].to_owned()
    if raw_name.is_empty() {
      reject("AR006", offset, "empty member name")
    }
    if raw_name.has_prefix(b"#1/"[:]) {
      reject(
        "AR011", offset, "BSD embedded names are unsupported in thin archives",
      )
    }
    // Only the raw GNU header spelling determines the physical role. A name
    // table entry such as __.SYMDEF is an external file, not an inline index.
    let kind = if raw_name == b"//" {
      NameTable
    } else if raw_name == b"/" || raw_name == b"/SYM64/" {
      SymbolTable
    } else {
      Regular
    }
    if kind == Regular && raw_name.has_prefix(b"/"[:]) {
      let mut separator = raw_name.length()
      for i = 1; i < raw_name.length(); i = i + 1 {
        if raw_name[i] == b':' && separator == raw_name.length() {
          separator = i
          continue
        }
        if raw_name[i] < b'0' || raw_name[i] > b'9' {
          reject(
            "AR006",
            offset + i,
            "invalid GNU thin name or archive-offset reference",
          )
        }
      }
      let reference = number_field(
          data,
          offset + 1,
          separator - 1,
          10,
          false,
          2147483647L,
        )
        .unwrap()
        .to_int()
      if separator < raw_name.length() {
        let target_offset = number_field(
            data,
            offset + separator + 1,
            raw_name.length() - separator - 1,
            10,
            false,
            2147483647L,
          )
          .unwrap()
          .to_int()
        if target_offset < 8 || target_offset % 2 != 0 {
          reject(
            "AR006",
            offset + separator + 1,
            "nested member offset must identify an aligned archive header",
          )
        }
        references[records.length()] = target_offset
      }
      pending_names.push((records.length(), reference))
      wanted_names[reference] = ()
    }
    let name = if kind == Regular && raw_name.has_suffix(b"/"[:]) {
      raw_name[:raw_name.length() - 1].to_owned()
    } else {
      raw_name
    }
    if name.is_empty() {
      reject("AR006", offset, "empty member name")
    }
    for byte in name {
      if byte == b'\x00' {
        reject("AR006", offset, "NUL inside member name")
      }
    }
    let timestamp = number_field(
      data,
      offset + 16,
      12,
      10,
      true,
      0x7fffffffffffffffL,
    )
    let uid = number_field(data, offset + 28, 6, 10, true, 0x7fffffffffffffffL)
    let gid = number_field(data, offset + 34, 6, 10, true, 0x7fffffffffffffffL)
    let mode = number_field(data, offset + 40, 8, 8, true, 0x7fffffffffffffffL)
    let size = number_field(data, offset + 48, 10, 10, false, 2147483647L)
      .unwrap()
      .to_int()
    let data_offset = offset + 60
    // External members occupy exactly the header, even for an odd declared size.
    let inline_size = if kind == Regular { 0 } else { size }
    if inline_size > data.length() - data_offset {
      reject("AR002", data_offset, "truncated thin special-record payload")
    }
    let end = data_offset + inline_size
    let padding = inline_size % 2
    if padding == 1 && (end == data.length() || data[end] != b'\n') {
      reject("AR003", end, "missing or invalid alignment byte")
    }
    if kind == NameTable {
      if long_table is Some(_) {
        reject("AR006", offset, "more than one GNU name table")
      }
      long_table = Some((data_offset, size))
    }
    records.push({
      name,
      kind,
      header_offset: offset,
      data_offset,
      size,
      stored_size: size,
      metadata: { timestamp, uid, gid, mode, },
    })
    offset = end + padding
  }
  let long_names = match long_table {
    Some((start, size)) => Some(gnu_names(data, start, size, wanted_names))
    None => None
  }
  for (index, reference) in pending_names {
    let entry = records[index]
    match long_names {
      None =>
        reject(
          "AR006",
          entry.header_offset,
          "GNU name reference has no name table",
        )
      Some(names) =>
        match names.get(reference) {
          None =>
            reject(
              "AR006",
              entry.header_offset,
              "GNU name reference is not an entry boundary",
            )
          Some(name) => records[index] = { ..entry, name, }
        }
    }
  }
  let mut total_name_bytes = 0
  for entry in records {
    if entry.name.length() > max_name_bytes - total_name_bytes {
      reject(
        "AR008",
        entry.header_offset,
        "expanded member-name byte limit exceeded",
      )
    }
    total_name_bytes += entry.name.length()
  }
  ({ data, records, indexes: [], }, references)
}

///|
/// Parse GNU thin metadata and GNU 32/64-bit symbol indexes without loading
/// external files. Byte limits cover this input, not referenced external files.
/// GNU archive-offset references are retained without I/O. BSD embedded names
/// are not part of the GNU thin encoding and are rejected.
pub fn parse_thin(
  input : Bytes,
  max_members~ : Int32 = 100000,
  max_bytes~ : Int32 = 67108864,
  max_name_bytes~ : Int32 = 67108864,
  max_symbols~ : Int32 = 1000000,
  max_symbol_name_bytes~ : Int32 = 67108864,
) -> Result[ThinArchive, ParseError] {
  if max_members < 0 ||
    max_bytes < 0 ||
    max_name_bytes < 0 ||
    max_symbols < 0 ||
    max_symbol_name_bytes < 0 {
    return Err({
      code: "AR008",
      offset: 0,
      message: "limits must be nonnegative",
    })
  }
  if input.length() > max_bytes {
    return Err({
      code: "AR008",
      offset: 0,
      message: "archive byte limit exceeded",
    })
  }
  try {
    let (archive, references) = parse_thin_records(
      input, max_members, max_name_bytes,
    )
    let mut seen_index = false
    for entry in archive.records {
      if entry.kind == SymbolTable {
        if seen_index {
          reject(
            "AR010",
            entry.header_offset,
            "multiple thin symbol indexes are unsupported",
          )
        }
        seen_index = true
      }
    }
    let indexes = parse_symbol_indexes(
      archive, max_symbols, max_symbol_name_bytes,
    )
    Ok({ archive: { ..archive, indexes, }, references, })
  } catch {
    InvalidArchive(error) => Err(error)
  }
}

///|
fn thin_relative_path(
  name : Bytes,
  offset : Int32,
  allow_parent_paths~ : Bool = false,
) -> Bytes raise InvalidArchive {
  if name.is_empty() || name[0] == b'/' {
    reject("AR012", offset, "external member path must be relative")
  }
  if name[name.length() - 1] == b'/' {
    reject("AR012", offset, "external member path must end in a file component")
  }
  for byte in name {
    if byte == b'\\' || byte == b':' || byte == b'\x00' {
      reject(
        "AR012", offset, "external member path contains a nonportable separator or prefix",
      )
    }
  }
  let normalized : Array[Byte] = []
  let mut cursor = 0
  while cursor < name.length() {
    let start = cursor
    while cursor < name.length() && name[cursor] != b'/' {
      cursor += 1
    }
    let part = name[start:cursor]
    if cursor == name.length() && (part == b"."[:] || part == b".."[:]) {
      reject(
        "AR012", offset, "external member path must end in a file component",
      )
    }
    if part == b".."[:] && !allow_parent_paths {
      reject(
        "AR012", offset, "external member path contains a parent component",
      )
    }
    if !part.is_empty() && part != b"."[:] {
      if !normalized.is_empty() {
        normalized.push(b'/')
      }
      for byte in part {
        normalized.push(byte)
      }
    }
    cursor += 1
  }
  if normalized.is_empty() {
    reject("AR012", offset, "external member path does not name a file")
  }
  Bytes::from_array(normalized)
}

///|
/// Return a portable relative path, removing "." and repeated "/" components.
/// Absolute, backslash, drive/stream and directory-ending paths fail with AR012.
/// Parent components require allow_parent_paths=true and remain in the returned
/// path so callers can resolve them relative to the archive directory.
pub fn ThinArchive::member_path(
  self : ThinArchive,
  index : Int32,
  allow_parent_paths~ : Bool = false,
) -> Result[Bytes, ParseError] {
  guard self.archive.records.get(index) is Some(entry) else {
    return Err({
      code: "AR011",
      offset: 0,
      message: "invalid thin member index",
    })
  }
  if entry.kind != Regular {
    return Err({
      code: "AR011",
      offset: entry.header_offset,
      message: "thin record is not an external member",
    })
  }
  Ok(thin_relative_path(entry.name, entry.header_offset, allow_parent_paths~)) catch {
    InvalidArchive(error) => Err(error)
  }
}

///|
/// Request an external member from a caller-supplied loader. The loader receives
/// a portable relative path and the maximum expected byte count. It controls the
/// base directory, symlink handling and actual I/O allocation. Declared size is
/// checked before calling it; returned bytes must match that size and max_bytes.
pub fn ThinArchive::load_member(
  self : ThinArchive,
  index : Int32,
  loader : (Bytes, Int32) -> Result[Bytes, String],
  max_bytes~ : Int32 = 67108864,
  allow_parent_paths~ : Bool = false,
  nested_limits~ : NestedLimits = NestedLimits::new(),
) -> Result[Bytes, ParseError] {
  self
  .resolve_member(
    index,
    loader,
    max_bytes~,
    allow_parent_paths~,
    nested_limits~,
  )
  .map(entry => entry.data)
}