import { Utils } from '@mikro-orm/core';
import { SchemaHelper } from '../../schema/SchemaHelper.js';
/** SpatiaLite system views that should be automatically ignored */
const SPATIALITE_VIEWS = [
  'geometry_columns',
  'spatial_ref_sys',
  'views_geometry_columns',
  'virts_geometry_columns',
  'geom_cols_ref_sys',
  'spatial_ref_sys_aux',
  'vector_layers',
  'vector_layers_auth',
  'vector_layers_field_infos',
  'vector_layers_statistics',
  'ElementaryGeometries',
];
export class SqliteSchemaHelper extends SchemaHelper {
  disableForeignKeysSQL() {
    return 'pragma foreign_keys = off;';
  }
  enableForeignKeysSQL() {
    return 'pragma foreign_keys = on;';
  }
  supportsSchemaConstraints() {
    return false;
  }
  getCreateNamespaceSQL(name) {
    return '';
  }
  getDropNamespaceSQL(name) {
    return '';
  }
  getListTablesSQL() {
    return (
      `select name as table_name from sqlite_master where type = 'table' and name != 'sqlite_sequence' and name != 'geometry_columns' and name != 'spatial_ref_sys' ` +
      `union all select name as table_name from sqlite_temp_master where type = 'table' order by name`
    );
  }
  async getAllTables(connection, schemas) {
    const databases = await this.getDatabaseList(connection);
    const hasAttachedDbs = databases.length > 1; // More than just 'main'
    // If no attached databases, use original behavior
    if (!hasAttachedDbs && !schemas?.length) {
      return connection.execute(this.getListTablesSQL());
    }
    // With attached databases, query each one
    const targetSchemas = schemas?.length ? schemas : databases;
    const allTables = [];
    for (const dbName of targetSchemas) {
      const prefix = this.getSchemaPrefix(dbName);
      const tables = await connection.execute(
        `select name from ${prefix}sqlite_master where type = 'table' ` +
          `and name != 'sqlite_sequence' and name != 'geometry_columns' and name != 'spatial_ref_sys'`,
      );
      for (const t of tables) {
        allTables.push({ table_name: t.name, schema_name: dbName });
      }
    }
    return allTables;
  }
  async getNamespaces(connection) {
    return this.getDatabaseList(connection);
  }
  getIgnoredViewsCondition() {
    return SPATIALITE_VIEWS.map(v => `name != '${v}'`).join(' and ');
  }
  getListViewsSQL() {
    return `select name as view_name, sql as view_definition from sqlite_master where type = 'view' and ${this.getIgnoredViewsCondition()} order by name`;
  }
  async loadViews(schema, connection, schemaName) {
    const databases = await this.getDatabaseList(connection);
    const hasAttachedDbs = databases.length > 1; // More than just 'main'
    // If no attached databases and no specific schema, use original behavior
    if (!hasAttachedDbs && !schemaName) {
      const views = await connection.execute(this.getListViewsSQL());
      for (const view of views) {
        schema.addView(view.view_name, schemaName, this.extractViewDefinition(view.view_definition));
      }
      return;
    }
    // With attached databases, query each one
    /* v8 ignore next - schemaName branch not commonly used */
    const targetDbs = schemaName ? [schemaName] : databases;
    for (const dbName of targetDbs) {
      const prefix = this.getSchemaPrefix(dbName);
      const views = await connection.execute(
        `select name as view_name, sql as view_definition from ${prefix}sqlite_master where type = 'view' and ${this.getIgnoredViewsCondition()} order by name`,
      );
      for (const view of views) {
        schema.addView(view.view_name, dbName, this.extractViewDefinition(view.view_definition));
      }
    }
  }
  getDropDatabaseSQL(name) {
    if (name === ':memory:') {
      return '';
    }
    /* v8 ignore next */
    return `drop database if exists ${this.quote(name)}`;
  }
  async loadInformationSchema(schema, connection, tables, schemas) {
    for (const t of tables) {
      const table = schema.addTable(t.table_name, t.schema_name, t.table_comment);
      const cols = await this.getColumns(connection, table.name, table.schema);
      const indexes = await this.getIndexes(connection, table.name, table.schema);
      const checks = await this.getChecks(connection, table.name, table.schema);
      const pks = await this.getPrimaryKeys(connection, indexes, table.name, table.schema);
      const fks = await this.getForeignKeys(connection, table.name, table.schema);
      const enums = await this.getEnumDefinitions(connection, table.name, table.schema);
      table.init(cols, indexes, checks, pks, fks, enums);
    }
  }
  createTable(table, alter) {
    let sql = `create table ${table.getQuotedName()} (`;
    const columns = table.getColumns();
    const lastColumn = columns[columns.length - 1].name;
    for (const column of columns) {
      const col = this.createTableColumn(column, table);
      if (col) {
        const comma = column.name === lastColumn ? '' : ', ';
        sql += col + comma;
      }
    }
    const primaryKey = table.getPrimaryKey();
    const createPrimary = primaryKey?.composite;
    if (createPrimary && primaryKey) {
      sql += `, primary key (${primaryKey.columnNames.map(c => this.quote(c)).join(', ')})`;
    }
    const parts = [];
    for (const fk of Object.values(table.getForeignKeys())) {
      parts.push(this.createForeignKey(table, fk, false));
    }
    for (const check of table.getChecks()) {
      const sql = `constraint ${this.quote(check.name)} check (${check.expression})`;
      parts.push(sql);
    }
    if (parts.length > 0) {
      sql += ', ' + parts.join(', ');
    }
    sql += ')';
    if (table.comment) {
      sql += ` /* ${table.comment} */`;
    }
    const ret = [];
    this.append(ret, sql);
    for (const index of table.getIndexes()) {
      this.append(ret, this.createIndex(index, table));
    }
    return ret;
  }
  createTableColumn(column, table, _changedProperties) {
    const col = [this.quote(column.name)];
    const checks = table.getChecks();
    const check = checks.findIndex(check => check.columnName === column.name);
    const useDefault = column.default != null && column.default !== 'null';
    let columnType = column.type;
    if (column.autoincrement) {
      columnType = 'integer';
    }
    if (column.generated) {
      columnType += ` generated always as ${column.generated}`;
    }
    col.push(columnType);
    if (check !== -1) {
      col.push(`check (${checks[check].expression})`);
      checks.splice(check, 1);
    }
    Utils.runIfNotEmpty(() => col.push('null'), column.nullable);
    Utils.runIfNotEmpty(() => col.push('not null'), !column.nullable && !column.generated);
    Utils.runIfNotEmpty(() => col.push('primary key'), column.primary);
    Utils.runIfNotEmpty(() => col.push('autoincrement'), column.autoincrement);
    Utils.runIfNotEmpty(() => col.push(`default ${column.default}`), useDefault);
    return col.join(' ');
  }
  getAddColumnsSQL(table, columns, diff) {
    return columns.map(column => {
      let sql = `alter table ${table.getQuotedName()} add column ${this.createTableColumn(column, table)}`;
      const foreignKey = Object.values(diff.addedForeignKeys).find(
        fk => fk.columnNames.length === 1 && fk.columnNames[0] === column.name,
      );
      if (foreignKey && this.options.createForeignKeyConstraints) {
        delete diff.addedForeignKeys[foreignKey.constraintName];
        sql += ' ' + this.createForeignKey(diff.toTable, foreignKey, false, true);
      }
      return sql;
    });
  }
  dropForeignKey(tableName, constraintName) {
    return '';
  }
  getDropColumnsSQL(tableName, columns, schemaName) {
    /* v8 ignore next */
    const name = this.quote(
      (schemaName && schemaName !== this.platform.getDefaultSchemaName() ? schemaName + '.' : '') + tableName,
    );
    return columns
      .map(column => {
        return `alter table ${name} drop column ${this.quote(column.name)}`;
      })
      .join(';\n');
  }
  getCreateIndexSQL(tableName, index) {
    /* v8 ignore next */
    if (index.expression) {
      return index.expression;
    }
    // SQLite requires: CREATE INDEX schema.index_name ON table_name (columns)
    // NOT: CREATE INDEX index_name ON schema.table_name (columns)
    const [schemaName, rawTableName] = this.splitTableName(tableName);
    const quotedTableName = this.quote(rawTableName);
    // If there's a schema, prefix the index name with it
    let keyName;
    if (schemaName && schemaName !== 'main') {
      keyName = `${this.quote(schemaName)}.${this.quote(index.keyName)}`;
    } else {
      keyName = this.quote(index.keyName);
    }
    const sqlPrefix = `create ${index.unique ? 'unique ' : ''}index ${keyName} on ${quotedTableName}`;
    /* v8 ignore next 4 */
    if (index.columnNames.some(column => column.includes('.'))) {
      // JSON columns can have unique index but not unique constraint, and we need to distinguish those, so we can properly drop them
      const columns = this.platform.getJsonIndexDefinition(index);
      return `${sqlPrefix} (${columns.join(', ')})`;
    }
    // Use getIndexColumns to support advanced options like sort order and collation
    return `${sqlPrefix} (${this.getIndexColumns(index)})`;
  }
  parseTableDefinition(sql, cols) {
    const columns = {};
    const constraints = [];
    // extract all columns definitions
    let columnsDef = new RegExp(`create table [\`"']?.*?[\`"']? \\((.*)\\)`, 'i').exec(sql.replaceAll('\n', ''))?.[1];
    /* v8 ignore next */
    if (columnsDef) {
      if (columnsDef.includes(', constraint ')) {
        constraints.push(...columnsDef.substring(columnsDef.indexOf(', constraint') + 2).split(', '));
        columnsDef = columnsDef.substring(0, columnsDef.indexOf(', constraint'));
      }
      for (let i = cols.length - 1; i >= 0; i--) {
        const col = cols[i];
        const re = ` *, *[\`"']?${col.name}[\`"']? (.*)`;
        const columnDef = new RegExp(re, 'i').exec(columnsDef);
        if (columnDef) {
          columns[col.name] = { name: col.name, definition: columnDef[1] };
          columnsDef = columnsDef.substring(0, columnDef.index);
        }
      }
    }
    return { columns, constraints };
  }
  /**
   * Returns schema prefix for pragma and sqlite_master queries.
   * Returns empty string for main database (no prefix needed).
   */
  getSchemaPrefix(schemaName) {
    if (!schemaName || schemaName === 'main') {
      return '';
    }
    return `${this.platform.quoteIdentifier(schemaName)}.`;
  }
  /**
   * Returns all database names excluding 'temp'.
   */
  async getDatabaseList(connection) {
    const databases = await connection.execute('pragma database_list');
    return databases.filter(d => d.name !== 'temp').map(d => d.name);
  }
  /**
   * Extracts the SELECT part from a CREATE VIEW statement.
   */
  extractViewDefinition(viewDefinition) {
    const match = /create\s+view\s+[`"']?\w+[`"']?\s+as\s+(.*)/is.exec(viewDefinition);
    /* v8 ignore next - fallback for non-standard view definitions */
    return match ? match[1] : viewDefinition;
  }
  async getColumns(connection, tableName, schemaName) {
    const prefix = this.getSchemaPrefix(schemaName);
    const columns = await connection.execute(`pragma ${prefix}table_xinfo('${tableName}')`);
    const sql = `select sql from ${prefix}sqlite_master where type = ? and name = ?`;
    const tableDefinition = await connection.execute(sql, ['table', tableName], 'get');
    const composite = columns.reduce((count, col) => count + (col.pk ? 1 : 0), 0) > 1;
    // there can be only one, so naive check like this should be enough
    const hasAutoincrement = tableDefinition.sql.toLowerCase().includes('autoincrement');
    const { columns: columnDefinitions } = this.parseTableDefinition(tableDefinition.sql, columns);
    return columns.map(col => {
      const mappedType = connection.getPlatform().getMappedType(col.type);
      let generated;
      if (col.hidden > 1) {
        /* v8 ignore next */
        const storage = col.hidden === 2 ? 'virtual' : 'stored';
        const re = new RegExp(`(generated always)? as \\((.*)\\)( ${storage})?$`, 'i');
        const match = columnDefinitions[col.name].definition.match(re);
        if (match) {
          generated = `${match[2]} ${storage}`;
        }
      }
      return {
        name: col.name,
        type: col.type,
        default: this.wrapExpressionDefault(col.dflt_value),
        nullable: !col.notnull,
        primary: !!col.pk,
        mappedType,
        unsigned: false,
        autoincrement: !composite && col.pk && this.platform.isNumericColumn(mappedType) && hasAutoincrement,
        generated,
      };
    });
  }
  /**
   * SQLite strips outer parentheses from expression defaults (`DEFAULT (expr)` → `expr` in pragma).
   * We need to add them back so they match what we generate in DDL.
   */
  wrapExpressionDefault(value) {
    if (value == null) {
      return null;
    }
    // simple values that are returned as-is from pragma (no wrapping needed)
    if (
      /^-?\d/.test(value) ||
      /^[xX]'/.test(value) ||
      value.startsWith("'") ||
      value.startsWith('"') ||
      value.startsWith('(')
    ) {
      return value;
    }
    const lower = value.toLowerCase();
    if (['null', 'true', 'false', 'current_timestamp', 'current_date', 'current_time'].includes(lower)) {
      return value;
    }
    // everything else is an expression that had its outer parens stripped
    return `(${value})`;
  }
  async getEnumDefinitions(connection, tableName, schemaName) {
    const prefix = this.getSchemaPrefix(schemaName);
    const sql = `select sql from ${prefix}sqlite_master where type = ? and name = ?`;
    const tableDefinition = await connection.execute(sql, ['table', tableName], 'get');
    const checkConstraints = [...(tableDefinition.sql.match(/[`["'][^`\]"']+[`\]"'] text check \(.*?\)/gi) ?? [])];
    return checkConstraints.reduce((o, item) => {
      // check constraints are defined as (note that last closing paren is missing):
      // `type` text check (`type` in ('local', 'global')
      const match = /[`["']([^`\]"']+)[`\]"'] text check \(.* \((.*)\)/i.exec(item);
      /* v8 ignore next */
      if (match) {
        o[match[1]] = match[2].split(',').map(item => /^\(?'(.*)'/.exec(item.trim())[1]);
      }
      return o;
    }, {});
  }
  async getPrimaryKeys(connection, indexes, tableName, schemaName) {
    const prefix = this.getSchemaPrefix(schemaName);
    const sql = `pragma ${prefix}table_info(\`${tableName}\`)`;
    const cols = await connection.execute(sql);
    return cols.filter(col => !!col.pk).map(col => col.name);
  }
  async getIndexes(connection, tableName, schemaName) {
    const prefix = this.getSchemaPrefix(schemaName);
    const sql = `pragma ${prefix}table_info(\`${tableName}\`)`;
    const cols = await connection.execute(sql);
    const indexes = await connection.execute(`pragma ${prefix}index_list(\`${tableName}\`)`);
    const ret = [];
    for (const col of cols.filter(c => c.pk)) {
      ret.push({
        columnNames: [col.name],
        keyName: 'primary',
        constraint: true,
        unique: true,
        primary: true,
      });
    }
    for (const index of indexes.filter(index => !this.isImplicitIndex(index.name))) {
      const res = await connection.execute(`pragma ${prefix}index_info(\`${index.name}\`)`);
      ret.push(
        ...res.map(row => ({
          columnNames: [row.name],
          keyName: index.name,
          unique: !!index.unique,
          constraint: !!index.unique,
          primary: false,
        })),
      );
    }
    return this.mapIndexes(ret);
  }
  async getChecks(connection, tableName, schemaName) {
    const { columns, constraints } = await this.getColumnDefinitions(connection, tableName, schemaName);
    const checks = [];
    for (const key of Object.keys(columns)) {
      const column = columns[key];
      const expression = / (check \((.*)\))/i.exec(column.definition);
      if (expression) {
        checks.push({
          name: this.platform.getConfig().getNamingStrategy().indexName(tableName, [column.name], 'check'),
          definition: expression[1],
          expression: expression[2],
          columnName: column.name,
        });
      }
    }
    for (const constraint of constraints) {
      const expression = /constraint *[`"']?(.*?)[`"']? * (check \((.*)\))/i.exec(constraint);
      if (expression) {
        checks.push({
          name: expression[1],
          definition: expression[2],
          expression: expression[3],
        });
      }
    }
    return checks;
  }
  async getColumnDefinitions(connection, tableName, schemaName) {
    const prefix = this.getSchemaPrefix(schemaName);
    const columns = await connection.execute(`pragma ${prefix}table_xinfo('${tableName}')`);
    const sql = `select sql from ${prefix}sqlite_master where type = ? and name = ?`;
    const tableDefinition = await connection.execute(sql, ['table', tableName], 'get');
    return this.parseTableDefinition(tableDefinition.sql, columns);
  }
  async getForeignKeys(connection, tableName, schemaName) {
    const { constraints } = await this.getColumnDefinitions(connection, tableName, schemaName);
    const prefix = this.getSchemaPrefix(schemaName);
    const fks = await connection.execute(`pragma ${prefix}foreign_key_list(\`${tableName}\`)`);
    const qualifiedTableName = schemaName ? `${schemaName}.${tableName}` : tableName;
    return fks.reduce((ret, fk) => {
      const constraintName = this.platform.getIndexName(tableName, [fk.from], 'foreign');
      const constraint = constraints?.find(c => c.includes(constraintName));
      ret[constraintName] = {
        constraintName,
        columnName: fk.from,
        columnNames: [fk.from],
        localTableName: qualifiedTableName,
        referencedTableName: fk.table,
        referencedColumnName: fk.to,
        referencedColumnNames: [fk.to],
        updateRule: fk.on_update.toLowerCase(),
        deleteRule: fk.on_delete.toLowerCase(),
        deferMode: constraint?.match(/ deferrable initially (deferred|immediate)/i)?.[1].toLowerCase(),
      };
      return ret;
    }, {});
  }
  getManagementDbName() {
    return '';
  }
  getCreateDatabaseSQL(name) {
    return '';
  }
  async databaseExists(connection, name) {
    const tables = await connection.execute(this.getListTablesSQL());
    return tables.length > 0;
  }
  /**
   * Implicit indexes will be ignored when diffing
   */
  isImplicitIndex(name) {
    // Ignore indexes with reserved names, e.g. autoindexes
    return name.startsWith('sqlite_');
  }
  dropIndex(table, index, oldIndexName = index.keyName) {
    return `drop index ${this.quote(oldIndexName)}`;
  }
  /**
   * SQLite does not support schema-qualified table names in REFERENCES clauses.
   * Foreign key references can only point to tables in the same database.
   */
  getReferencedTableName(referencedTableName, schema) {
    const [schemaName, tableName] = this.splitTableName(referencedTableName);
    // Strip any schema prefix - SQLite REFERENCES clause doesn't support it
    return tableName;
  }
  alterTable(diff, safe) {
    const ret = [];
    const [schemaName, tableName] = this.splitTableName(diff.name);
    if (
      Utils.hasObjectKeys(diff.removedChecks) ||
      Utils.hasObjectKeys(diff.changedChecks) ||
      Utils.hasObjectKeys(diff.changedForeignKeys) ||
      Utils.hasObjectKeys(diff.changedColumns)
    ) {
      return this.getAlterTempTableSQL(diff);
    }
    for (const index of Object.values(diff.removedIndexes)) {
      this.append(ret, this.dropIndex(diff.name, index));
    }
    for (const index of Object.values(diff.changedIndexes)) {
      this.append(ret, this.dropIndex(diff.name, index));
    }
    /* v8 ignore next */
    if (!safe && Object.values(diff.removedColumns).length > 0) {
      this.append(ret, this.getDropColumnsSQL(tableName, Object.values(diff.removedColumns), schemaName));
    }
    if (Object.values(diff.addedColumns).length > 0) {
      this.append(ret, this.getAddColumnsSQL(diff.toTable, Object.values(diff.addedColumns), diff));
    }
    if (Utils.hasObjectKeys(diff.addedForeignKeys) || Utils.hasObjectKeys(diff.addedChecks)) {
      return this.getAlterTempTableSQL(diff);
    }
    for (const [oldColumnName, column] of Object.entries(diff.renamedColumns)) {
      this.append(ret, this.getRenameColumnSQL(tableName, oldColumnName, column, schemaName));
    }
    for (const index of Object.values(diff.addedIndexes)) {
      ret.push(this.createIndex(index, diff.toTable));
    }
    for (const index of Object.values(diff.changedIndexes)) {
      ret.push(this.createIndex(index, diff.toTable, true));
    }
    for (const [oldIndexName, index] of Object.entries(diff.renamedIndexes)) {
      if (index.unique) {
        this.append(ret, this.dropIndex(diff.name, index, oldIndexName));
        this.append(ret, this.createIndex(index, diff.toTable));
      } else {
        this.append(ret, this.getRenameIndexSQL(diff.name, index, oldIndexName));
      }
    }
    return ret;
  }
  getAlterTempTableSQL(changedTable) {
    const tempName = `${changedTable.toTable.name}__temp_alter`;
    const quotedName = this.quote(changedTable.toTable.name);
    const quotedTempName = this.quote(tempName);
    const [first, ...rest] = this.createTable(changedTable.toTable);
    const sql = [
      'pragma foreign_keys = off;',
      first.replace(`create table ${quotedName}`, `create table ${quotedTempName}`),
    ];
    const columns = [];
    for (const column of changedTable.toTable.getColumns()) {
      const fromColumn = changedTable.fromTable.getColumn(column.name);
      if (fromColumn) {
        columns.push(this.quote(column.name));
      } else {
        columns.push(`null as ${this.quote(column.name)}`);
      }
    }
    sql.push(`insert into ${quotedTempName} select ${columns.join(', ')} from ${quotedName};`);
    sql.push(`drop table ${quotedName};`);
    sql.push(`alter table ${quotedTempName} rename to ${quotedName};`);
    sql.push(...rest);
    sql.push('pragma foreign_keys = on;');
    return sql;
  }
}
