///|
/// A custom id that exceeds Discord's limit or cannot round-trip its segments.
pub(all) suberror CustomIdError {
  /// The encoded state or complete custom id exceeds 100 UTF-16 units.
  TooLong(custom_id~ : String, length~ : Int)
  /// A non-final zip segment contains `:` and would not round-trip.
  SeparatorInSegment(segment~ : String)
} derive(Debug)

///|
fn check_custom_id_length(custom_id : String) -> Unit raise CustomIdError {
  let length = custom_id.length()
  if length > 100 {
    raise TooLong(custom_id~, length~)
  }
}

///|
/// A reversible text encoding for the state carried in a custom id.
/// Custom encode/decode functions and `imap` mappings must preserve round trips.
pub struct CustomIdCodec[A] {
  priv encode_ : (A) -> String
  priv decode_ : (String) -> A raise HandlerError
  priv validate_ : (A) -> Unit raise CustomIdError
}

///|
/// Encode unit as no state at all, producing only the route id.
pub fn CustomIdCodec::unit() -> CustomIdCodec[Unit] {
  CustomIdCodec::custom(encode=_ => "", decode=text => {
    guard text.is_empty() else {
      raise InvalidArgument("expected no component state")
    }
  })
}

///|
/// Preserve the state string, including separators in a final zip segment.
pub fn CustomIdCodec::string() -> CustomIdCodec[String] {
  CustomIdCodec::custom(encode=value => value, decode=text => text)
}

///|
/// Encode and decode a decimal integer.
pub fn CustomIdCodec::int() -> CustomIdCodec[Int] {
  CustomIdCodec::custom(encode=value => value.to_string(), decode=text => {
    @string.parse_int(text) catch {
      source => raise InvalidArgument("\{Repr(source)}")
    }
  })
}

///|
/// Encode and decode a snowflake without losing its phantom resource type.
pub fn[M] CustomIdCodec::id() -> CustomIdCodec[@model.Id[M]] {
  CustomIdCodec::custom(encode=value => value.to_string(), decode=text => {
    @model.Id::parse(text) catch {
      source => raise InvalidArgument("\{Repr(source)}")
    }
  })
}

///|
/// Define a reversible encoding. Decode failures should raise `InvalidArgument`.
pub fn[A] CustomIdCodec::custom(
  encode~ : (A) -> String,
  decode~ : (String) -> A raise HandlerError,
) -> CustomIdCodec[A] {
  { encode_: encode, decode_: decode, validate_: _ => (), }
}

///|
/// Join two encodings with `:` and split at the first `:` on decode.
/// Encoding rejects a separator in the non-final segment.
/// Nest additional segments on the right, e.g. `a.zip(b.zip(c))`.
pub fn[A, B] CustomIdCodec::zip(
  self : CustomIdCodec[A],
  other : CustomIdCodec[B],
) -> CustomIdCodec[(A, B)] {
  {
    encode_: value => "\{(self.encode_)(value.0)}:\{(other.encode_)(value.1)}",
    decode_: text => {
      guard text.find(":") is Some(index) else {
        raise InvalidArgument("expected ':' between component state segments")
      }
      (
        self.decode(text[:index].to_owned()),
        other.decode(text[index + 1:].to_owned()),
      )
    },
    validate_: value => {
      (self.validate_)(value.0)
      (other.validate_)(value.1)
      let segment = (self.encode_)(value.0)
      if segment.contains(":") {
        raise SeparatorInSegment(segment~)
      }
    },
  }
}

///|
/// Map a codec to another type using a fallible decode and a reverse mapping.
pub fn[A, B] CustomIdCodec::imap(
  self : CustomIdCodec[A],
  to~ : (A) -> B raise HandlerError,
  from~ : (B) -> A,
) -> CustomIdCodec[B] {
  {
    encode_: value => (self.encode_)(from(value)),
    decode_: text => to(self.decode(text)),
    validate_: value => (self.validate_)(from(value)),
  }
}

///|
/// Encode state, rejecting ambiguous zip segments and text over 100 UTF-16 units.
/// The complete route id is checked separately by `ComponentRoute::custom_id`.
pub fn[A] CustomIdCodec::encode(
  self : CustomIdCodec[A],
  value : A,
) -> String raise CustomIdError {
  (self.validate_)(value)
  let text = (self.encode_)(value)
  check_custom_id_length(text)
  text
}

///|
/// Decode untrusted state. The handler must still authorize the invoking user.
pub fn[A] CustomIdCodec::decode(
  self : CustomIdCodec[A],
  text : String,
) -> A raise HandlerError {
  (self.decode_)(text)
}

///|
/// A component's route identity and the codec shared by producer and handler.
pub struct ComponentRoute[A] {
  priv id_ : String
  priv state_ : CustomIdCodec[A]
}

///|
/// Define a component route matching `id` or `id:state`.
/// The id may itself contain `:`, so ids that are already in use elsewhere
/// (`bot:rolemenu`) keep working. `App::validate` rejects empty ids, ids over
/// 100 units, and an id that extends another typed route's id at a `:`
/// (`ticket` beside `ticket:close`), because the shorter route's state could
/// then produce a custom id the longer route receives.
///
/// ```mbt check
/// test "build a component from its route" {
///   let route = @app.component_route(id="page", state=@app.CustomIdCodec::int())
///   let app = @app.App()
///   app.on_component(
///     route,
///     Immediate((_, page) => @app.ComponentReply::message(content="Page \{page}")),
///   )
///   let _ = @interaction.button(custom_id=route.custom_id(2), label="Next")
///   assert_eq(route.custom_id(2), "page:2")
///   app.validate()
/// }
/// ```
pub fn[A] component_route(
  id~ : String,
  state~ : CustomIdCodec[A],
) -> ComponentRoute[A] {
  { id_: id, state_: state, }
}

///|
/// Build the id or `id:state`, enforcing Discord's 100 UTF-16-unit limit.
/// An empty encoded state produces the bare id.
pub fn[A] ComponentRoute::custom_id(
  self : ComponentRoute[A],
  state : A,
) -> String raise CustomIdError {
  (self.state_.validate_)(state)
  let text = (self.state_.encode_)(state)
  let custom_id = if text.is_empty() { self.id_ } else { "\{self.id_}:\{text}" }
  check_custom_id_length(custom_id)
  custom_id
}

///|
/// Decode the state from a custom id this route produced — the inverse of
/// `custom_id`. Typed handlers receive the state already decoded; use this for
/// ids that reach you undecoded, such as the `ComponentCtx` returned by
/// `wait_for_component`, and in tests. An id that belongs to another route, or
/// whose state does not decode, raises `HandlerError::InvalidArgument`. The
/// decoded state is untrusted input.
///
/// ```mbt check
/// test {
///   let route = @app.component_route(
///     id="ticket-close",
///     state=@app.CustomIdCodec::int(),
///   )
///   assert_eq(route.decode(route.custom_id(42)), 42)
/// }
/// ```
pub fn[A] ComponentRoute::decode(
  self : ComponentRoute[A],
  custom_id : String,
) -> A raise HandlerError {
  let text = if custom_id == self.id_ {
    ""
  } else if custom_id.has_prefix("\{self.id_}:") {
    custom_id[self.id_.length() + 1:].to_owned()
  } else {
    raise InvalidArgument(
      "custom id \{custom_id} does not belong to route \{self.id_}",
    )
  }
  self.state_.decode(text) catch {
    source => raise InvalidArgument("\{Repr(source)}")
  }
}

///|
priv enum AppRoutePattern {
  Prefix(String)
  Id(String)
}

///|
fn AppRoutePattern::base(self : AppRoutePattern) -> String {
  match self {
    Prefix(prefix) | Id(prefix) => prefix
  }
}

///|
fn validate_route(
  pattern : AppRoutePattern,
  seen : Set[String],
  ids : Array[String],
  modal~ : Bool,
) -> Unit raise AppConfigError {
  let custom_id = pattern.base()
  if custom_id.is_empty() {
    if modal {
      raise EmptyModalRoute
    } else {
      raise EmptyComponentRoute
    }
  }
  let effective_prefix = match pattern {
    Prefix(prefix) => prefix
    Id(id) => {
      if id.length() > 100 {
        raise InvalidRouteId(
          custom_id~,
          reason="route ids cannot exceed 100 UTF-16 units",
        )
      }
      "\{id}:"
    }
  }
  if seen.contains(effective_prefix) {
    if modal {
      raise DuplicateModalRoute(custom_id~)
    } else {
      raise DuplicateComponentRoute(custom_id~)
    }
  }
  seen.add(effective_prefix)
  if pattern is Id(id) {
    // Typed routes never shadow each other: `a` and `a:b` would both match
    // `a:b:state`, and state encoded for `a` could reach the handler of `a:b`.
    for other in ids {
      if effective_prefix.has_prefix("\{other}:") ||
        "\{other}:".has_prefix(effective_prefix) {
        raise InvalidRouteId(
          custom_id~,
          reason="overlaps the typed route id '\{other}' at a ':' boundary",
        )
      }
    }
    ids.push(id)
  }
}