///|
/// The mcpbox command line: argument parsing, command dispatch, and the
/// human/JSON rendering of results.

///|
/// Options shared by the commands.
priv struct Options {
  mut json : Bool
  mut brief : Bool
  mut raw : Bool
  mut no_docs : Bool
  mut all_servers : Bool
  mut background : Bool
  mut quiet : Bool
  mut help : Bool
  mut timeout_ms : Int32
  mut http_url : String?
  mut stdio : String?
  positional : Array[String]
}

///|
/// A server plus the outcome of talking to it. `list` reports one of these per
/// server, whether the connection worked or not.
priv struct ServerReport {
  server : Server
  mut info : ServerInfo?
  mut tools : Array[Tool]
  mut error : String?
}

///|
/// Write one line to standard output. A closed pipe (`mcpbox list | head`) is
/// not an error worth reporting.
async fn print_line(text : String) -> Unit {
  @stdio.stdout.write(text + "\n") catch {
    _ => ()
  }
}

///|
/// The argument list without the program name.
fn tail(args : Array[String]) -> Array[String] {
  let out : Array[String] = []
  for i in 1.. Int32 raise McpError {
  let seconds = @string.parse_double(text.trim()) catch {
    _ => raise fail("'\{text}' is not a number of seconds")
  }
  if seconds.is_nan() || seconds.is_inf() || seconds <= 0.0 {
    raise fail("the timeout must be a positive, finite number of seconds")
  }
  let millis = seconds * 1000.0
  if millis < 1.0 {
    raise fail("the timeout must be at least 0.001 seconds")
  }
  if millis > 2147483647.0 {
    raise fail("the timeout is too large")
  }
  millis.to_int()
}

///|
/// Split a command string the way a shell would, honouring single and double
/// quotes. Used by `--stdio "npx -y server"`.
fn shell_split(command : String) -> Array[String] {
  let parts : Array[String] = []
  let current = StringBuilder()
  let chars = command.to_array()
  let mut i = 0
  let mut in_quote = false
  let mut quote_char : Char = '"'
  let mut has_content = false
  while i < chars.length() {
    let c = chars[i]
    if in_quote {
      if c == quote_char {
        in_quote = false
      } else {
        current.write_char(c)
      }
      has_content = true
    } else if c == '"' || c == '\'' {
      in_quote = true
      quote_char = c
      has_content = true
    } else if c == ' ' || c == '\t' || c == '\n' {
      if has_content {
        parts.push(current.to_string())
        current.reset()
        has_content = false
      }
    } else {
      current.write_char(c)
      has_content = true
    }
    i += 1
  }
  if has_content {
    parts.push(current.to_string())
  }
  parts
}

///|
/// Parse the flags every command understands. Unknown `--flags` are an error
/// rather than being mistaken for tool arguments.
fn parse_options(args : Array[String]) -> Options raise McpError {
  let opts : Options = {
    json: false,
    brief: false,
    raw: false,
    no_docs: false,
    all_servers: false,
    background: false,
    quiet: false,
    help: false,
    timeout_ms: 30000,
    http_url: None,
    stdio: None,
    positional: [],
  }
  let mut i = 0
  let mut only_positional = false
  while i < args.length() {
    let arg = args[i]
    if only_positional {
      opts.positional.push(arg)
      i += 1
    } else if arg == "--" {
      only_positional = true
      i += 1
    } else if arg == "--json" {
      opts.json = true
      i += 1
    } else if arg == "--brief" {
      opts.brief = true
      i += 1
    } else if arg == "--raw" {
      opts.raw = true
      i += 1
    } else if arg == "--no-docs" {
      opts.no_docs = true
      i += 1
    } else if arg == "--all" {
      opts.all_servers = true
      i += 1
    } else if arg == "-b" || arg == "--background" {
      opts.background = true
      i += 1
    } else if arg == "--quiet" || arg == "-q" {
      opts.quiet = true
      i += 1
    } else if arg == "--timeout" {
      if i + 1 >= args.length() {
        raise fail("--timeout needs a value in seconds")
      }
      opts.timeout_ms = parse_seconds(args[i + 1])
      i += 2
    } else if arg.has_prefix("--timeout=") {
      opts.timeout_ms = parse_seconds(arg[10:].to_owned())
      i += 1
    } else if arg == "--http-url" {
      if i + 1 >= args.length() {
        raise fail("--http-url needs a URL")
      }
      opts.http_url = Some(args[i + 1])
      i += 2
    } else if arg.has_prefix("--http-url=") {
      opts.http_url = Some(arg[11:].to_owned())
      i += 1
    } else if arg == "--stdio" {
      if i + 1 >= args.length() {
        raise fail("--stdio needs a command")
      }
      opts.stdio = Some(args[i + 1])
      i += 2
    } else if arg.has_prefix("--stdio=") {
      opts.stdio = Some(arg[8:].to_owned())
      i += 1
    } else if arg == "--help" || arg == "-h" {
      opts.help = true
      i += 1
    } else if arg.has_prefix("--") {
      raise fail("unknown option '\{arg}'")
    } else {
      opts.positional.push(arg)
      i += 1
    }
  }
  opts
}

///|
/// The ad-hoc server described by `--http-url` or `--stdio`, if any.
fn adhoc_server(opts : Options) -> Server? raise McpError {
  match opts.http_url {
    Some(url) => {
      let can_connect = url.has_prefix("http://") || url.has_prefix("https://")
      if !can_connect {
        raise fail("'\{url}' should start with http:// or https://")
      }
      return Some({
        name: "adhoc",
        kind: Http({ url, headers: Map([]), }),
        note: "ad-hoc --http-url",
      })
    }
    None => ()
  }
  match opts.stdio {
    Some(command) => {
      let parts = shell_split(command)
      if parts.length() == 0 {
        raise fail("--stdio needs a command")
      }
      let argv : Array[String] = []
      for i in 1.. None
  }
}

///|
/// The servers a command should talk to: the ad-hoc one if the flags asked for
/// it, otherwise everything discovered from config.
async fn resolve_servers(opts : Options) -> Array[Server] raise Error {
  match adhoc_server(opts) {
    Some(server) => [server]
    None => load_servers()
  }
}

///|
/// Find a server by name.
fn find_server(servers : Array[Server], name : String) -> Server? {
  for server in servers {
    if server.name == name {
      return Some(server)
    }
  }
  None
}

///|
/// A `, `-separated list of server names for an error message.
fn available_names(servers : Array[Server]) -> String {
  if servers.length() == 0 {
    return " (no servers are configured)"
  }
  let names : Array[String] = []
  for server in servers {
    names.push(server.name)
  }
  " (available: " + names.join(", ") + ")"
}

///|
/// A `, `-separated list of tool names for an error message.
fn available_tools(tools : Array[Tool]) -> String {
  if tools.length() == 0 {
    return " (it advertises no tools)"
  }
  let names : Array[String] = []
  for tool in tools {
    names.push(tool.name)
  }
  " (available: " + names.join(", ") + ")"
}

///|
/// Split a `server.tool` target. A bare name is allowed when an ad-hoc server
/// is in play, since there is then only one server to mean.
fn split_target(
  target : String,
  has_adhoc : Bool,
) -> (String, String) raise McpError {
  match target.find(".") {
    Some(index) =>
      if index == 0 || index + 1 >= target.length() {
        raise fail("'" + target + "' must be .")
      } else {
        (
          target.exact_view(end=index).to_owned(),
          target.exact_view(start=index + 1).to_owned(),
        )
      }
    None =>
      if has_adhoc {
        ("adhoc", target)
      } else {
        raise fail("'" + target + "' must be .")
      }
  }
}

///|
/// Find a tool by name.
fn find_tool(tools : Array[Tool], name : String) -> Tool? {
  for tool in tools {
    if tool.name == name {
      return Some(tool)
    }
  }
  None
}

///|
/// Connect to a server and collect its tools, converting any failure into a
/// report rather than an exception so one broken server does not hide the rest.
async fn fetch_server(
  server : Server,
  opts : Options,
  daemon : DaemonLocator?,
) -> ServerReport {
  let report : ServerReport = { server, info: None, tools: [], error: None, }
  server_progress(opts, "connecting to '\{server.name}'…")
  try {
    match daemon {
      Some(locator) => {
        let result = daemon_request(
          locator,
          daemon_work("list", server, opts),
          opts.timeout_ms,
        )
        report.info = Some(parse_server_info(result))
        report.tools = parse_tools(result)
      }
      None =>
        with_server(server, opts.timeout_ms, session => {
          report.info = Some(session.server_info())
          report.tools = session.list_tools(opts.timeout_ms)
        })
    }
  } catch {
    error => report.error = Some(error_text(error))
  }
  report
}

///|
/// `mcpbox list [server ...] [--all]`
///
/// Without `--all`, no server is started: the default path only prints the
/// configured names so discovery stays instant. `--all` opts into connecting
/// to every server and showing its tools.
async fn cmd_list(args : Array[String]) -> Unit raise Error {
  let opts = parse_options(args)
  if opts.help {
    print_line(help_text())
    return
  }
  if opts.all_servers && opts.positional.length() > 0 {
    raise fail("--all lists every server; do not also name servers")
  }
  let has_adhoc = adhoc_server(opts) is Some(_)
  let servers = resolve_servers(opts)
  if !opts.all_servers && !has_adhoc && opts.positional.length() == 0 {
    if servers.length() == 0 {
      if opts.json {
        print_line(Json::object({ "servers": Json::array([]) }).stringify())
      } else {
        print_line("no MCP servers configured")
      }
      return
    }
    if opts.json {
      print_line(render_servers_json(servers))
    } else {
      print_line(render_server_names(servers))
    }
    return
  }
  let selected : Array[Server] = []
  if opts.all_servers || opts.positional.length() == 0 {
    for server in servers {
      selected.push(server)
    }
  } else {
    for name in opts.positional {
      match find_server(servers, name) {
        Some(server) => selected.push(server)
        None =>
          raise fail(
            "no server named '" + name + "'" + available_names(servers),
          )
      }
    }
  }
  if selected.length() == 0 {
    if opts.json {
      print_line(Json::object({ "servers": Json::array([]) }).stringify())
    } else {
      print_line("no MCP servers configured")
    }
    return
  }
  let daemon = daemon_for_command(opts)
  let tasks : Array[async () -> ServerReport] = []
  for server in selected {
    tasks.push(() => fetch_server(server, opts, daemon))
  }
  let reports = if interactive_progress(opts) {
    let label = "starting " +
      selected.length().to_string() +
      " MCP server(s)…"
    let reports = @async.with_task_group(group => {
      group.spawn_bg(no_wait=true, () => spinner_loop(label))
      @async.all(tasks)
    })
    clear_progress_line()
    reports
  } else {
    @async.all(tasks)
  }
  if opts.json {
    print_line(render_reports_json(reports))
  } else {
    print_line(render_reports_text(reports, opts))
  }
}

///|
/// `mcpbox call . [key=value ...]`
async fn cmd_call(args : Array[String]) -> Unit raise Error {
  let opts = parse_options(args)
  if opts.help {
    print_line(help_text())
    return
  }
  if opts.positional.length() == 0 {
    raise fail("usage: mcpbox call . [key=value ...]")
  }
  let servers = resolve_servers(opts)
  let has_adhoc = adhoc_server(opts) is Some(_)
  let (server_name, tool_name) = split_target(opts.positional[0], has_adhoc)
  let server = match find_server(servers, server_name) {
    Some(server) => server
    None =>
      raise fail(
        "no server named '" + server_name + "'" + available_names(servers),
      )
  }
  let pairs : Array[String] = []
  for i in 1.. {
        group.spawn_bg(no_wait=true, () => spinner_loop(label))
        perform_call(server, server_name, tool_name, pairs, opts, daemon)
      })
      Ok(())
    } catch {
      error => Err(error)
    }
    clear_progress_line()
    match outcome {
      Ok(_) => ()
      Err(error) => raise error
    }
  } else {
    startup_progress(
      opts,
      "connecting to '\{server.name}' and calling '\{tool_name}'…",
    )
    perform_call(server, server_name, tool_name, pairs, opts, daemon)
  }
}

///|
/// Connect (or reuse the daemon) and run one tool call. Split out so `call`
/// can wrap it in the same spinner as `list`.
async fn perform_call(
  server : Server,
  server_name : String,
  tool_name : String,
  pairs : Array[String],
  opts : Options,
  daemon : DaemonLocator?,
) -> Unit raise Error {
  match daemon {
    Some(locator) => {
      let request = daemon_work("call", server, opts)
      guard request is Object(fields) else {
        raise fail("invalid daemon request")
      }
      fields["tool"] = Json::string(tool_name)
      fields["pairs"] = Json::array(pairs.map(pair => Json::string(pair)))
      let result = parse_call_result(
        daemon_request(locator, request, opts.timeout_ms),
      )
      print_line(render_call_output(result, opts))
      if result.is_error {
        raise fail("tool '\{tool_name}' reported an error")
      }
    }
    None =>
      with_server(server, opts.timeout_ms, session => {
        let tools = session.list_tools(opts.timeout_ms)
        let tool = match find_tool(tools, tool_name) {
          Some(tool) => tool
          None =>
            raise fail(
              "'" +
              server_name +
              "' has no tool named '" +
              tool_name +
              "'" +
              available_tools(tools),
            )
        }
        let arguments = build_arguments(pairs, tool.input_schema)
        let result = session.call_tool(tool_name, arguments, opts.timeout_ms)
        print_line(render_call_output(result, opts))
        if result.is_error {
          raise fail("tool '\{tool_name}' reported an error")
        }
      })
  }
}

///|
/// `mcpbox config [--json]`
async fn cmd_config(args : Array[String]) -> Unit raise Error {
  let opts = parse_options(args)
  if opts.help {
    print_line(help_text())
    return
  }
  let files = config_files()
  let all_servers = resolve_servers(opts)
  let servers : Array[Server] = []
  for server in all_servers {
    if server_matches_filters(server.name, opts.positional) {
      servers.push(server)
    }
  }
  if opts.json {
    let file_json : Array[Json] = []
    for file in files {
      file_json.push(
        Json::object({
          "path": Json::string(file.0),
          "servers": Json::number(file.1.to_double(), repr=file.1.to_string()),
        }),
      )
    }
    let server_json : Array[Json] = []
    for server in servers {
      server_json.push(server_to_json(server))
    }
    print_line(
      Json::object({
        "files": Json::array(file_json),
        "servers": Json::array(server_json),
      }).stringify(indent=2),
    )
    return
  }
  print_line("config files:")
  if files.length() == 0 {
    print_line("  (none found)")
  }
  for file in files {
    print_line("  \{file.0}  (\{file.1} servers)")
  }
  print_line("")
  print_line("servers:")
  if servers.length() == 0 {
    if all_servers.length() == 0 {
      print_line("  (none)")
    } else {
      print_line("  (no servers match the filter)")
    }
  }
  for server in servers {
    print_line(
      "  \{server.name}  \{transport_label(server)}  \{transport_target(server)}",
    )
  }
}

///|
/// The dispatch table.
pub async fn run(args : Array[String]) -> Unit raise Error {
  let argv = tail(args)
  if argv.length() == 0 {
    print_line(help_text())
    return
  }
  let command = argv[0]
  let rest = tail(argv)
  match command {
    "version" | "--version" | "-v" => print_line("mcpbox \{version}")
    "help" | "--help" | "-h" => print_line(help_text())
    "list" | "ls" | "tools" => cmd_list(rest)
    "call" => cmd_call(rest)
    "config" => cmd_config(rest)
    "stop" => cmd_stop(rest)
    "__daemon" => daemon_main()
    _ => raise fail("unknown command '\{command}' (try 'mcpbox help')")
  }
}

///|
/// The `mcpbox help` text.
pub fn help_text() -> String {
  let out = StringBuilder()
  out.write_string(
    "mcpbox \{version} — carry MCP tools into scripts and agents\n",
  )
  out.write_string("\n")
  out.write_string("Usage: mcpbox  [options]\n")
  out.write_string("\n")
  out.write_string("Commands:\n")
  out.write_string(
    "  list [server ...]              list configured server names; --all shows tools\n",
  )
  out.write_string(
    "  call . [args]    call a tool; args are key=value pairs\n",
  )
  out.write_string(
    "  config [filter ...]            show config files and resolved servers\n",
  )
  out.write_string(
    "  stop               stop kept-alive servers (all also exits daemon)\n",
  )
  out.write_string("  version                        print the version\n")
  out.write_string("  help                           show this help\n")
  out.write_string("\n")
  out.write_string("Options:\n")
  out.write_string("  --json                 machine-readable output\n")
  out.write_string("  --brief                names only, for list\n")
  out.write_string(
    "  --no-docs              signatures without tool documentation\n",
  )
  out.write_string(
    "  --all                  with list, start every server and show its tools\n",
  )
  out.write_string(
    "  -b, --background       start daemon and keep server sessions alive\n",
  )
  out.write_string("  -q, --quiet            hide startup messages on stderr\n")
  out.write_string("  --raw                  text content only, for call\n")
  out.write_string(
    "  --timeout     per-request timeout (default 30)\n",
  )
  out.write_string(
    "  --http-url        call an ad-hoc Streamable HTTP server\n",
  )
  out.write_string(
    "  --stdio       call an ad-hoc stdio server, e.g. \"npx -y x\"\n",
  )
  out.write_string("  -h, --help             show this help\n")
  out.write_string("  -v, --version          print the version\n")
  out.write_string("\n")
  out.write_string("Call arguments:\n")
  out.write_string(
    "  key=value              value is coerced using the tool's schema\n",
  )
  out.write_string("  key:=            value is taken as raw JSON\n")
  out.write_string(
    "  --                     stop option parsing; later words are arguments\n",
  )
  out.write_string("\n")
  out.write_string("Config:\n")
  out.write_string(
    "  mcpbox.json, .mcpbox.json, config/mcpbox.json, .mcpbox/config.json,\n",
  )
  out.write_string(
    "  ~/.mcpbox.json, ~/.config/mcpbox/config.json, plus servers imported from\n",
  )
  out.write_string(
    "  Cursor, Claude Code/Desktop, Windsurf and VS Code. Set MCPBOX_CONFIG to\n",
  )
  out.write_string("  override.\n")
  out.write_string(
    "  config filters match name substrings; * and ? are wildcards.\n",
  )
  out.to_string()
}