///|
fn quote_identifier(name : String) -> String {
  let escaped = name.replace_all(old="\"", new="\"\"")
  "\"" + escaped + "\""
}

///|
/// `inline_primary_key` is false when the table declares a composite primary
/// key, which has to be one table-level constraint. A single-column key keeps
/// the inline form, because in SQLite `INTEGER PRIMARY KEY` written inline is
/// a rowid alias and `PRIMARY KEY("id")` written separately is not.
fn column_definition(
  column : Column,
  inline_primary_key? : Bool = true,
) -> String {
  let parts : Array[String] = [quote_identifier(column.name), column.data_type]
  if column.primary_key && inline_primary_key {
    parts.push("PRIMARY KEY")
  }
  if column.unique {
    parts.push("UNIQUE")
  }
  if !column.nullable {
    parts.push("NOT NULL")
  }
  match column.default_value {
    Some(value) => parts.push("DEFAULT \{value}")
    None => ()
  }
  parts.join(" ")
}

///|
fn foreign_key_definition(foreign_key : ForeignKey) -> String {
  let local_columns = foreign_key.columns.map(quote_identifier).join(", ")
  let referenced = foreign_key.referenced_columns
    .map(quote_identifier)
    .join(", ")
  let mut sql = "CONSTRAINT \{quote_identifier(foreign_key.name)} FOREIGN KEY (\{local_columns}) REFERENCES \{quote_identifier(foreign_key.referenced_table)} (\{referenced})"
  match foreign_key.on_delete {
    Some(action) => sql = "\{sql} ON DELETE \{action}"
    None => ()
  }
  match foreign_key.on_update {
    Some(action) => sql = "\{sql} ON UPDATE \{action}"
    None => ()
  }
  sql
}

///|
fn trigger_sql(table : String, trigger : Trigger) -> String {
  "CREATE TRIGGER \{quote_identifier(trigger.name)} \{trigger.timing} \{trigger.event} ON \{quote_identifier(table)} \{trigger.action};"
}

///|
fn view_sql(view : View) -> String {
  "CREATE VIEW \{quote_identifier(view.name)} AS \{view.definition};"
}

///|
fn check_definition(check : CheckConstraint) -> String {
  "CONSTRAINT \{quote_identifier(check.name)} CHECK (\{check.expression})"
}

///|
fn create_table_sql(table : Table, override_name? : String? = None) -> String {
  let key_columns = table.columns.filter(column => column.primary_key)
  let composite_key = key_columns.length() > 1
  let definitions = table.columns.map(column => {
    column_definition(column, inline_primary_key=!composite_key)
  })
  if composite_key {
    let names = key_columns.map(column => quote_identifier(column.name))
    definitions.push("PRIMARY KEY (\{names.join(", ")})")
  }
  definitions.push_iter(table.foreign_keys.map(foreign_key_definition).iter())
  definitions.push_iter(table.checks.map(check_definition).iter())
  let name = override_name.unwrap_or(table.name)
  let body = definitions.join(",\n  ")
  "CREATE TABLE \{quote_identifier(name)} (\n  \{body}\n);"
}

///|
fn create_index_sql(table : String, index : Index) -> String {
  let unique = if index.unique { "UNIQUE " } else { "" }
  let columns = index.columns.map(quote_identifier).join(", ")
  "CREATE \{unique}INDEX \{quote_identifier(index.name)} ON \{quote_identifier(table)} (\{columns});"
}

///|
fn add_foreign_key_sql(table : String, foreign_key : ForeignKey) -> String {
  "ALTER TABLE \{quote_identifier(table)} ADD \{foreign_key_definition(foreign_key)};"
}

///|
fn postgres_alter_column(
  table : String,
  before : Column,
  after : Column,
) -> Array[String] {
  let statements : Array[String] = []
  let prefix = "ALTER TABLE \{quote_identifier(table)} ALTER COLUMN \{quote_identifier(after.name)}"
  if before.data_type != after.data_type {
    statements.push("\{prefix} TYPE \{after.data_type};")
  }
  if before.nullable != after.nullable {
    if after.nullable {
      statements.push("\{prefix} DROP NOT NULL;")
    } else {
      match after.default_value {
        Some(value) =>
          statements.push(
            "UPDATE \{quote_identifier(table)} SET \{quote_identifier(after.name)} = COALESCE(\{quote_identifier(after.name)}, \{value}) WHERE \{quote_identifier(after.name)} IS NULL;",
          )
        None => ()
      }
      statements.push("\{prefix} SET NOT NULL;")
    }
  }
  if before.default_value != after.default_value {
    match after.default_value {
      Some(value) => statements.push("\{prefix} SET DEFAULT \{value};")
      None => statements.push("\{prefix} DROP DEFAULT;")
    }
  }
  // A primary-key or uniqueness change needs a constraint name that the IR
  // does not carry, so `render_plan` refuses the whole plan before reaching
  // here rather than emitting SQL that names a constraint it guessed.
  statements
}

///|
fn sqlite_rebuild_sql(
  before : Table,
  after : Table,
  mappings : Array[(String, String)],
) -> Array[String] {
  let temporary = "__msp_new_\{after.name}"
  let target_columns = mappings
    .map(mapping => quote_identifier(mapping.1))
    .join(", ")
  let source_expressions = mappings
    .map(mapping => {
      let source_name = quote_identifier(mapping.0)
      let source = before.columns
        .iter()
        .find_first(column => column.name == mapping.0)
      let target = after.columns
        .iter()
        .find_first(column => column.name == mapping.1)
      match (source, target) {
        (Some(source), Some(target)) if source.nullable &&
          !target.nullable &&
          target.default_value is Some(default_value) =>
          "COALESCE(\{source_name}, \{default_value})"
        _ => source_name
      }
    })
    .join(", ")
  let statements : Array[String] = [
    "PRAGMA foreign_keys=OFF;",
    "BEGIN IMMEDIATE;",
    create_table_sql(after, override_name=Some(temporary)),
  ]
  if !mappings.is_empty() {
    statements.push(
      "INSERT INTO \{quote_identifier(temporary)} (\{target_columns}) SELECT \{source_expressions} FROM \{quote_identifier(before.name)};",
    )
  }
  statements.push("DROP TABLE \{quote_identifier(before.name)};")
  statements.push(
    "ALTER TABLE \{quote_identifier(temporary)} RENAME TO \{quote_identifier(after.name)};",
  )
  for index in after.indexes {
    statements.push(create_index_sql(after.name, index))
  }
  // `PRAGMA foreign_key_check` only reports violations, so on its own the
  // commit proceeds over a broken reference. Feeding its count through a CHECK
  // constraint turns that report into a real error. A client that stops at the
  // first error then leaves the transaction open, and rolling back an open
  // transaction on exit is what undoes the rebuild -- so the migration must be
  // applied with `sqlite3 -bail`, or any driver that aborts on error. SQL alone
  // cannot make a COMMIT conditional on a query result.
  statements.push(
    "CREATE TEMP TABLE \"__msp_fk_guard\" (violations INTEGER, CHECK (violations = 0));",
  )
  statements.push(
    "INSERT INTO \"__msp_fk_guard\" SELECT count(*) FROM pragma_foreign_key_check;",
  )
  statements.push("DROP TABLE \"__msp_fk_guard\";")
  statements.push("COMMIT;")
  statements.push("PRAGMA foreign_keys=ON;")
  statements
}

///|
fn render_change(change : Change, dialect : Dialect) -> Array[String] {
  match change {
    AddTable(table) => {
      let statements = [create_table_sql(table)]
      for index in table.indexes {
        statements.push(create_index_sql(table.name, index))
      }
      // A new table's triggers belong to its creation, the same way its
      // indexes do, rather than arriving as separate steps.
      for trigger in table.triggers {
        statements.push(trigger_sql(table.name, trigger))
      }
      statements
    }
    DropTable(table) => ["DROP TABLE \{quote_identifier(table.name)};"]
    RenameTable(before, after) =>
      [
        "ALTER TABLE \{quote_identifier(before)} RENAME TO \{quote_identifier(after)};",
      ]
    AddColumn(table, column) =>
      [
        "ALTER TABLE \{quote_identifier(table)} ADD COLUMN \{column_definition(column)};",
      ]
    DropColumn(table, column) =>
      [
        "ALTER TABLE \{quote_identifier(table)} DROP COLUMN \{quote_identifier(column.name)};",
      ]
    RenameColumn(table, before, after) =>
      [
        "ALTER TABLE \{quote_identifier(table)} RENAME COLUMN \{quote_identifier(before)} TO \{quote_identifier(after)};",
      ]
    AlterColumn(table, before, after) =>
      match dialect {
        PostgreSQL => postgres_alter_column(table, before, after)
        SQLite =>
          [
            "-- internal error: SQLite alterations must be planned as a table rebuild",
          ]
      }
    AddIndex(table, index) => [create_index_sql(table, index)]
    DropIndex(_, index) => ["DROP INDEX \{quote_identifier(index.name)};"]
    AddForeignKey(table, foreign_key) =>
      [add_foreign_key_sql(table, foreign_key)]
    AddCheck(table, check) =>
      ["ALTER TABLE \{quote_identifier(table)} ADD \{check_definition(check)};"]
    AddView(view) => [view_sql(view)]
    DropView(view) => ["DROP VIEW \{quote_identifier(view.name)};"]
    AddTrigger(table, trigger) => [trigger_sql(table, trigger)]
    DropTrigger(table, trigger) =>
      match dialect {
        // PostgreSQL scopes a trigger to its table; SQLite's are schema-wide.
        PostgreSQL =>
          [
            "DROP TRIGGER \{quote_identifier(trigger.name)} ON \{quote_identifier(table)};",
          ]
        SQLite => ["DROP TRIGGER \{quote_identifier(trigger.name)};"]
      }
    DropCheck(table, check) =>
      match dialect {
        PostgreSQL =>
          [
            "ALTER TABLE \{quote_identifier(table)} DROP CONSTRAINT \{quote_identifier(check.name)};",
          ]
        SQLite =>
          [
            "-- internal error: SQLite check-constraint changes require a table rebuild",
          ]
      }
    DropForeignKey(table, foreign_key) =>
      match dialect {
        PostgreSQL =>
          [
            "ALTER TABLE \{quote_identifier(table)} DROP CONSTRAINT \{quote_identifier(foreign_key.name)};",
          ]
        SQLite =>
          [
            "-- internal error: SQLite foreign-key changes require a table rebuild",
          ]
      }
    RebuildTable(before, after, mappings) =>
      sqlite_rebuild_sql(before, after, mappings)
  }
}

///|
/// Render a plan into SQL. Destructive plans are blocked unless the caller
/// explicitly opts in, making the safe path the default for both library and CLI.
pub fn render_plan(
  plan : Plan,
  allow_destructive? : Bool = false,
) -> Result[RenderedPlan, Array[RenderIssue]] {
  let issues : Array[RenderIssue] = []
  if !allow_destructive {
    for step in plan.steps {
      if step.risk == Destructive {
        issues.push({
          step_id: step.id,
          message: "destructive step blocked: \{step.reason}",
        })
      }
    }
  }
  for step in plan.steps {
    match step.change {
      AlterColumn(_, before, after) if before.primary_key != after.primary_key ||
        before.unique != after.unique =>
        issues.push({
          step_id: step.id,
          message: "cannot render a key constraint change without an explicit constraint name",
        })
      _ => ()
    }
  }
  guard issues.is_empty() else { return Err(issues) }
  let sql : Array[String] = []
  for step in plan.steps {
    sql.push("-- \{step.id} [\{step.risk.label()}] \{step.reason}")
    sql.push_iter(render_change(step.change, plan.dialect).iter())
  }
  Ok({ plan, sql, })
}