///|
/// Usage, file, JSON or schema-validation failure. Nothing was rendered.
const EXIT_USAGE : Int = 1

///|
/// The plan is well formed but contains a step above the configured ceiling.
const EXIT_POLICY : Int = 2

///|
/// Kept equal to `version` in moon.mod by an assertion in
/// `scripts/cli_smoke.sh`, because nothing at run time can read the manifest.
const VERSION : String = "0.4.0"

///|
fn usage() -> String {
  (
    #|moon-schema-plan: deterministic schema diff and safe SQL planning
    #|
    #|Usage:
    #|  moon-schema-plan plan    [options]
    #|  moon-schema-plan verify  [options]
    #|  moon-schema-plan demo
    #|  moon-schema-plan help
    #|  moon-schema-plan version
    #|
    #|Schema input (choose one form per schema):
    #|  --before          read the source schema from a JSON file
    #|  --after           read the target schema from a JSON file
    #|  --hints           read explicit rename hints from a JSON file
    #|  --before-json     pass the source schema as inline JSON
    #|  --after-json      pass the target schema as inline JSON
    #|  --hints-json      pass rename hints as inline JSON
    #|
    #|The first three positional arguments after the dialect are still accepted
    #|as inline before, after and hints JSON.
    #|
    #|Options:
    #|  --max-risk 
    #|                          highest risk the run may contain (default: review)
    #|  --allow-destructive     alias for --max-risk destructive
    #|  --format 
    #|                          plan output format (default: sql)
    #|                          verify accepts text, json or markdown (default: text)
    #|  --out             write the output to a file instead of stdout
    #|
    #|Exit codes:
    #|  0  success, and for verify the plan satisfies the risk policy
    #|  1  usage, file, JSON or schema-validation error
    #|  2  the plan contains a step above --max-risk
    #|
    #|Run `moon-schema-plan demo` for a complete executable SQLite example.
  )
}

///|
/// `@sys.exit` does not return, but its MoonBit signature does. The `abort`
/// keeps the type checker honest without adding a reachable failure path.
fn[T] exit_with(code : Int) -> T {
  @sys.exit(code)
  abort("moon-schema-plan: the process failed to exit")
}

///|
/// MoonBit's wasm backend has no portable stderr, so diagnostics share stdout
/// with the SQL. Every diagnostic line is therefore written as a SQL comment:
/// a pipeline such as `plan ... | sqlite3` cannot be fed a stray message, the
/// database ignores whatever arrives, and the non-zero exit still stops the
/// build. Writing the migration to a file with `--out` remains the better
/// habit, and the README says so.
fn diagnostic(message : String) -> Unit {
  for line in message.split("\n") {
    println("-- \{line}")
  }
}

///|
fn[T] fail_cli(message : String) -> T {
  diagnostic(message)
  diagnostic("run `moon-schema-plan help` for usage")
  exit_with(EXIT_USAGE)
}

///|
fn read_file(path : String) -> String {
  @fs.read_file_to_string(path) catch {
    @fs.IOError(message) => fail_cli("cannot read \{path}: \{message}")
  }
}

///|
fn write_output(text : String, out : String?) -> Unit {
  match out {
    None => println(text)
    Some(path) =>
      @fs.write_string_to_file(path, text + "\n") catch {
        @fs.IOError(message) => fail_cli("cannot write \{path}: \{message}")
      }
  }
}

///|
fn parse_dialect(value : String) -> @lib.Dialect {
  match @lib.Dialect::parse(value) {
    Some(dialect) => dialect
    None => fail_cli("unsupported dialect: \{value}")
  }
}

///|
fn parse_schema(source : String, origin : String) -> @lib.Schema {
  let json = @json.parse(source) catch {
    err => fail_cli("invalid JSON in \{origin}: \{err}")
  }
  @lib.schema_from_json(json) catch {
    err => fail_cli("invalid schema in \{origin}: \{err}")
  }
}

///|
fn parse_hints(source : String, origin : String) -> @lib.RenameHints {
  let json = @json.parse(source) catch {
    err => fail_cli("invalid JSON in \{origin}: \{err}")
  }
  @json.from_json(json) catch {
    err => fail_cli("invalid rename hints in \{origin}: \{err}")
  }
}

///|
/// Where a schema argument came from. Parsing records the intent; reading the
/// file happens afterwards, which is what keeps `parse_options` a pure
/// function and therefore testable.
pub(all) enum SourceKind {
  Inline
  File
} derive(Eq, Debug)

///|
/// A schema argument together with where it came from, so that every
/// diagnostic can name the file or the flag the operator actually used.
pub(all) struct Source {
  kind : SourceKind
  value : String
  origin : String
} derive(Eq, Debug)

///|
pub(all) struct Options {
  mut before : Source?
  mut after : Source?
  mut hints : Source?
  mut max_risk : @lib.Risk
  mut format : String?
  mut out : String?
}

///|
fn Options::new() -> Options {
  {
    before: None,
    after: None,
    hints: None,
    max_risk: Review,
    format: None,
    out: None,
  }
}

///|
fn Options::slot(self : Options, slot : String) -> Source? {
  match slot {
    "before" => self.before
    "after" => self.after
    _ => self.hints
  }
}

///|
fn Options::assign(
  self : Options,
  slot : String,
  value : Source,
) -> Result[Unit, String] {
  if self.slot(slot) is Some(previous) {
    return Err(
      "the \{slot} schema was given twice: \{previous.origin} and \{value.origin}",
    )
  }
  match slot {
    "before" => self.before = Some(value)
    "after" => self.after = Some(value)
    _ => self.hints = Some(value)
  }
  Ok(())
}

///|
/// Read the value that follows a flag, naming the flag rather than reporting a
/// generic index error when it is missing.
fn flag_value(args : Array[String], index : Int) -> Result[String, String] {
  if index >= args.length() {
    return Err("\{args[index - 1]} requires a value")
  }
  Ok(args[index])
}

///|
/// Turn the argument list into options, or into the diagnostic that explains
/// why it could not be. This performs no I/O and exits no process, so every
/// branch below is reachable from a test.
fn parse_options(args : Array[String]) -> Result[Options, String] {
  let options = Options::new()
  let positional = ["before", "after", "hints"]
  let mut next_positional = 0
  let mut i = 3
  while i < args.length() {
    let argument = args[i]
    match argument {
      "--before" | "--after" | "--hints" => {
        let path = match flag_value(args, i + 1) {
          Ok(value) => value
          Err(message) => return Err(message)
        }
        let slot = argument[2:].to_owned()
        match options.assign(slot, { kind: File, value: path, origin: path, }) {
          Err(message) => return Err(message)
          Ok(_) => ()
        }
        i = i + 2
      }
      "--before-json" | "--after-json" | "--hints-json" => {
        let text = match flag_value(args, i + 1) {
          Ok(value) => value
          Err(message) => return Err(message)
        }
        let slot = argument[2:argument.length() - 5].to_owned()
        match
          options.assign(slot, { kind: Inline, value: text, origin: argument, }) {
          Err(message) => return Err(message)
          Ok(_) => ()
        }
        i = i + 2
      }
      "--max-risk" => {
        let value = match flag_value(args, i + 1) {
          Ok(value) => value
          Err(message) => return Err(message)
        }
        match @lib.Risk::parse(value) {
          Some(risk) => options.max_risk = risk
          None => return Err("unsupported risk level: \{value}")
        }
        i = i + 2
      }
      "--allow-destructive" => {
        options.max_risk = Destructive
        i = i + 1
      }
      "--format" => {
        match flag_value(args, i + 1) {
          Ok(value) => options.format = Some(value)
          Err(message) => return Err(message)
        }
        i = i + 2
      }
      "--out" => {
        match flag_value(args, i + 1) {
          Ok(value) => options.out = Some(value)
          Err(message) => return Err(message)
        }
        i = i + 2
      }
      value => {
        if value.has_prefix("--") {
          return Err("unknown option: \{value}")
        }
        if next_positional >= positional.length() {
          return Err("unexpected argument: \{value}")
        }
        let slot = positional[next_positional]
        next_positional = next_positional + 1
        match
          options.assign(slot, {
            kind: Inline,
            value,
            origin: "<\{slot} argument>",
          }) {
          Err(message) => return Err(message)
          Ok(_) => ()
        }
        i = i + 1
      }
    }
  }
  Ok(options)
}

///|
/// Resolve a recorded argument into schema text, reading the file if that is
/// what the operator asked for.
fn resolve(source : Source) -> String {
  match source.kind {
    Inline => source.value
    File => read_file(source.value)
  }
}

///|
fn build_plan_from(args : Array[String]) -> (@lib.Plan, Options) {
  guard args.length() >= 3 else { fail_cli(usage()) }
  let dialect = parse_dialect(args[2])
  let options = match parse_options(args) {
    Ok(options) => options
    Err(message) => fail_cli(message)
  }
  let before = match options.before {
    Some(source) => parse_schema(resolve(source), source.origin)
    None => fail_cli("the source schema is required; use --before")
  }
  let after = match options.after {
    Some(source) => parse_schema(resolve(source), source.origin)
    None => fail_cli("the target schema is required; use --after")
  }
  let hints = match options.hints {
    Some(source) => parse_hints(resolve(source), source.origin)
    None => @lib.RenameHints::empty()
  }
  match @lib.build_plan(before, after, dialect, hints~) {
    Ok(plan) => (plan, options)
    Err(issues) => fail_cli(ToJson::to_json(issues).stringify(indent=2))
  }
}

///|
/// Report the steps a policy rejects and leave with `EXIT_POLICY`. Printing the
/// offending steps is the whole point: an operator must see why CI failed.
fn[T] reject_policy(plan : @lib.Plan, max_risk : @lib.Risk) -> T {
  let violations = plan.steps_above(max_risk)
  diagnostic("migration policy violated: --max-risk is \{max_risk.label()}")
  for step in violations {
    diagnostic(
      "  \{step.id} [\{step.risk.label()}] \{step.change.describe()}: \{step.reason}",
    )
  }
  exit_with(EXIT_POLICY)
}

///|
fn run_plan(args : Array[String]) -> Unit {
  let (plan, options) = build_plan_from(args)
  if !plan.steps_above(options.max_risk).is_empty() {
    reject_policy(plan, options.max_risk)
  }
  let allow_destructive = options.max_risk == Destructive
  let rendered = match @lib.render_plan(plan, allow_destructive~) {
    Ok(rendered) => rendered
    Err(issues) => fail_cli(ToJson::to_json(issues).stringify(indent=2))
  }
  let output = match options.format.unwrap_or("sql") {
    "sql" => rendered.sql.join("\n")
    "json" => @lib.rendered_plan_to_json(rendered).stringify(indent=2)
    "markdown" => rendered.to_markdown()
    other => fail_cli("unsupported plan format: \{other}")
  }
  write_output(output, options.out)
}

///|
fn verify_text(plan : @lib.Plan, max_risk : @lib.Risk) -> String {
  let summary = plan.summary()
  let lines = [
    "schema \{plan.from_version} -> \{plan.to_version} (\{plan.dialect.label()})",
    "steps: \{summary.total} (safe \{summary.safe}, review \{summary.review}, destructive \{summary.destructive})",
    "highest risk: \{plan.max_risk().label()}",
    "policy: --max-risk \{max_risk.label()}",
  ]
  for step in plan.steps {
    lines.push("  \{step.id} [\{step.risk.label()}] \{step.change.describe()}")
  }
  lines.join("\n")
}

///|
/// `verify` never renders SQL. It answers one question for CI: may this
/// migration proceed under the configured policy?
fn run_verify(args : Array[String]) -> Unit {
  let (plan, options) = build_plan_from(args)
  let output = match options.format.unwrap_or("text") {
    "text" => verify_text(plan, options.max_risk)
    "json" => @lib.plan_to_json(plan).stringify(indent=2)
    "markdown" => plan.to_markdown()
    other => fail_cli("unsupported verify format: \{other}")
  }
  write_output(output, options.out)
  if !plan.steps_above(options.max_risk).is_empty() {
    reject_policy(plan, options.max_risk)
  }
}

///|
fn demo() -> String {
  let before = @lib.Schema::{
    version: "1",
    tables: [
      {
        name: "users",
        columns: [
          {
            name: "id",
            data_type: "INTEGER",
            nullable: false,
            default_value: None,
            primary_key: true,
            unique: false,
          },
          {
            name: "nickname",
            data_type: "TEXT",
            nullable: true,
            default_value: None,
            primary_key: false,
            unique: false,
          },
        ],
        indexes: [],
        foreign_keys: [],
        checks: [],
        triggers: [],
      },
    ],
    views: [],
  }
  let after = @lib.Schema::{
    version: "2",
    tables: [
      {
        name: "users",
        columns: [
          {
            name: "id",
            data_type: "INTEGER",
            nullable: false,
            default_value: None,
            primary_key: true,
            unique: false,
          },
          {
            name: "display_name",
            data_type: "TEXT",
            nullable: false,
            default_value: Some("'anonymous'"),
            primary_key: false,
            unique: false,
          },
        ],
        indexes: [
          {
            name: "users_display_name_idx",
            columns: ["display_name"],
            unique: false,
          },
        ],
        foreign_keys: [],
        checks: [],
        triggers: [],
      },
    ],
    views: [],
  }
  let hints = @lib.RenameHints::{
    tables: [],
    columns: [("users", "nickname", "display_name")],
  }
  let plan = match @lib.build_plan(before, after, @lib.SQLite, hints~) {
    Ok(plan) => plan
    Err(issues) => fail_cli(ToJson::to_json(issues).stringify(indent=2))
  }
  match @lib.render_plan(plan, allow_destructive=true) {
    Err(issues) => fail_cli(ToJson::to_json(issues).stringify(indent=2))
    Ok(rendered) => rendered.sql.join("\n")
  }
}

///|
fn main {
  let args = @env.args()
  if args.length() < 2 {
    println(usage())
    exit_with(EXIT_USAGE)
  }
  match args[1] {
    "plan" => run_plan(args)
    "verify" => run_verify(args)
    "demo" => println(demo())
    "help" | "--help" | "-h" => println(usage())
    "version" | "--version" | "-V" => println("moon-schema-plan \{VERSION}")
    command => fail_cli("unknown command: \{command}")
  }
}