// LunaPack core: data model, package format, JSON & binary helpers.
// Package: riantr/LunaPack
//
// Package layout (v2):
//
//   [0..8)      magic "APPKGD02"
//   [8..)       entry payloads, stored back to back
//   [moff..)    manifest, UTF-8 JSON
//   last 24 B   footer: magic(8) + manifest_offset(8 LE) + manifest_length(8 LE)

///|
/// Magic marker at the head and inside the footer of every .appkg file.
/// v2 = SHA-256 checksums + optional gzip compression + install scripts.
///
/// The marker names the *container* layout, which did not change when the
/// MD5 fingerprint was added: the fingerprint is stored in the manifest and
/// is optional, so a package written by an older version still unpacks and a
/// newer version still reads an older package.
pub const MAGIC : String = "APPKGD02"

///|
/// Format version recorded in the manifest.
///
/// v3 adds the MD5 fingerprint of every entry next to its SHA-256 checksum.
/// v4 adds the LZ4 codec. v5 adds the encryption of the entries.
pub const FORMAT_VERSION : Int32 = 5

///|
/// Checksum algorithm used for every entry payload.
pub const CHECKSUM_ALGO : String = "sha256"

// ---------------------------------------------------------------- UTF-8 ---

///|
/// Encode a String into UTF-8 bytes.
/// NOTE: MoonBit strings are UTF-16 internally, so `String::to_bytes` yields
/// UTF-16 and must not be used for on-disk data. The bundled `@utf8` codec
/// gives real UTF-8.
pub fn string_to_utf8_bytes(s : String) -> Bytes {
  @utf8.encode(s)
}

///|
/// Decode UTF-8 bytes into a String.
/// NOTE: `Bytes::to_string()` returns the debug representation `b'\x..'`,
/// not the decoded text, so it must not be used here.
pub fn utf8_bytes_to_string(bs : Bytes) -> String {
  @utf8.decode_lossy(bs[:])
}

///|
/// The 8 magic bytes that open the package and close the footer.
pub fn magic_bytes() -> Bytes {
  string_to_utf8_bytes(MAGIC)
}

// -------------------------------------------------------------- structs ---

///|
/// One file inside the package.
pub struct Entry {
  /// Path relative to the package root, always using '/' as separator.
  path : String
  /// UNIX permission bits (0o644 / 0o755). Ignored on Windows.
  mode : Int32
  /// Size of the original file in bytes.
  size : Int64
  /// Number of bytes this entry occupies inside the package.
  stored_size : Int64
  /// How the payload is stored: "raw" or "gzip".
  codec : String
  /// Lowercase hex SHA-256 of the ORIGINAL (decompressed) content.
  checksum : String
  /// Lowercase hex MD5 of the ORIGINAL (decompressed) content.
  ///
  /// MD5 is not collision resistant, so it is only a convenience fingerprint
  /// that can be compared against what the platform tooling shows; the
  /// integrity of the package rests on `checksum`.
  md5 : String
  /// Byte offset of the payload inside the package.
  offset : Int64
}

///|
/// An install script together with the platform it targets.
///
/// A package built on Windows can carry a Windows batch script while a
/// package built on Linux carries a POSIX shell script; the matching one is
/// executed at unpack time and the other is skipped.
pub struct Script {
  /// "windows", "linux", "macos", "posix" (linux+macos) or "any".
  platform : String
  /// The script body.
  body : String
}

///|
/// Constructor so other packages can build a Script.
pub fn new_script(platform : String, body : String) -> Script {
  { platform, body, }
}

///|
/// Package metadata plus the list of entries.
pub struct Manifest {
  format_version : Int32
  name : String
  version : String
  os : String
  arch : String
  checksum_algo : String
  /// Compression policy requested at pack time: "none", "gzip" or "auto".
  compression : String
  /// ISO-8601 UTC timestamp of creation.
  created_at : String
  /// Name of the entry that is the main program, empty if there is none.
  main_program : String
  /// Script executed in the destination directory before extraction.
  pre_install : Script?
  /// Script executed in the destination directory after extraction.
  post_install : Script?
  /// How the entries are encrypted, if they are.
  encryption : Encryption?
  entries : Array[Entry]
}

///|
/// Options accepted by `pack`.
pub struct PackOptions {
  name : String
  version : String
  os : String
  arch : String
  pre_install : Script?
  post_install : Script?
  /// "none" | "gzip" | "auto"
  compression : String
  /// Name of the entry that is the main program, empty if there is none.
  main_program : String
  /// How the entries are encrypted, if they are.
  encryption : EncryptionOptions?
}

///|
/// Constructor so callers in other packages can build PackOptions without
/// relying on cross-package struct literals (which MoonBit disallows).
pub fn new_pack_options(
  name : String,
  version : String,
  os : String,
  arch : String,
  pre_install : Script?,
  post_install : Script?,
  compression : String,
) -> PackOptions {
  {
    name,
    version,
    os,
    arch,
    pre_install,
    post_install,
    compression,
    main_program: "",
    encryption: None,
  }
}

///|
/// Return a copy of `opts` that encrypts the package.
pub fn with_encryption(
  opts : PackOptions,
  encryption : EncryptionOptions,
) -> PackOptions {
  {
    name: opts.name,
    version: opts.version,
    os: opts.os,
    arch: opts.arch,
    pre_install: opts.pre_install,
    post_install: opts.post_install,
    compression: opts.compression,
    main_program: opts.main_program,
    encryption: Some(encryption),
  }
}

///|
/// Return a copy of `opts` whose main program is `name`.
pub fn with_main_program(opts : PackOptions, name : String) -> PackOptions {
  {
    name: opts.name,
    version: opts.version,
    os: opts.os,
    arch: opts.arch,
    pre_install: opts.pre_install,
    post_install: opts.post_install,
    compression: opts.compression,
    main_program: name,
    encryption: opts.encryption,
  }
}

///|
/// Return a copy of `opts` with the given metadata fields replaced.
pub fn with_defaults(
  opts : PackOptions,
  name : String,
  version : String,
  os : String,
  arch : String,
) -> PackOptions {
  {
    name,
    version,
    os,
    arch,
    pre_install: opts.pre_install,
    post_install: opts.post_install,
    compression: opts.compression,
    main_program: opts.main_program,
    encryption: opts.encryption,
  }
}

// ----------------------------------------------------------------- JSON ---

///|
fn json_escape(s : String) -> String {
  let mut out = ""
  let mut i = 0
  while i < s.length() {
    let c = s[i]
    if c == '"' {
      out = out + "\\\""
    } else if c == '\\' {
      out = out + "\\\\"
    } else if c == '\n' {
      out = out + "\\n"
    } else if c == '\r' {
      out = out + "\\r"
    } else if c == '\t' {
      out = out + "\\t"
    } else {
      // `s[i]` yields a UTF-16 code unit; convert it back to a character.
      match c.to_int().to_char() {
        Some(x) => out = out + String::make(1, x)
        None => out = out + "?"
      }
    }
    i = i + 1
  }
  out
}

///|
/// Serialize an optional script as a nested JSON object.
fn script_to_json(o : Script?) -> String {
  match o {
    Some(sc) =>
      "{\"platform\":\"" +
      json_escape(sc.platform) +
      "\",\"body\":\"" +
      json_escape(sc.body) +
      "\"}"
    None => "null"
  }
}

///|
/// Serialize the encryption of a package as a nested JSON object.
fn encryption_to_json(e : Encryption?) -> String {
  match e {
    Some(x) =>
      "{\"method\":\"" +
      json_escape(x.method_name) +
      "\",\"recipient\":\"" +
      json_escape(x.recipient) +
      "\",\"ephemeral\":\"" +
      json_escape(x.ephemeral) +
      "\",\"salt\":\"" +
      json_escape(x.salt) +
      "\",\"iterations\":" +
      x.iterations.to_string() +
      ",\"key_check\":\"" +
      json_escape(x.key_check) +
      "\"}"
    None => "null"
  }
}

///|
/// Serialize a manifest into the canonical UTF-8 JSON representation.
pub fn manifest_to_json(m : Manifest) -> String {
  let mut s = "{"
  s = s + "\"format_version\":" + m.format_version.to_string()
  s = s + ",\"name\":\"" + json_escape(m.name) + "\""
  s = s + ",\"version\":\"" + json_escape(m.version) + "\""
  s = s + ",\"os\":\"" + json_escape(m.os) + "\""
  s = s + ",\"arch\":\"" + json_escape(m.arch) + "\""
  s = s + ",\"checksum_algo\":\"" + json_escape(m.checksum_algo) + "\""
  s = s + ",\"compression\":\"" + json_escape(m.compression) + "\""
  s = s + ",\"created_at\":\"" + json_escape(m.created_at) + "\""
  s = s + ",\"main_program\":\"" + json_escape(m.main_program) + "\""
  s = s + ",\"pre_install\":" + script_to_json(m.pre_install)
  s = s + ",\"post_install\":" + script_to_json(m.post_install)
  s = s + ",\"encryption\":" + encryption_to_json(m.encryption)
  s = s + ",\"entries\":["
  let mut i = 0
  while i < m.entries.length() {
    let e = m.entries[i]
    if i > 0 {
      s = s + ","
    }
    s = s + "{\"path\":\"" + json_escape(e.path) + "\""
    s = s + ",\"mode\":" + e.mode.to_string()
    s = s + ",\"size\":" + e.size.to_string()
    s = s + ",\"stored_size\":" + e.stored_size.to_string()
    s = s + ",\"codec\":\"" + json_escape(e.codec) + "\""
    s = s + ",\"checksum\":\"" + e.checksum + "\""
    s = s + ",\"md5\":\"" + e.md5 + "\""
    s = s + ",\"offset\":" + e.offset.to_string()
    s = s + "}"
    i = i + 1
  }
  s = s + "]}"
  s
}

///|
/// Read a field out of a JSON object.
///
/// `Json::value` is deprecated; the pattern match is the recommended form.
fn json_field(obj : @json.Json, key : String) -> @json.Json? {
  match obj {
    Object(o) => o.get(key)
    _ => None
  }
}

///|
/// The string carried by a JSON node, if it is a string.
fn json_string(j : @json.Json) -> String? {
  match j {
    String(s) => Some(s)
    _ => None
  }
}

///|
/// The number carried by a JSON node, if it is a number.
fn json_number(j : @json.Json) -> Double? {
  match j {
    Number(n, ..) => Some(n)
    _ => None
  }
}

///|
/// The array carried by a JSON node, if it is an array.
fn json_array(j : @json.Json) -> Array[@json.Json]? {
  match j {
    Array(a) => Some(a)
    _ => None
  }
}

///|
/// Parse the manifest JSON back into a `Manifest`.
pub fn json_to_manifest(text : String) -> Manifest raise {
  let j = @json.parse(text) catch {
    err => fail("invalid manifest JSON: " + err.to_string())
  }
  let get_str = fn(obj : @json.Json, k : String) -> String raise {
    match json_field(obj, k) {
      Some(v) =>
        match json_string(v) {
          Some(x) => x
          None => fail("field " + k + " is not a string")
        }
      None => fail("missing field " + k)
    }
  }
  let get_str_def = fn(obj : @json.Json, k : String, dflt : String) -> String {
    match json_field(obj, k) {
      Some(v) =>
        match json_string(v) {
          Some(x) => x
          None => dflt
        }
      None => dflt
    }
  }
  let get_i64 = fn(obj : @json.Json, k : String) -> Int64 raise {
    match json_field(obj, k) {
      Some(v) =>
        match json_number(v) {
          Some(n) => n.to_int64()
          None => fail("field " + k + " is not a number")
        }
      None => fail("missing field " + k)
    }
  }
  let get_i64_def = fn(obj : @json.Json, k : String, dflt : Int64) -> Int64 {
    match json_field(obj, k) {
      Some(v) =>
        match json_number(v) {
          Some(n) => n.to_int64()
          None => dflt
        }
      None => dflt
    }
  }
  let get_int_def = fn(obj : @json.Json, k : String, dflt : Int32) -> Int32 {
    match json_field(obj, k) {
      Some(v) =>
        match json_number(v) {
          Some(n) => n.to_int()
          None => dflt
        }
      None => dflt
    }
  }
  let get_script = fn(obj : @json.Json, k : String) -> Script? {
    match json_field(obj, k) {
      Some(v) =>
        match json_field(v, "body") {
          Some(b) =>
            match json_string(b) {
              Some(body) => {
                let plat = match json_field(v, "platform") {
                  Some(p) =>
                    match json_string(p) {
                      Some(x) => x
                      None => "any"
                    }
                  None => "any"
                }
                Some(Script::{ platform: plat, body, })
              }
              None => None
            }
          None => None
        }
      None => None
    }
  }
  let get_encryption = fn(obj : @json.Json) -> Encryption? {
    match json_field(obj, "encryption") {
      Some(v) =>
        match v {
          Object(_) => {
            let method_name = get_str_def(v, "method", "none")
            if method_name == "none" {
              None
            } else {
              Some(
                new_encryption(
                  method_name,
                  get_str_def(v, "recipient", ""),
                  get_str_def(v, "ephemeral", ""),
                  get_str_def(v, "salt", ""),
                  get_int_def(v, "iterations", 0),
                  get_str_def(v, "key_check", ""),
                ),
              )
            }
          }
          _ => None
        }
      None => None
    }
  }
  let entries_arr = match json_field(j, "entries") {
    Some(v) =>
      match json_array(v) {
        Some(a) => a
        None => fail("entries is not an array")
      }
    None => fail("missing entries")
  }
  let entries = []
  for e in entries_arr {
    let size = get_i64(e, "size")
    entries.push({
      path: get_str(e, "path"),
      mode: get_int_def(e, "mode", 0o644),
      size,
      stored_size: get_i64_def(e, "stored_size", size),
      codec: get_str_def(e, "codec", "raw"),
      checksum: get_str(e, "checksum"),
      md5: get_str_def(e, "md5", ""),
      offset: get_i64(e, "offset"),
    })
  }
  {
    format_version: get_i64_def(j, "format_version", 1L).to_int(),
    name: get_str_def(j, "name", "unknown"),
    version: get_str_def(j, "version", "0.0.0"),
    os: get_str_def(j, "os", "unknown"),
    arch: get_str_def(j, "arch", "unknown"),
    checksum_algo: get_str_def(j, "checksum_algo", "sha256"),
    compression: get_str_def(j, "compression", "none"),
    created_at: get_str_def(j, "created_at", ""),
    main_program: get_str_def(j, "main_program", ""),
    pre_install: get_script(j, "pre_install"),
    post_install: get_script(j, "post_install"),
    encryption: get_encryption(j),
    entries,
  }
}

// --------------------------------------------------------------- binary ---

///|
/// 8-byte little-endian encoding of an Int64 into Bytes.
pub fn i64_to_le8(v : Int64) -> Bytes {
  let buf = []
  let mut i = 0
  while i < 8 {
    buf.push(((v >> (i * 8)) & 0xffL).to_byte())
    i = i + 1
  }
  Bytes::from_array(buf)
}

///|
/// Read an 8-byte little-endian Int64 from `b` starting at byte `start`.
pub fn le8_to_i64(b : Bytes, start : Int32) -> Int64 {
  let mut v : Int64 = 0
  let mut i = 0
  while i < 8 {
    v = v | (b.at((start + i : Int32)).to_int64() << (i * 8))
    i = i + 1
  }
  v
}

// ----------------------------------------------------------------- time ---

///|
fn pad2(n : Int32) -> String {
  if n < 10 {
    "0" + n.to_string()
  } else {
    n.to_string()
  }
}

///|
fn pad4(n : Int32) -> String {
  if n < 10 {
    "000" + n.to_string()
  } else if n < 100 {
    "00" + n.to_string()
  } else if n < 1000 {
    "0" + n.to_string()
  } else {
    n.to_string()
  }
}

///|
/// Convert a day count since 1970-01-01 into a civil (y, m, d) date.
/// Uses Howard Hinnant's `civil_from_days` algorithm.
fn civil_from_days(z0 : Int64) -> (Int32, Int32, Int32) {
  let z = z0 + 719468
  let era = (if z >= 0 { z } else { z - 146096 }) / 146097
  let doe = z - era * 146097
  let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
  let y = yoe + era * 400
  let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
  let mp = (5L * doy + 2L) / 153L
  let d = doy - (153L * mp + 2L) / 5L + 1L
  let m = if mp < 10L { mp + 3L } else { mp - 9L }
  let y = if m <= 2L { y + 1L } else { y }
  (y.to_int(), m.to_int(), d.to_int())
}

///|
/// Format epoch milliseconds as an ISO-8601 UTC timestamp, e.g.
/// 2026-11-03T14:35:55Z. (`@env.now()` reports milliseconds.)
pub fn iso8601_utc(epoch_millis : UInt64) -> String {
  let epoch_secs = epoch_millis / 1000UL
  let days = (epoch_secs / 86400UL).reinterpret_as_int64()
  let sod = (epoch_secs % 86400UL).reinterpret_as_int64()
  let (y, m, d) = civil_from_days(days)
  let hh = (sod / 3600).to_int()
  let mm = (sod % 3600 / 60).to_int()
  let ss = (sod % 60).to_int()
  pad4(y) +
  "-" +
  pad2(m) +
  "-" +
  pad2(d) +
  "T" +
  pad2(hh) +
  ":" +
  pad2(mm) +
  ":" +
  pad2(ss) +
  "Z"
}