// AT-URI -- `at://authority/collection/rkey`, the way one record refers to
// another. The `uri` half of every strong ref, every reply parent, every embed.
//
// https://atproto.com/specs/at-uri-scheme
// Ported from @atproto/syntax packages/syntax/src/aturi_validation.ts, the
// strict "Restricted AT URI Syntax" that Lexicons use -- not the older, laxer
// `AtUri` class, which admits syntax a server will reject.
//
// This parses into COMPONENTS rather than keeping a path string, and the
// components are an enum rather than two independent options. Both choices are
// reactions to the same bug: rsky models an AT-URI as `{host, pathname}` and
// re-splits `pathname` in every accessor, and its `set_collection` writes
// segment 0 while `get_collection` reads segment 1 -- so setting a collection
// silently returns the record key afterwards. A shape that cannot represent
// "an rkey with no collection" cannot have that bug.

///|
/// 8 KB. Larger than anything real; the point is to bound work on hostile
/// input, not to express a protocol limit.
const AT_URI_MAX_LENGTH : Int = 8192

///|
/// What an AT-URI points at. A record key without a collection is not a state
/// this type can hold.
pub(all) enum AtUriPath {
  /// `at://alice.bsky.social` -- the repository itself.
  Repo
  /// `at://alice.bsky.social/app.bsky.feed.post` -- one collection in it.
  Collection(Nsid)
  /// `at://alice.bsky.social/app.bsky.feed.post/3jzfcijpj2z2a` -- one record.
  Record(collection~ : Nsid, rkey~ : RecordKey)
} derive(Eq, Debug)

///|
/// A syntactically valid AT-URI.
pub struct AtUri {
  authority : AtIdentifier
  path : AtUriPath
  /// A JSON pointer into the record, including its leading slash. Rare; used by
  /// Lexicon references and by moderation reports that cite one field.
  fragment : String?
} derive(Eq, Debug)

///|
pub impl Show for AtUri with fn output(self, logger) {
  logger.write_string(self.to_string())
}

///|
pub fn AtUri::to_string(self : Self) -> String {
  let b = StringBuilder::new()
  b.write_string("at://")
  b.write_string(self.authority.to_string())
  match self.path {
    Repo => ()
    Collection(nsid) => {
      b.write_char('/')
      b.write_string(nsid.to_string())
    }
    Record(collection~, rkey~) => {
      b.write_char('/')
      b.write_string(collection.to_string())
      b.write_char('/')
      b.write_string(rkey.to_string())
    }
  }
  if self.fragment is Some(f) {
    b.write_char('#')
    b.write_string(f)
  }
  b.to_string()
}

///|
pub fn AtUri::authority(self : Self) -> AtIdentifier {
  self.authority
}

///|
pub fn AtUri::path(self : Self) -> AtUriPath {
  self.path
}

///|
pub fn AtUri::fragment(self : Self) -> String? {
  self.fragment
}

///|
pub fn AtUri::collection(self : Self) -> Nsid? {
  match self.path {
    Repo => None
    Collection(nsid) => Some(nsid)
    Record(collection~, ..) => Some(collection)
  }
}

///|
pub fn AtUri::rkey(self : Self) -> RecordKey? {
  match self.path {
    Record(rkey~, ..) => Some(rkey)
    _ => None
  }
}

///|
/// Builds an AT-URI from parts that are already valid, so it cannot fail. This
/// is the constructor to reach for when you have a DID and a record key in hand
/// rather than a string to interpret.
pub fn AtUri::make(
  authority : AtIdentifier,
  path : AtUriPath,
  fragment? : String,
) -> AtUri {
  { authority, path, fragment }
}

///|
pub fn AtUri::is_valid(uri : String) -> Bool {
  try {
    AtUri::parse(uri) |> ignore
    true
  } catch {
    _ => false
  }
}

///|
/// Note what this does NOT accept, all of which the older `AtUri` class did: a
/// missing `at://` prefix, a trailing slash, a query string, and a record key
/// that is not a valid record key. Every one of those is in the protocol's
/// interop corpus as invalid.
pub fn AtUri::parse(uri : String) -> AtUri raise SyntaxError {
  fn bad(reason : String) -> SyntaxError {
    SyntaxError(kind=AtUri, input=uri, reason~)
  }

  guard uri.length() <= AT_URI_MAX_LENGTH else {
    raise bad("ATURI exceeds maximum length")
  }
  // This also rejects every whitespace character, which is why nothing below
  // has to think about spaces.
  guard all_chars(uri, is_at_uri_char) else {
    raise bad("Disallowed characters in ATURI (ASCII)")
  }
  guard uri.has_prefix("at://") else {
    raise bad("ATURI must start with \"at://\"")
  }

  // Fragment first, then query: a `?` after a `#` is part of the fragment, not
  // a query string.
  let (before_hash, fragment) = match uri.split_once("#") {
    Some((head, tail)) => (head.to_owned(), Some(tail.to_owned()))
    None => (uri, None)
  }
  guard before_hash.split_once("?") is None else {
    raise bad("ATURI query part is not allowed")
  }
  let rest = before_hash[5:].to_owned()
  guard !rest.has_suffix("/") else {
    raise bad("ATURI can not have a trailing slash")
  }
  let segments = rest.split("/").collect()
  guard segments.length() <= 3 else {
    raise bad("ATURI can not have more than two path segments")
  }
  for segment in segments {
    guard segment.length() > 0 else {
      raise bad("ATURI can not have empty path segments")
    }
  }
  let authority = AtIdentifier::parse(segments[0].to_owned()) catch {
    _ => raise bad("ATURI has invalid authority")
  }
  let path = if segments.length() == 1 {
    AtUriPath::Repo
  } else {
    let collection = Nsid::parse(segments[1].to_owned()) catch {
      _ => raise bad("ATURI has invalid collection")
    }
    if segments.length() == 2 {
      AtUriPath::Collection(collection)
    } else {
      let rkey = RecordKey::parse(segments[2].to_owned()) catch {
        _ => raise bad("ATURI has invalid record key")
      }
      AtUriPath::Record(collection~, rkey~)
    }
  }
  if fragment is Some(f) {
    guard is_valid_fragment(f) else { raise bad("ATURI has invalid fragment") }
  }
  { authority, path, fragment }
}

///|
/// `[a-zA-Z0-9._~:@!$&'()*+,;=%/\[\]#?-]` -- RFC 3986's unreserved and
/// sub-delims, plus the structural characters. Deliberately wider than any
/// component allows, so a bad character inside a collection is reported as a
/// bad collection rather than as a bad URI.
fn is_at_uri_char(c : Char) -> Bool {
  is_ascii_alnum(c) ||
  c == '.' ||
  c == '_' ||
  c == '~' ||
  c == ':' ||
  c == '@' ||
  c == '!' ||
  c == '$' ||
  c == '&' ||
  c == '\'' ||
  c == '(' ||
  c == ')' ||
  c == '*' ||
  c == '+' ||
  c == ',' ||
  c == ';' ||
  c == '=' ||
  c == '%' ||
  c == '/' ||
  c == '\\' ||
  c == '[' ||
  c == ']' ||
  c == '#' ||
  c == '?' ||
  c == '-'
}

///|
/// A fragment is a JSON pointer: it must be non-empty, start with `/`, and use
/// only the pointer character set -- which notably excludes `#`, so `#a#b` is
/// rejected rather than read as a fragment containing a hash.
///
/// Percent escapes must be well-formed. Upstream gets this by calling
/// `decodeURIComponent` and catching; the check here is the same rule without
/// building the decoded string, since the decoded value is not wanted.
fn is_valid_fragment(fragment : String) -> Bool {
  guard fragment.length() > 0 && fragment.has_prefix("/") else { return false }
  let mut i = 0
  while i < fragment.length() {
    let unit = fragment[i]
    if code_unit_is(unit, '%') {
      guard i + 2 < fragment.length() else { return false }
      guard is_hex_digit(fragment[i + 1]) && is_hex_digit(fragment[i + 2]) else {
        return false
      }
      i = i + 3
    } else {
      guard char_at_is_fragment_char(fragment, i) else { return false }
      i = i + 1
    }
  }
  true
}

///|
fn is_hex_digit(unit : UInt16) -> Bool {
  let i = unit.to_int()
  code_unit_is_ascii_digit(unit) ||
  (i >= 'a'.to_int() && i <= 'f'.to_int()) ||
  (i >= 'A'.to_int() && i <= 'F'.to_int())
}

///|
/// `[a-zA-Z0-9._~:@!$&'()*+,;=/\[\]-]`, i.e. the URI set without `%`, `#` and
/// `?`. `%` is handled by the escape branch above.
fn char_at_is_fragment_char(s : String, i : Int) -> Bool {
  let unit = s[i]
  if code_unit_is(unit, '#') ||
    code_unit_is(unit, '?') ||
    code_unit_is(unit, '%') {
    return false
  }
  code_unit_is_ascii_alpha(unit) ||
  code_unit_is_ascii_digit(unit) ||
  code_unit_is(unit, '.') ||
  code_unit_is(unit, '_') ||
  code_unit_is(unit, '~') ||
  code_unit_is(unit, ':') ||
  code_unit_is(unit, '@') ||
  code_unit_is(unit, '!') ||
  code_unit_is(unit, '$') ||
  code_unit_is(unit, '&') ||
  code_unit_is(unit, '\'') ||
  code_unit_is(unit, '(') ||
  code_unit_is(unit, ')') ||
  code_unit_is(unit, '*') ||
  code_unit_is(unit, '+') ||
  code_unit_is(unit, ',') ||
  code_unit_is(unit, ';') ||
  code_unit_is(unit, '=') ||
  code_unit_is(unit, '/') ||
  code_unit_is(unit, '\\') ||
  code_unit_is(unit, '[') ||
  code_unit_is(unit, ']') ||
  code_unit_is(unit, '-')
}