///|
/// Resource category recorded in a `.reslock` file.
pub(all) enum AssetKind {
  Text
  Binary
  Image
  Audio
  Font
  Data
  Other(String)
} derive(Eq)

///|
/// Cache and deployment intent for an asset.
pub(all) enum CachePolicy {
  Immutable
  Revalidate
  NoStore
  Runtime
} derive(Eq)

///|
/// Errors returned by parsers and constructors.
pub(all) enum ReslockError {
  EmptyPath
  AbsolutePath(String)
  ParentTraversal(String)
  InvalidPathSegment(String)
  InvalidTag(String)
  InvalidFingerprint(String)
  DuplicatePath(String)
  MissingHeader
  InvalidLine(Int, String)
  InvalidEscape(String)
  BadNumber(String)
  UnknownKind(String)
  UnknownPolicy(String)
} derive(Eq)

///|
/// One resource entry inside a deterministic resource lockfile.
pub(all) struct Asset {
  path : String
  kind : AssetKind
  bytes : Int
  fingerprint : String
  policy : CachePolicy
  tags : Array[String]
  note : String?
} derive(Eq)

///|
/// A resource manifest. Entries are sorted only when exported, so callers may
/// preserve discovery order during construction.
pub(all) struct Manifest {
  name : String
  version : String
  entries : Array[Asset]
} derive(Eq)

///|
/// Validation severity.
pub(all) enum FindingLevel {
  Error
  Warning
  Info
} derive(Eq)

///|
/// A validation finding with a stable machine-readable code.
pub(all) struct Finding {
  level : FindingLevel
  code : String
  path : String?
  message : String
} derive(Eq)

///|
/// Diff category for comparing two manifests.
pub(all) enum DiffKind {
  Added
  Removed
  Changed
} derive(Eq)

///|
/// A single manifest diff row.
pub(all) struct EntryDiff {
  kind : DiffKind
  path : String
  before : Asset?
  after : Asset?
  reasons : Array[String]
} derive(Eq)

///|
/// A compact, reproducible summary of a manifest.
pub(all) struct SealSummary {
  entries : Int
  total_bytes : Int
  manifest_fingerprint : String
  errors : Int
  warnings : Int
} derive(Eq)

///|
pub fn AssetKind::to_wire(self : AssetKind) -> String {
  match self {
    Text => "text"
    Binary => "binary"
    Image => "image"
    Audio => "audio"
    Font => "font"
    Data => "data"
    Other(value) => "other:\{value}"
  }
}

///|
pub fn CachePolicy::to_wire(self : CachePolicy) -> String {
  match self {
    Immutable => "immutable"
    Revalidate => "revalidate"
    NoStore => "no-store"
    Runtime => "runtime"
  }
}

///|
pub fn parse_kind(raw : String) -> Result[AssetKind, ReslockError] {
  let value = raw.trim().to_owned().to_lower()
  match value {
    "text" => Ok(Text)
    "binary" => Ok(Binary)
    "image" => Ok(Image)
    "audio" => Ok(Audio)
    "font" => Ok(Font)
    "data" => Ok(Data)
    _ =>
      match value.strip_prefix("other:") {
        Some(rest) =>
          if rest.is_empty() {
            Err(UnknownKind(raw))
          } else {
            Ok(Other(rest.to_owned()))
          }
        None => Err(UnknownKind(raw))
      }
  }
}

///|
pub fn parse_policy(raw : String) -> Result[CachePolicy, ReslockError] {
  match raw.trim().to_owned().to_lower() {
    "immutable" => Ok(Immutable)
    "revalidate" => Ok(Revalidate)
    "no-store" => Ok(NoStore)
    "runtime" => Ok(Runtime)
    other => Err(UnknownPolicy(other))
  }
}

///|
pub fn normalize_path(raw : String) -> Result[String, ReslockError] {
  let trimmed = raw.trim().to_owned()
  if trimmed.is_empty() {
    return Err(EmptyPath)
  }
  let path = trimmed.replace_all(old="\\", new="/")
  if path.has_prefix("/") {
    return Err(AbsolutePath(raw))
  }
  if path.length() >= 2 && path.get_char(1) is Some(':') {
    return Err(AbsolutePath(raw))
  }
  let parts : Array[String] = []
  for piece in path.split("/") {
    let part = piece.to_owned()
    if part == "" || part == "." {
      continue
    }
    if part == ".." {
      return Err(ParentTraversal(raw))
    }
    if !valid_path_segment(part) {
      return Err(InvalidPathSegment(part))
    }
    parts.push(part)
  }
  if parts.is_empty() {
    Err(EmptyPath)
  } else {
    Ok(parts.join("/"))
  }
}

///|
pub fn normalize_tags(tags : Array[String]) -> Array[String] {
  let normalized : Array[String] = []
  for tag in tags {
    let clean = tag.trim().to_owned().to_lower()
    if clean != "" && !normalized.contains(clean) {
      normalized.push(clean)
    }
  }
  normalized.sort()
  normalized
}

///|
pub fn valid_tag(tag : String) -> Bool {
  if tag.is_empty() {
    return false
  }
  for ch in tag {
    if !(ch.is_ascii_alphabetic() ||
      ch.is_ascii_digit() ||
      ch == '-' ||
      ch == '_' ||
      ch == '.') {
      return false
    }
  }
  true
}

///|
pub fn fingerprint_text(content : String) -> String {
  let mut hash : UInt64 = 0xcbf29ce484222325UL
  for ch in content {
    hash = hash.lxor(ch.to_uint().to_uint64())
    hash = hash * 0x100000001b3UL
  }
  "fnv1a64:" + hash.to_string(radix=16).pad_start(16, '0')
}

///|
pub fn fingerprint_fields(fields : Array[String]) -> String {
  let joined = fields.join("\n")
  fingerprint_text(joined)
}

///|
pub fn asset(
  path : String,
  content : String,
  kind? : AssetKind = Data,
  policy? : CachePolicy = Immutable,
  tags? : Array[String] = [],
  note? : String,
) -> Result[Asset, ReslockError] {
  match normalize_path(path) {
    Err(err) => Err(err)
    Ok(path) => {
      let tags = normalize_tags(tags)
      for tag in tags {
        if !valid_tag(tag) {
          return Err(InvalidTag(tag))
        }
      }
      Ok({
        path,
        kind,
        bytes: content.length(),
        fingerprint: fingerprint_text(content),
        policy,
        tags,
        note,
      })
    }
  }
}

///|
pub fn sealed_asset(
  path : String,
  kind : AssetKind,
  bytes : Int,
  fingerprint : String,
  policy : CachePolicy,
  tags? : Array[String] = [],
  note? : String,
) -> Result[Asset, ReslockError] {
  if bytes < 0 {
    return Err(BadNumber(bytes.to_string()))
  }
  if !valid_fingerprint(fingerprint) {
    return Err(InvalidFingerprint(fingerprint))
  }
  match normalize_path(path) {
    Err(err) => Err(err)
    Ok(path) => {
      let tags = normalize_tags(tags)
      for tag in tags {
        if !valid_tag(tag) {
          return Err(InvalidTag(tag))
        }
      }
      Ok({ path, kind, bytes, fingerprint, policy, tags, note })
    }
  }
}

///|
pub fn manifest(
  name : String,
  version : String,
  entries : Array[Asset],
) -> Manifest {
  { name, version, entries }
}

///|
pub fn validate_manifest(manifest : Manifest) -> Array[Finding] {
  let findings : Array[Finding] = []
  if manifest.name.trim().is_empty() {
    findings.push(
      finding_error("manifest-name-empty", None, "manifest name is empty"),
    )
  }
  if manifest.version.trim().is_empty() {
    findings.push(
      finding_error("manifest-version-empty", None, "manifest version is empty"),
    )
  }
  if manifest.entries.is_empty() {
    findings.push(
      finding_error("manifest-empty", None, "manifest has no entries"),
    )
  }
  let seen : Map[String, Bool] = Map([])
  for entry in manifest.entries {
    match normalize_path(entry.path) {
      Ok(path) =>
        if path != entry.path {
          findings.push(
            finding_warning(
              "path-not-normalized",
              Some(entry.path),
              "path normalizes to '\{path}'",
            ),
          )
        }
      Err(err) =>
        findings.push(
          finding_error(
            "path-invalid",
            Some(entry.path),
            "invalid path: " + error_message(err),
          ),
        )
    }
    if seen.contains(entry.path) {
      findings.push(
        finding_error(
          "path-duplicate",
          Some(entry.path),
          "same normalized path appears more than once",
        ),
      )
    } else {
      seen.set(entry.path, true)
    }
    if entry.bytes < 0 {
      findings.push(
        finding_error(
          "bytes-negative",
          Some(entry.path),
          "byte length is negative",
        ),
      )
    }
    if !valid_fingerprint(entry.fingerprint) {
      findings.push(
        finding_error(
          "fingerprint-invalid",
          Some(entry.path),
          "fingerprint must be fnv1a64 followed by 16 hex digits",
        ),
      )
    }
    for tag in entry.tags {
      if !valid_tag(tag) {
        findings.push(
          finding_error("tag-invalid", Some(entry.path), "invalid tag '\{tag}'"),
        )
      }
    }
    if entry.policy == NoStore && entry.kind == Image {
      findings.push(
        finding_warning(
          "image-no-store",
          Some(entry.path),
          "image assets are usually cacheable unless generated at runtime",
        ),
      )
    }
  }
  findings
}

///|
pub fn is_valid(manifest : Manifest) -> Bool {
  validate_manifest(manifest).all(f => f.level != Error)
}

///|
pub fn render_manifest(manifest : Manifest) -> String {
  let entries = [ for entry in manifest.entries => entry ]
  entries.sort_by((a, b) => a.path.compare(b.path))
  let out = StringBuilder()
  out.write_string("reslock|1|")
  out.write_string(escape_field(manifest.name))
  out.write_string("|")
  out.write_string(escape_field(manifest.version))
  out.write_string("\n")
  for entry in entries {
    out.write_string("entry|")
    out.write_string(escape_field(entry.path))
    out.write_string("|")
    out.write_string(entry.kind.to_wire())
    out.write_string("|")
    out.write_string(entry.bytes.to_string())
    out.write_string("|")
    out.write_string(entry.fingerprint)
    out.write_string("|")
    out.write_string(entry.policy.to_wire())
    out.write_string("|")
    out.write_string(join_tags(entry.tags))
    if entry.note is Some(note) {
      out.write_string("|")
      out.write_string(escape_field(note))
    }
    out.write_string("\n")
  }
  out.to_string()
}

///|
pub fn parse_manifest(input : String) -> Result[Manifest, ReslockError] {
  let mut seen_header = false
  let mut name = ""
  let mut version = ""
  let entries : Array[Asset] = []
  let mut line_number = 0
  for raw_line in input.split("\n") {
    line_number = line_number + 1
    let line0 = raw_line.to_owned()
    let line = match line0.strip_suffix("\r") {
      Some(clean) => clean.to_owned()
      None => line0
    }
    let trimmed = line.trim()
    if trimmed.is_empty() || trimmed.has_prefix("#") {
      continue
    }
    let fields = match split_record(line, '|') {
      Ok(fields) => fields
      Err(err) => return Err(err)
    }
    if !seen_header {
      if fields.length() != 4 || fields[0] != "reslock" || fields[1] != "1" {
        return Err(InvalidLine(line_number, "expected reslock v1 header"))
      }
      name = fields[2]
      version = fields[3]
      seen_header = true
      continue
    }
    if fields.length() != 7 && fields.length() != 8 {
      return Err(InvalidLine(line_number, "entry rows need 7 or 8 fields"))
    }
    if fields[0] != "entry" {
      return Err(InvalidLine(line_number, "unknown record '\{fields[0]}'"))
    }
    let kind = match parse_kind(fields[2]) {
      Ok(kind) => kind
      Err(err) => return Err(err)
    }
    let bytes = match parse_non_negative_int(fields[3]) {
      Ok(bytes) => bytes
      Err(err) => return Err(err)
    }
    let policy = match parse_policy(fields[5]) {
      Ok(policy) => policy
      Err(err) => return Err(err)
    }
    let tags = match parse_tags(fields[6]) {
      Ok(tags) => tags
      Err(err) => return Err(err)
    }
    let note = if fields.length() == 8 { Some(fields[7]) } else { None }
    let entry = match note {
      Some(note) =>
        match
          sealed_asset(fields[1], kind, bytes, fields[4], policy, tags~, note~) {
          Ok(entry) => entry
          Err(err) => return Err(err)
        }
      None =>
        match sealed_asset(fields[1], kind, bytes, fields[4], policy, tags~) {
          Ok(entry) => entry
          Err(err) => return Err(err)
        }
    }
    entries.push(entry)
  }
  if !seen_header {
    Err(MissingHeader)
  } else {
    Ok({ name, version, entries })
  }
}

///|
pub fn diff_manifest(before : Manifest, after : Manifest) -> Array[EntryDiff] {
  let old_map : Map[String, Asset] = Map([])
  for entry in before.entries {
    old_map.set(entry.path, entry)
  }
  let touched : Map[String, Bool] = Map([])
  let diffs : Array[EntryDiff] = []
  for entry in after.entries {
    touched.set(entry.path, true)
    match old_map.get(entry.path) {
      None =>
        diffs.push({
          kind: Added,
          path: entry.path,
          before: None,
          after: Some(entry),
          reasons: ["new"],
        })
      Some(old) => {
        let reasons = changed_reasons(old, entry)
        if !reasons.is_empty() {
          diffs.push({
            kind: Changed,
            path: entry.path,
            before: Some(old),
            after: Some(entry),
            reasons,
          })
        }
      }
    }
  }
  for entry in before.entries {
    if !touched.contains(entry.path) {
      diffs.push({
        kind: Removed,
        path: entry.path,
        before: Some(entry),
        after: None,
        reasons: ["missing"],
      })
    }
  }
  diffs.sort_by((a, b) => a.path.compare(b.path))
  diffs
}

///|
pub fn render_diff(diffs : Array[EntryDiff]) -> String {
  if diffs.is_empty() {
    return "no changes\n"
  }
  let out = StringBuilder()
  for diff in diffs {
    match diff.kind {
      Added => out.write_string("+ ")
      Removed => out.write_string("- ")
      Changed => out.write_string("~ ")
    }
    out.write_string(diff.path)
    if !diff.reasons.is_empty() {
      out.write_string(" [")
      out.write_string(diff.reasons.join(","))
      out.write_string("]")
    }
    out.write_string("\n")
  }
  out.to_string()
}

///|
pub fn summarize(manifest : Manifest) -> SealSummary {
  let findings = validate_manifest(manifest)
  let mut errors = 0
  let mut warnings = 0
  for finding in findings {
    match finding.level {
      Error => errors = errors + 1
      Warning => warnings = warnings + 1
      Info => ()
    }
  }
  {
    entries: manifest.entries.length(),
    total_bytes: total_bytes(manifest),
    manifest_fingerprint: fingerprint_text(render_manifest(manifest)),
    errors,
    warnings,
  }
}

///|
pub fn demo_manifest() -> Manifest {
  let stylesheet = asset(
    "public\\css\\app.css",
    "body{color:#24313f}",
    kind=Text,
    tags=["critical", "style"],
    note="first paint",
  ).unwrap()
  let sprite = asset(
    "public/img/logo.sprite",
    "MOONBIT-RESOURCE-SPRITE",
    kind=Image,
    policy=Revalidate,
    tags=["image", "ui"],
  ).unwrap()
  manifest("reslock-demo", "2026.08", [stylesheet, sprite])
}

///|
fn finding_error(code : String, path : String?, message : String) -> Finding {
  { level: Error, code, path, message }
}

///|
fn finding_warning(code : String, path : String?, message : String) -> Finding {
  { level: Warning, code, path, message }
}

///|
fn valid_path_segment(segment : String) -> Bool {
  if segment.is_empty() {
    return false
  }
  for ch in segment {
    if ch.is_control() || ch == '|' || ch == '\n' || ch == '\r' {
      return false
    }
  }
  true
}

///|
fn valid_fingerprint(value : String) -> Bool {
  if !value.has_prefix("fnv1a64:") || value.length() != 24 {
    return false
  }
  for ch in value.sub(start=8) {
    if !ch.is_ascii_hexdigit() {
      return false
    }
  }
  true
}

///|
fn escape_field(value : String) -> String {
  let out = StringBuilder()
  for ch in value {
    match ch {
      '\\' => out.write_string("\\\\")
      '|' => out.write_string("\\p")
      ',' => out.write_string("\\c")
      '\n' => out.write_string("\\n")
      '\r' => out.write_string("\\r")
      '\t' => out.write_string("\\t")
      _ => out.write_char(ch)
    }
  }
  out.to_string()
}

///|
fn unescape_field(value : String) -> Result[String, ReslockError] {
  let out = StringBuilder()
  let mut escaping = false
  for ch in value {
    if escaping {
      match ch {
        '\\' => out.write_char('\\')
        'p' => out.write_char('|')
        'c' => out.write_char(',')
        'n' => out.write_char('\n')
        'r' => out.write_char('\r')
        't' => out.write_char('\t')
        _ => return Err(InvalidEscape(value))
      }
      escaping = false
    } else if ch == '\\' {
      escaping = true
    } else {
      out.write_char(ch)
    }
  }
  if escaping {
    Err(InvalidEscape(value))
  } else {
    Ok(out.to_string())
  }
}

///|
fn split_escaped(
  value : String,
  delimiter : Char,
) -> Result[Array[String], ReslockError] {
  let parts : Array[String] = []
  let out = StringBuilder()
  let mut escaping = false
  for ch in value {
    if escaping {
      out.write_char('\\')
      out.write_char(ch)
      escaping = false
    } else if ch == '\\' {
      escaping = true
    } else if ch == delimiter {
      parts.push(out.to_string())
      out.reset()
    } else {
      out.write_char(ch)
    }
  }
  if escaping {
    Err(InvalidEscape(value))
  } else {
    parts.push(out.to_string())
    Ok(parts)
  }
}

///|
fn split_record(
  value : String,
  delimiter : Char,
) -> Result[Array[String], ReslockError] {
  let raw = match split_escaped(value, delimiter) {
    Ok(raw) => raw
    Err(err) => return Err(err)
  }
  let fields : Array[String] = []
  for field in raw {
    let value = match unescape_field(field) {
      Ok(value) => value
      Err(err) => return Err(err)
    }
    fields.push(value)
  }
  Ok(fields)
}

///|
fn join_tags(tags : Array[String]) -> String {
  let rendered : Array[String] = []
  for tag in tags {
    rendered.push(escape_field(tag))
  }
  rendered.join(",")
}

///|
fn parse_tags(raw : String) -> Result[Array[String], ReslockError] {
  if raw == "" {
    return Ok([])
  }
  let pieces = match split_record(raw, ',') {
    Ok(pieces) => pieces
    Err(err) => return Err(err)
  }
  let tags = normalize_tags(pieces)
  for tag in tags {
    if !valid_tag(tag) {
      return Err(InvalidTag(tag))
    }
  }
  Ok(tags)
}

///|
fn changed_reasons(before : Asset, after : Asset) -> Array[String] {
  let reasons : Array[String] = []
  if before.fingerprint != after.fingerprint {
    reasons.push("fingerprint")
  }
  if before.bytes != after.bytes {
    reasons.push("bytes")
  }
  if before.kind != after.kind {
    reasons.push("kind")
  }
  if before.policy != after.policy {
    reasons.push("policy")
  }
  if before.tags != after.tags {
    reasons.push("tags")
  }
  reasons
}

///|
fn total_bytes(manifest : Manifest) -> Int {
  let mut total = 0
  for entry in manifest.entries {
    total = total + entry.bytes
  }
  total
}

///|
fn parse_non_negative_int(raw : String) -> Result[Int, ReslockError] {
  let value = raw.trim().to_owned()
  if value.is_empty() {
    return Err(BadNumber(raw))
  }
  let mut total = 0
  for ch in value {
    if !ch.is_ascii_digit() {
      return Err(BadNumber(raw))
    }
    total = total * 10 + ch.to_int() - '0'.to_int()
  }
  Ok(total)
}

///|
fn error_message(err : ReslockError) -> String {
  match err {
    EmptyPath => "empty path"
    AbsolutePath(path) => "absolute path '\{path}'"
    ParentTraversal(path) => "parent traversal in '\{path}'"
    InvalidPathSegment(segment) => "invalid path segment '\{segment}'"
    InvalidTag(tag) => "invalid tag '\{tag}'"
    InvalidFingerprint(value) => "invalid fingerprint '\{value}'"
    DuplicatePath(path) => "duplicate path '\{path}'"
    MissingHeader => "missing reslock header"
    InvalidLine(line, message) => "invalid line \{line}: \{message}"
    InvalidEscape(value) => "invalid escape sequence in '\{value}'"
    BadNumber(value) => "bad number '\{value}'"
    UnknownKind(value) => "unknown kind '\{value}'"
    UnknownPolicy(value) => "unknown policy '\{value}'"
  }
}