// The `slack-mcp` binary.
//
// Two switches, both explicit, and deliberately orthogonal:
//
//   --backend mock|real   where the Slack calls go
//   --data    demo|…      what the mock workspace contains
//
// Neither is inferred from the environment. A server that silently talks to a real workspace
// because a variable happened to be set is a server that will one day post to a real channel
// during a demo, so reaching real Slack takes saying so.
//
// Everything defaults to the offline path: no flags at all gives a working MCP server over
// stdio, backed by an in-process workspace with users, channels, a thread, a DM and a file,
// and no account, token or network anywhere.

///|
/// Which Slack the calls reach.
enum WhichBackend {
  MockWorkspace
  RealSlack
} derive(Eq)

///|
/// What the mock workspace is filled with.
///
/// `Demo` is synthetic, offline and byte-stable — the default, and the only one CI uses.
/// `Live` fetches real Feeling-of-Computing history from the atproto bridge at startup.
/// `Snapshot` replays a recorded fetch, which is how a live load becomes reproducible.
enum WhichData {
  Demo
  Live
  Snapshot(String)
} derive(Eq)

///|
struct Options {
  backend : WhichBackend
  data : WhichData
  token : String?
  base_url : String?
  /// Serve streamable HTTP on this address instead of stdio.
  http_addr : String?
  allowed_origins : Array[String]
  /// How far back `--data live` reaches.
  days : Int
  /// Write the fetched records here, so a live load can be replayed offline.
  record_to : String?
}

///|
let usage : String =
  #| slack-mcp — a Slack MCP server (2026-07-28)
  #|
  #|   slack-mcp [options]
  #|
  #| Backend:
  #|   --backend mock|real   which Slack to talk to (default: mock)
  #|   --token TOKEN         Slack token; required for --backend real
  #|   --base-url URL        override https://slack.com/api/ (a proxy or recorder)
  #|
  #| Data (mock backend only):
  #|   --data demo           the built-in demo workspace (default; offline, deterministic)
  #|   --data live           fetch real Feeling-of-Computing history from the atproto bridge
  #|   --data snapshot FILE  replay a recorded fetch (offline, deterministic)
  #|   --days N              how far back --data live reaches (default: 90)
  #|   --record FILE         save what --data live fetched, for replay
  #|
  #| Transport:
  #|   --http ADDR           serve streamable HTTP on ADDR instead of stdio
  #|   --allow-origin ORIGIN permit this Origin; repeatable
  #|
  #|   -h, --help            this message
  #|
  #| With no options it serves stdio against an in-process mock workspace: no Slack
  #| account, no token and no network are involved.

///|
/// Parse argv. Returns None when the caller asked for help or got something wrong; the
/// message has already been printed in that case.
fn parse_args(argv : Array[String]) -> Options? {
  let mut backend = MockWorkspace
  let mut data = Demo
  let mut days = 90
  let mut record_to : String? = None
  let mut token : String? = None
  let mut base_url : String? = None
  let mut http_addr : String? = None
  let allowed_origins : Array[String] = []
  let mut i = 0
  while i < argv.length() {
    let arg = argv[i]
    // A flag needing a value that does not have one is an error rather than a default:
    // silently serving the mock when someone asked for real Slack is the wrong failure.
    let value = fn() -> String? {
      if i + 1 < argv.length() {
        i = i + 1
        Some(argv[i])
      } else {
        println("slack-mcp: \{arg} needs a value")
        None
      }
    }
    match arg {
      "-h" | "--help" => {
        println(usage)
        return None
      }
      "--backend" =>
        match value() {
          Some("mock") => backend = MockWorkspace
          Some("real") => backend = RealSlack
          Some(other) => {
            println("slack-mcp: --backend must be mock or real, got '\{other}'")
            return None
          }
          None => return None
        }
      "--data" =>
        match value() {
          Some("demo") => data = Demo
          Some("live") => data = Live
          Some("snapshot") =>
            match value() {
              Some(path) => data = Snapshot(path)
              None => return None
            }
          Some(other) => {
            println(
              "slack-mcp: --data must be demo, live or snapshot FILE, got '\{other}'",
            )
            return None
          }
          None => return None
        }
      "--days" =>
        match value() {
          Some(v) => {
            let n = @string.parse_int(v) catch { _ => -1 }
            if n <= 0 {
              println("slack-mcp: --days needs a positive number, got '\{v}'")
              return None
            }
            days = n
          }
          None => return None
        }
      "--record" =>
        match value() {
          Some(v) => record_to = Some(v)
          None => return None
        }
      "--token" =>
        match value() {
          Some(v) => token = Some(v)
          None => return None
        }
      "--base-url" =>
        match value() {
          Some(v) => base_url = Some(v)
          None => return None
        }
      "--http" =>
        match value() {
          Some(v) => http_addr = Some(v)
          None => return None
        }
      "--allow-origin" =>
        match value() {
          Some(v) => allowed_origins.push(v)
          None => return None
        }
      other => {
        println("slack-mcp: unknown option '\{other}'\n")
        println(usage)
        return None
      }
    }
    i = i + 1
  }
  Some({
    backend,
    data,
    token,
    base_url,
    http_addr,
    allowed_origins,
    days,
    record_to,
  })
}

///|
/// Build the Slack side.
///
/// The whole mock/real switch is the `match` below: both arms produce a `&@api.Transport`,
/// and everything above them is identical. That is the property the slack package's own
/// `ext/scenario` relies on, reused here.
async fn build_backend(opts : Options) -> @tools.Backend? {
  match opts.backend {
    MockWorkspace => {
      let (ws, token, description) = match opts.data {
        Demo =>
          (
            @mock.Workspace::demo(),
            @mock.demo_token,
            "an in-process mock workspace (built-in demo data)",
          )
        Live =>
          match load_live(opts) {
            Some(triple) => triple
            // A showcase that will not start without a network is not much of a showcase,
            // so a failed fetch degrades to the demo workspace rather than exiting. It says
            // so on stderr, and in the instructions, so nobody mistakes one for the other.
            None =>
              (
                @mock.Workspace::demo(),
                @mock.demo_token,
                "an in-process mock workspace (built-in demo data — the live fetch failed)",
              )
          }
        Snapshot(path) =>
          match load_snapshot(path) {
            Some(triple) => triple
            None => return None
          }
      }
      let api = @typed.Api::new(ws.transport(), token)
      Some(@tools.Backend::new(api, description, is_mock=true))
    }
    RealSlack => {
      guard opts.token is Some(token) else {
        println(
          "slack-mcp: --backend real needs --token. Nothing is read from the environment, on purpose.",
        )
        return None
      }
      let where_ = opts.base_url.unwrap_or("https://slack.com/api/")
      let api = @typed.Api::new(
        @slack_http.HttpTransport::new(description=where_),
        token,
        base_url?=opts.base_url,
      )
      Some(@tools.Backend::new(api, where_, is_mock=false))
    }
  }
}

///|
/// Fetch the last `days` of Feeling-of-Computing history and seed a workspace with it.
///
/// Three collections across two PDSes, whose hosts are resolved rather than hardcoded --
/// bsky.network reshards accounts, and the two host names the at-foc reader has baked in are
/// true today and need not stay true.
async fn load_live(opts : Options) -> (@mock.Workspace, String, String)? {
  // The clock comes from the caller so the cutoff is a decision, not a side effect. Seconds
  // since the epoch, to microseconds.
  let now_micros = @env.now().reinterpret_as_int64() * 1000L
  let cutoff = now_micros - opts.days.to_int64() * 86400L * 1000000L
  let (channels, messages, reactions) = fetch_bridge(cutoff) catch {
    e => {
      log("could not read the atproto bridge: \{e}")
      return None
    }
  }
  match opts.record_to {
    Some(path) => write_snapshot(path, channels, messages, reactions, opts.days)
    None => ()
  }
  let loaded = @foc.load(channels, messages, reactions)
  log("loaded \{loaded.summary()} from the Feeling-of-Computing bridge")
  Some(
    (
      loaded.workspace,
      loaded.token,
      "a mock workspace seeded from the last \{opts.days} days of the Feeling-of-Computing Slack, via its atproto bridge",
    ),
  )
}

///|
async fn fetch_bridge(
  cutoff : Int64,
) -> (Array[@atproto.Record], Array[@atproto.Record], Array[@atproto.Record]) {
  let bot_pds = @atproto.resolve_pds(@foc.bot_did)
  let owner_pds = @atproto.resolve_pds(@foc.owner_did)
  // Channel keys are not TIDs, and there are seventeen of them, so the whole collection is
  // fetched rather than windowed.
  let channels = @atproto.fetch_all(
    owner_pds, @foc.owner_did, @foc.channel_collection,
  )
  let messages = @atproto.fetch_since(
    bot_pds, @foc.bot_did, @foc.message_collection, cutoff,
  )
  // Reaction keys carry their *target's* microsecond, so the same window selects the
  // reactions belonging to the messages just fetched.
  let reactions = @atproto.fetch_since(
    bot_pds, @foc.bot_did, @foc.reaction_collection, cutoff,
  )
  (channels, messages, reactions)
}

///|
async fn write_snapshot(
  path : String,
  channels : Array[@atproto.Record],
  messages : Array[@atproto.Record],
  reactions : Array[@atproto.Record],
  days : Int,
) -> Unit {
  let snapshot = @foc.Snapshot::{
    channels,
    messages,
    reactions,
    taken_at: @env.now().to_string(),
    days,
  }
  @fs.write_file(
    path,
    snapshot.to_json().stringify(),
    create_mode=CreateOrTruncate,
  ) catch {
    e => {
      log("could not write \{path}: \{e}")
      return
    }
  }
  log("wrote \{path}")
}

///|
async fn load_snapshot(path : String) -> (@mock.Workspace, String, String)? {
  let text = @fs.read_file(path).text() catch {
    e => {
      log("could not read \{path}: \{e}")
      return None
    }
  }
  let body = @json.parse(text) catch {
    e => {
      log("\{path} is not JSON: \{e}")
      return None
    }
  }
  let snapshot = @foc.Snapshot::from_json(body) catch {
    e => {
      log("\{path}: \{e}")
      return None
    }
  }
  let loaded = snapshot.load()
  log("replayed \{loaded.summary()} from \{path}")
  Some((loaded.workspace, loaded.token, snapshot.describe()))
}

///|
/// Diagnostics go to stderr, never stdout.
///
/// On stdio, stdout IS the protocol: one stray line there corrupts the NDJSON stream and the
/// client stops being able to parse anything.
async fn log(message : String) -> Unit {
  @stdio.stderr.write("slack-mcp: " + message + "\n") catch {
    _ => ()
  }
}

///|
async fn main {
  let argv = @env.args()
  // argv[0] is the program itself.
  let rest = if argv.length() > 1 { argv[1:].to_owned() } else { [] }
  guard parse_args(rest) is Some(opts) else { return }
  guard build_backend(opts) is Some(backend) else { return }
  let srv = @tools.build(backend)
  match opts.http_addr {
    Some(addr) =>
      @transport_http.serve(srv, addr, allowed_origins=opts.allowed_origins)
    None => @transport_stdio.serve(srv)
  }
}