///|
/// Execution strategy for a typed application command.
pub(all) enum InteractionHandler[A] {
  Immediate(async (ImmediateCtx, A) -> CommandReply)
  Deferred(ephemeral~ : Bool, async (DeferredCtx, A) -> Unit)
  Raw(async (@framework.CommandCtx) -> Unit)
}

///|
priv struct SlashRoute {
  path : Array[String]
  ack : AckPlan
  dispatch : async (
    AppCtx,
    ComponentWaiter?,
    (AppWarning) -> Unit,
    @framework.CommandCtx,
  ) -> Unit
}

///|
fn[A] InteractionHandler::ack_plan(self : InteractionHandler[A]) -> AckPlan {
  match self {
    Deferred(ephemeral~, _) => DeferMessage(ephemeral~)
    Immediate(_) | Raw(_) => Respond
  }
}

///|
priv struct SuggestRoute {
  path : Array[String]
  option_name : String
  handler : @interaction.SuggestHandler
}

///|
/// A type-erased slash subcommand or subcommand group.
pub struct SlashChild {
  priv definition_ : @model.CommandOption
  priv routes_ : Array[SlashRoute]
  priv node_paths_ : Array[Array[String]]
  priv nested_group_paths_ : Array[Array[String]]
  priv suggest_routes_ : Array[SuggestRoute]
  priv is_group_ : Bool
}

///|
/// An application-command definition with a context-aware decoder.
pub struct Command[A] {
  priv definition : @interaction.CommandSpec
  priv decode : (@framework.CommandCtx) -> A raise
  priv handler : InteractionHandler[A]
  priv routes : Array[SlashRoute]?
  priv node_paths : Array[Array[String]]
  priv nested_group_paths : Array[Array[String]]
  priv suggest_routes : Array[SuggestRoute]
  priv checks : Array[CommandCheck]
  priv cooldown : CooldownConfig?
}

///|
fn[A] suggest_routes(
  path : Array[String],
  args : @interaction.Args[A],
) -> Array[SuggestRoute] {
  args
  .suggestions()
  .map(handler => { path: path.copy(), option_name: handler.name(), handler, })
}

///|
/// Decode slash arguments. A failed `Arg` validation is input the user can
/// correct; a missing or mistyped option means the payload does not match the
/// registered command, which the user cannot fix.
fn[A] decode_command_args(
  args : @interaction.Args[A],
  options : @interaction.CommandOptions,
) -> A raise HandlerError {
  args.decode(options) catch {
    @interaction.ArgsDecodeError::Validation(name~, message~) =>
      raise InvalidInput(name=Some(name), message~)
    source => raise malformed_unless_handler_error(source)
  }
}

///|
/// Define a typed slash command.
///
/// ```mbt check
/// test "inspect a slash-command registration spec" {
///   let command = @app.slash(
///     name="echo",
///     description="Echo text",
///     args=@interaction.Args::of(
///       @interaction.arg_string(name="text", description="Text to echo"),
///     ),
///     handler=Raw(_ => ()),
///   )
///   json_inspect(command.spec().to_json(), content={
///     "name": "echo",
///     "description": "Echo text",
///     "options": [
///       {
///         "type": 3,
///         "name": "text",
///         "description": "Text to echo",
///         "required": true,
///       },
///     ],
///   })
/// }
/// ```
pub fn[A] slash(
  name~ : String,
  description~ : String,
  args~ : @interaction.Args[A],
  handler~ : InteractionHandler[A],
  default_member_permissions? : @model.Permissions,
  nsfw? : Bool,
  integration_types? : Array[@model.ApplicationIntegrationType],
  contexts? : Array[@model.InteractionContextType],
  name_localizations? : Map[String, String],
  description_localizations? : Map[String, String],
) -> Command[A] {
  {
    definition: @interaction.CommandSpec::slash(
      name,
      description,
      options=args.definitions(),
      default_member_permissions?,
      nsfw?,
      integration_types?,
      contexts?,
      name_localizations?,
      description_localizations?,
    ),
    decode: raw => decode_command_args(args, raw.options),
    handler,
    routes: None,
    node_paths: [],
    nested_group_paths: [],
    suggest_routes: suggest_routes([], args),
    checks: [],
    cooldown: None,
  }
}

///|
fn target_key(raw : @framework.CommandCtx) -> String raise HandlerError {
  guard raw.data.target_id is Some(target_id) else {
    raise Malformed(reason="context command is missing target_id")
  }
  target_id.to_string()
}

///|
fn decode_target_user(
  raw : @framework.CommandCtx,
) -> @framework.TargetUser raise HandlerError {
  let key = target_key(raw)
  guard raw.data.resolved is Some(_) else {
    raise Malformed(reason="user target is missing resolved data")
  }
  guard raw.target_user() is Some(target) else {
    raise Malformed(reason="resolved user target \{key} was not found")
  }
  target
}

///|
fn decode_target_message(
  raw : @framework.CommandCtx,
) -> @model.Message raise HandlerError {
  let key = target_key(raw)
  guard raw.data.resolved is Some(_) else {
    raise Malformed(reason="message target is missing resolved data")
  }
  guard raw.target_message() is Some(message) else {
    raise Malformed(reason="resolved message target \{key} was not found")
  }
  message
}

///|
/// Define a USER context-menu command.
///
/// ```mbt check
/// test "inspect context-menu command kinds" {
///   let user = @app.user_command(name="Inspect user", handler=Raw(_ => ()))
///   let message = @app.message_command(
///     name="Inspect message",
///     handler=Raw(_ => ()),
///   )
///   json_inspect(user.spec().to_json(), content={
///     "name": "Inspect user",
///     "description": "",
///     "type": 2,
///   })
///   json_inspect(message.spec().to_json(), content={
///     "name": "Inspect message",
///     "description": "",
///     "type": 3,
///   })
/// }
/// ```
pub fn user_command(
  name~ : String,
  handler~ : InteractionHandler[@framework.TargetUser],
  default_member_permissions? : @model.Permissions,
  nsfw? : Bool,
  integration_types? : Array[@model.ApplicationIntegrationType],
  contexts? : Array[@model.InteractionContextType],
  name_localizations? : Map[String, String],
) -> Command[@framework.TargetUser] {
  {
    definition: @interaction.CommandSpec::user(
      name,
      default_member_permissions?,
      nsfw?,
      integration_types?,
      contexts?,
      name_localizations?,
    ),
    decode: raw => decode_target_user(raw),
    handler,
    routes: None,
    node_paths: [],
    nested_group_paths: [],
    suggest_routes: [],
    checks: [],
    cooldown: None,
  }
}

///|
/// Define a MESSAGE context-menu command.
pub fn message_command(
  name~ : String,
  handler~ : InteractionHandler[@model.Message],
  default_member_permissions? : @model.Permissions,
  nsfw? : Bool,
  integration_types? : Array[@model.ApplicationIntegrationType],
  contexts? : Array[@model.InteractionContextType],
  name_localizations? : Map[String, String],
) -> Command[@model.Message] {
  {
    definition: @interaction.CommandSpec::message(
      name,
      default_member_permissions?,
      nsfw?,
      integration_types?,
      contexts?,
      name_localizations?,
    ),
    decode: raw => decode_target_message(raw),
    handler,
    routes: None,
    node_paths: [],
    nested_group_paths: [],
    suggest_routes: [],
    checks: [],
    cooldown: None,
  }
}

///|
fn prefixed_path(prefix : String, path : Array[String]) -> Array[String] {
  let result = [prefix]
  for part in path {
    result.push(part)
  }
  result
}

///|
fn path_key(path : Array[String]) -> String {
  path.join("\u{1f}")
}

///|
/// Define a typed slash subcommand.
pub fn[A] subcommand(
  name~ : String,
  description~ : String,
  args~ : @interaction.Args[A],
  handler~ : InteractionHandler[A],
  name_localizations? : Map[String, String],
  description_localizations? : Map[String, String],
) -> SlashChild {
  let decode = (raw : @framework.CommandCtx) => {
    decode_command_args(args, raw.options)
  }
  {
    definition_: @interaction.sub_command(
      name,
      description,
      options=args.definitions(),
      name_localizations?,
      description_localizations?,
    ),
    routes_: [
      {
        path: [name],
        ack: handler.ack_plan(),
        dispatch: (bot, waiter, warn, raw) => {
          dispatch_command_handler(
            raw => decode(raw),
            handler,
            bot,
            waiter,
            warn,
            raw,
          )
        },
      },
    ],
    node_paths_: [[name]],
    nested_group_paths_: [],
    suggest_routes_: suggest_routes([name], args),
    is_group_: false,
  }
}

///|
/// Define a slash subcommand group. Nested groups are retained for validation
/// and rejected by `AppBuilder::build`, never by panic or abort here.
pub fn subcommand_group(
  name~ : String,
  description~ : String,
  children~ : Array[SlashChild],
  name_localizations? : Map[String, String],
  description_localizations? : Map[String, String],
) -> SlashChild {
  let definitions = children.map(child => child.definition_)
  let routes = []
  let node_paths = [[name]]
  let nested_group_paths = []
  let suggest_routes = []
  for child in children {
    for route in child.routes_ {
      routes.push({ ..route, path: prefixed_path(name, route.path), })
    }
    for path in child.node_paths_ {
      node_paths.push(prefixed_path(name, path))
    }
    if child.is_group_ {
      nested_group_paths.push([name, child.definition_.name])
    }
    for path in child.nested_group_paths_ {
      nested_group_paths.push(prefixed_path(name, path))
    }
    for route in child.suggest_routes_ {
      suggest_routes.push({ ..route, path: prefixed_path(name, route.path), })
    }
  }
  {
    definition_: @interaction.sub_command_group(
      name,
      description,
      definitions,
      name_localizations?,
      description_localizations?,
    ),
    routes_: routes,
    node_paths_: node_paths,
    nested_group_paths_: nested_group_paths,
    suggest_routes_: suggest_routes,
    is_group_: true,
  }
}

///|
/// Define a slash command whose children dispatch by submitted option path.
pub fn slash_group(
  name~ : String,
  description~ : String,
  children~ : Array[SlashChild],
  default_member_permissions? : @model.Permissions,
  nsfw? : Bool,
  integration_types? : Array[@model.ApplicationIntegrationType],
  contexts? : Array[@model.InteractionContextType],
  name_localizations? : Map[String, String],
  description_localizations? : Map[String, String],
) -> Command[Unit] {
  let routes = []
  let node_paths = []
  let nested_group_paths = []
  let suggest_routes = []
  for child in children {
    routes.append(child.routes_)
    node_paths.append(child.node_paths_)
    nested_group_paths.append(child.nested_group_paths_)
    suggest_routes.append(child.suggest_routes_)
  }
  {
    definition: @interaction.CommandSpec::slash(
      name,
      description,
      options=children.map(child => child.definition_),
      default_member_permissions?,
      nsfw?,
      integration_types?,
      contexts?,
      name_localizations?,
      description_localizations?,
    ),
    decode: _ => (),
    handler: Raw(_ => ()),
    routes: Some(routes),
    node_paths,
    nested_group_paths,
    suggest_routes,
    checks: [],
    cooldown: None,
  }
}

///|
/// Add a pre-execution check. Checks run in registration order after middleware
/// and before cooldown, argument decoding, and handler dispatch, inside
/// Discord's three-second initial-response window. A `Deferred` command is
/// acknowledged automatically when 1000 ms of that budget remain; immediate
/// and raw commands are not, so keep their checks fast or cache the results.
/// Returning false is equivalent to `HandlerError::CheckFailed`. Returns a new
/// command; `self` keeps its own checks.
pub fn[A] Command::check(self : Command[A], check : CommandCheck) -> Command[A] {
  { ..self, checks: [..self.checks, check], }
}

///|
/// Apply a fixed-window cooldown to this command. The window starts when all
/// checks pass, before argument decoding and handler execution. Returns a new
/// command; `self` keeps its own cooldown.
pub fn[A] Command::cooldown(
  self : Command[A],
  seconds~ : Int32,
  bucket~ : CooldownBucket = User,
) -> Command[A] {
  { ..self, cooldown: Some({ seconds, bucket, }), }
}

///|
/// The command registration specification.
pub fn[A] Command::spec(self : Command[A]) -> @interaction.CommandSpec {
  self.definition
}

///|
async fn[A] dispatch_command_handler(
  decode : (@framework.CommandCtx) -> A raise,
  handler : InteractionHandler[A],
  bot : AppCtx,
  waiter : ComponentWaiter?,
  warn : (AppWarning) -> Unit,
  raw : @framework.CommandCtx,
) -> Unit {
  let value = decode(raw)
  match handler {
    Immediate(run) => {
      let response = run({ raw_: raw, bot_: bot, }, value)
      response.send(raw)
    }
    Deferred(ephemeral~, run) => {
      ensure_acknowledged(command_admission(raw), DeferMessage(ephemeral~))
      run({ raw_: raw, bot_: bot, waiter_: waiter, warn_: warn, }, value)
    }
    Raw(run) => run(raw)
  }
}

///|
fn find_slash_route(
  routes : Array[SlashRoute],
  path : Array[String],
) -> SlashRoute? {
  let key = path_key(path)
  for route in routes {
    if path_key(route.path) == key {
      return Some(route)
    }
  }
  None
}

///|
/// The acknowledgement plan of the route an invocation selects.
///
/// An unknown path plans `Respond` on purpose: the plan is taken before
/// admission, so nothing auto-defers, and the `Malformed` error that
/// `dispatch_typed` raises later reaches the error policy while the callback
/// is still pending, which lets it answer with an "out of date" initial reply.
fn[A] Command::ack_plan(
  self : Command[A],
  raw : @framework.CommandCtx,
) -> AckPlan {
  match self.routes {
    None => self.handler.ack_plan()
    Some(routes) =>
      match find_slash_route(routes, raw.options.path()) {
        Some(route) => route.ack
        None => Respond
      }
  }
}

///|
async fn[A] Command::dispatch_typed(
  self : Command[A],
  bot : AppCtx,
  waiter : ComponentWaiter?,
  warn : (AppWarning) -> Unit,
  raw : @framework.CommandCtx,
) -> Unit {
  match self.routes {
    None =>
      dispatch_command_handler(
        self.decode,
        self.handler,
        bot,
        waiter,
        warn,
        raw,
      )
    Some(routes) => {
      let path = raw.options.path()
      guard find_slash_route(routes, path) is Some(route) else {
        raise HandlerError::Malformed(
          reason="unknown subcommand path: \{if path.is_empty() { "" } else { path.join("/") }}",
        )
      }
      (route.dispatch)(bot, waiter, warn, raw)
    }
  }
}

///|
fn find_suggest_route(
  routes : Array[SuggestRoute],
  path : Array[String],
  option : String,
) -> SuggestRoute? {
  let key = path_key(path)
  for route in routes {
    if path_key(route.path) == key && route.option_name == option {
      return Some(route)
    }
  }
  None
}

///|
async fn dispatch_suggest(
  routes : Array[SuggestRoute],
  warn : (AppWarning) -> Unit,
  raw : @framework.AutocompleteCtx,
) -> Unit {
  let path = raw.options.path()
  guard raw.options.focused_input() is Some(input) else {
    raise HandlerError::Malformed(
      reason="autocomplete payload has no focused option",
    )
  }
  guard find_suggest_route(routes, path, input.name()) is Some(route) else {
    let display_path = if path.is_empty() { "" } else { path.join("/") }
    raise HandlerError::Malformed(
      reason="no autocomplete route for \{display_path}:\{input.name()}",
    )
  }
  let ctx = @interaction.SuggestCtx::{
    interaction: raw.interaction,
    options: raw.options,
    guild_id: raw.interaction.guild_id,
    locale: raw.interaction.locale,
  }
  let choices = route.handler.suggest(ctx, input)
  // `suggest` sends the first 25; say so when it drops the rest.
  if choices.length() > 25 {
    let command_path = [raw.data.name, ..path].join("/")
    warn(
      AutocompleteTruncated(
        command_path~,
        option=input.name(),
        count=choices.length(),
      ),
    )
  }
  raw.suggest(choices)
}

///|
/// A type-erased command consumed by an App executor.
pub struct RegisteredCommand {
  priv name_ : String
  priv definition : @interaction.CommandSpec
  priv node_paths_ : Array[Array[String]]
  priv nested_group_paths_ : Array[Array[String]]
  priv suggest_routes_ : Array[SuggestRoute]
  priv checks_ : Array[CommandCheck]
  priv cooldown_ : CooldownConfig?
  priv ack_ : (@framework.CommandCtx) -> AckPlan
  priv dispatch : async (
    AppCtx,
    ComponentWaiter?,
    (AppWarning) -> Unit,
    @framework.CommandCtx,
  ) -> Unit
}

///|
/// The top-level command name used for routing.
pub fn RegisteredCommand::name(self : RegisteredCommand) -> String {
  self.name_
}

///|
/// Erase the decoded argument type while retaining registration and dispatch.
pub fn[A] Command::erase(self : Command[A]) -> RegisteredCommand {
  {
    name_: self.definition.name,
    definition: self.definition,
    node_paths_: self.node_paths,
    nested_group_paths_: self.nested_group_paths,
    suggest_routes_: self.suggest_routes,
    checks_: self.checks,
    cooldown_: self.cooldown,
    ack_: raw => self.ack_plan(raw),
    dispatch: (bot, waiter, warn, raw) => {
      self.dispatch_typed(bot, waiter, warn, raw)
    },
  }
}

///|
/// A copy that shares no mutable data with the command it came from: the
/// registration spec's arrays and maps are public and may still be held by
/// the caller, so a built `App` keeps its own.
fn RegisteredCommand::snapshot(self : RegisteredCommand) -> RegisteredCommand {
  {
    ..self,
    definition: copy_spec(self.definition),
    checks_: self.checks_.copy(),
  }
}

///|
fn copy_spec(spec : @interaction.CommandSpec) -> @interaction.CommandSpec {
  {
    ..spec,
    name_localizations: spec.name_localizations.map(Map::copy),
    description_localizations: spec.description_localizations.map(Map::copy),
    options: spec.options.map(copy_option),
    integration_types: spec.integration_types.map(Array::copy),
    contexts: spec.contexts.map(Array::copy),
  }
}

///|
fn copy_option(option : @model.CommandOption) -> @model.CommandOption {
  {
    ..option,
    name_localizations: option.name_localizations.map(copy_nullable_map),
    description_localizations: option.description_localizations.map(
      copy_nullable_map,
    ),
    choices: option.choices.map(choices => {
      choices.map(choice => {
        ..choice,
        name_localizations: choice.name_localizations.map(copy_nullable_map),
      })
    }),
    options: option.options.map(options => options.map(copy_option)),
    channel_types: option.channel_types.map(Array::copy),
    file_types: option.file_types.map(Array::copy),
  }
}

///|
fn copy_nullable_map(
  map : @model.Nullable[Map[String, String]],
) -> @model.Nullable[Map[String, String]] {
  match map {
    Null => Null
    Value(map) => Value(map.copy())
  }
}

///|
/// The `(type, name)` identity Discord uses for a command, as one string.
fn RegisteredCommand::key(self : RegisteredCommand) -> String {
  "\{self.definition.typ.to_int()}:\{self.name_}"
}