// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// The length limits the specification puts on a frontmatter field.
///
/// They are a record rather than three constants because a client may enforce
/// a tighter budget than the specification's — a model with a smaller context
/// wants shorter descriptions — and because a test that reads the boundary
/// should read it from one place.
pub(all) struct Limits {
  /// The longest `name`, in characters. The specification says 64.
  name : Int32
  /// The longest `description`, in characters. The specification says 1024.
  description : Int32
  /// The longest `compatibility`, in characters. The specification says 500.
  compatibility : Int32
}

///|
/// The limits of the Agent Skills specification.
pub let limits : Limits = { name: 64, description: 1024, compatibility: 500, }

///|
/// Every rule the specification states about the frontmatter, judged against
/// the skill's own text.
///
/// The list is empty when the skill is valid. It is not a refusal: a validator
/// reports all the faults at once, because a person fixing a document wants the
/// whole list rather than the first line.
///
/// Two things are deliberately not faults. The body length and the token budget
/// are recommendations — the specification asks for a `SKILL.md` under five
/// hundred lines and says nothing breaks when it is longer — and an unknown key
/// under `metadata` is the caller's business, since the specification reserves
/// that mapping for clients.
pub fn validate(skill : Skill, limits~ : Limits = limits) -> Array[Fault] {
  let faults = []
  let unexpected = []
  for key, _ in skill.meta {
    if !is_allowed_field(key) {
      unexpected.push(key)
    }
  }
  if unexpected.length() > 0 {
    unexpected.sort_by((a, b) => a.lexical_compare(b))
    faults.push(UnexpectedFields(unexpected))
  }
  match field_of(skill, "name", skill.name) {
    None => faults.push(MissingName)
    Some(raw) => judge_name(faults, raw, skill.root, limits)
  }
  match field_of(skill, "description", skill.description) {
    None => faults.push(MissingDescription)
    Some(raw) =>
      if raw.trim() == "" {
        faults.push(DescriptionEmpty)
      } else if raw.char_length() > limits.description {
        faults.push(DescriptionTooLong(raw.char_length(), limits.description))
      }
  }
  match skill.meta.get("compatibility") {
    None => ()
    Some(value) =>
      match value {
        String(text) =>
          if text.char_length() > limits.compatibility {
            faults.push(
              CompatibilityTooLong(text.char_length(), limits.compatibility),
            )
          }
        _ => faults.push(CompatibilityNotText)
      }
  }
  faults
}

///|
/// Every fault in a document, from the fence to the last rule.
///
/// This is the call a validator makes: a document that does not parse is one
/// fault of kind `malformed`, and the rest of the list is whatever
/// [`validate`] finds in the fields. It is the whole check of a `SKILL.md` in
/// one function, which is what a command line and a test both want.
pub fn faults(
  text : StringView,
  root? : String,
  limits~ : Limits = limits,
) -> Array[Fault] {
  let parsed : Result[Skill, Refused] = Ok(parse(text, root?)) catch {
    Refused::MissingFrontmatter => Err(MissingFrontmatter)
    Refused::UnclosedFrontmatter => Err(UnclosedFrontmatter)
    Refused::BadYaml(reason) => Err(BadYaml(reason))
    Refused::NotAMapping => Err(NotAMapping)
    Refused::NotUtf8(at) => Err(NotUtf8(at))
    Refused::NoDocument => Err(NoDocument)
  }
  match parsed {
    Ok(skill) => validate(skill, limits~)
    Err(refused) => [Malformed(refused)]
  }
}

///|
/// The frontmatter fields the specification defines. Anything else is a fault,
/// because a reader that ignores a key it does not know has silently thrown
/// away something the author meant.
fn is_allowed_field(key : StringView) -> Bool {
  key == "name" ||
  key == "description" ||
  key == "license" ||
  key == "compatibility" ||
  key == "metadata" ||
  key == "allowed-tools"
}

///|
/// The field as written, untrimmed, so the length rule counts what the author
/// wrote rather than what this library would store.
///
/// `None` means the frontmatter has no such key and the parsed field is empty
/// too — the case the specification calls a missing required field.
fn field_of(skill : Skill, key : String, parsed : String) -> String? {
  match skill.meta.get(key) {
    Some(String(text)) => Some(text)
    Some(_) => Some("")
    None => if parsed == "" { None } else { Some(parsed) }
  }
}

///|
fn judge_name(
  faults : Array[Fault],
  raw : String,
  root : String,
  limits : Limits,
) -> Unit {
  // The specification is about what a reader sees, so the checks run on the
  // composed form: `cafe` plus a combining acute and `café` are one name.
  let name = @normalize.normalize(@normalize.NFKC, raw.trim())
  if name == "" {
    faults.push(NameEmpty)
    return
  }
  if name.char_length() > limits.name {
    faults.push(NameTooLong(name, name.char_length(), limits.name))
  }
  if @moonucd.lower(name) != name {
    faults.push(NameNotLowercase(name))
  }
  if name.has_prefix("-") || name.has_suffix("-") {
    faults.push(NameEdgeHyphen)
  }
  if name.contains("--") {
    faults.push(NameDoubleHyphen)
  }
  if !name.all(is_name_char) {
    faults.push(NameBadCharacter(name))
  }
  if root != "" {
    let directory = @normalize.normalize(@normalize.NFKC, base_name(root))
    if directory != name {
      faults.push(NameMismatch(directory, name))
    }
  }
}

///|
/// Whether a character may appear in a name.
///
/// The specification says lowercase letters, digits and hyphens. "Letters" is
/// the Unicode sense — a skill named in Chinese or Russian is a skill whose
/// author writes Chinese or Russian — so the test is the one Python's
/// `isalnum` makes: any letter, any digit, any number.
fn is_name_char(c : Char) -> Bool {
  if c == '-' {
    return true
  }
  match @moonucd.category(c) {
    Lu | Ll | Lt | Lm | Lo | Nd | Nl | No => true
    _ => false
  }
}