///|
/// The identifier-looking tokens in a SQL fragment, with string literals
/// skipped and quoted identifiers unwrapped.
///
/// This is not a SQL parser and does not try to be one: it cannot tell a column
/// from a function or a keyword. It exists so that one narrow question can be
/// answered — does this expression still name something the schema just removed
/// — which needs only that the tokens be recognised, not understood.
fn identifier_tokens(expression : String) -> Array[String] {
  let tokens : Array[String] = []
  let characters = expression.to_array()
  let current = StringBuilder()
  let mut index = 0
  fn flush() {
    if current.to_string() != "" {
      tokens.push(current.to_string())
      current.reset()
    }
  }

  while index < characters.length() {
    let character = characters[index]
    if character == '\'' {
      // A string literal names nothing; skip to its end, honouring '' escapes.
      flush()
      index = index + 1
      while index < characters.length() {
        if characters[index] == '\'' {
          if index + 1 < characters.length() && characters[index + 1] == '\'' {
            index = index + 2
          } else {
            index = index + 1
            break
          }
        } else {
          index = index + 1
        }
      }
    } else if character == '"' {
      // A quoted identifier is a reference like any other.
      flush()
      index = index + 1
      let quoted = StringBuilder()
      while index < characters.length() {
        if characters[index] == '"' {
          if index + 1 < characters.length() && characters[index + 1] == '"' {
            quoted.write_char('"')
            index = index + 2
          } else {
            index = index + 1
            break
          }
        } else {
          quoted.write_char(characters[index])
          index = index + 1
        }
      }
      if quoted.to_string() != "" {
        tokens.push(quoted.to_string())
      }
    } else if character.is_ascii_alphabetic() ||
      character.is_ascii_digit() ||
      character == '_' {
      current.write_char(character)
      index = index + 1
    } else {
      flush()
      index = index + 1
    }
  }
  flush()
  tokens
}

///|
/// Report a check constraint that still names a column the change removes.
///
/// A table-level `CHECK` may only reference columns of its own table, so a name
/// that was a column here before and is not one now is a reference the
/// migration would break. `CREATE TABLE` or `ADD CONSTRAINT` would then fail,
/// which is worth saying while planning rather than discovering mid-migration.
///
/// Matching is exact. A name spelled in a different case is missed rather than
/// guessed at, because a false positive here blocks a migration that would have
/// worked.
fn validate_check_references(
  before : Schema,
  after : Schema,
  hints : RenameHints,
) -> Array[ValidationIssue] {
  let issues : Array[ValidationIssue] = []
  for target_table in after.tables {
    if target_table.checks.is_empty() {
      continue
    }
    let source_name = renamed_table_source(hints, target_table.name).unwrap_or(
      target_table.name,
    )
    match find_table(before.tables, source_name) {
      None => ()
      Some(source_table) => {
        // A source column name is no longer valid in the target table if no
        // column of that name survives there, which covers a drop and a rename
        // alike: after a rename the old name is gone just the same.
        let removed = source_table.columns
          .map(column => column.name)
          .filter(name => find_column(target_table.columns, name) is None)
        for check in target_table.checks {
          for token in identifier_tokens(check.expression) {
            if has_name(removed, token) {
              issues.push({
                path: "tables.\{target_table.name}.checks.\{check.name}.expression",
                message: "references a column this change removes: \{token}",
              })
            }
          }
        }
      }
    }
  }
  issues
}

///|
/// The length of a string in UTF-8 bytes, which is the unit PostgreSQL counts
/// identifiers in. MoonBit strings are UTF-16, so the width is derived from
/// each code point rather than from the string's own length.
fn utf8_length(text : String) -> Int {
  let mut total = 0
  for character in text {
    let code = character.to_int()
    total = total +
      (if code < 0x80 {
        1
      } else if code < 0x800 {
        2
      } else if code < 0x10000 {
        3
      } else {
        4
      })
  }
  total
}

///|
/// PostgreSQL truncates an identifier longer than 63 bytes instead of refusing
/// it. Two names that differ only past that point therefore become one object,
/// silently, which is exactly the kind of surprise this planner exists to keep
/// out of a migration.
fn validate_identifier_lengths(schema : Schema) -> Array[ValidationIssue] {
  let issues : Array[ValidationIssue] = []
  fn check_name(path : String, kind : String, name : String) {
    let length = utf8_length(name)
    if length > 63 {
      issues.push({
        path,
        message: "PostgreSQL truncates an identifier over 63 bytes; this \{kind} is \{length} bytes",
      })
    }
  }

  for table in schema.tables {
    let base = "tables.\{table.name}"
    check_name("\{base}.name", "table name", table.name)
    for column in table.columns {
      check_name("\{base}.columns.\{column.name}", "column name", column.name)
    }
    for index in table.indexes {
      check_name("\{base}.indexes.\{index.name}", "index name", index.name)
    }
    for foreign_key in table.foreign_keys {
      check_name(
        "\{base}.foreign_keys.\{foreign_key.name}",
        "constraint name",
        foreign_key.name,
      )
    }
    for check in table.checks {
      check_name("\{base}.checks.\{check.name}", "constraint name", check.name)
    }
    for trigger in table.triggers {
      check_name(
        "\{base}.triggers.\{trigger.name}",
        "trigger name",
        trigger.name,
      )
    }
  }
  for view in schema.views {
    check_name("views.\{view.name}", "view name", view.name)
  }
  issues
}