import { Inject, Provide, Scope, ScopeEnum } from '@midwayjs/core';
import { NetaClawDataSourceEntity } from '../entity/data_source.js';
import { MysqlPoolManager } from './mysql_pool.js';

export type MysqlMaskMode = 'hash' | 'partial' | 'redact';

export interface MysqlSchemaColumn {
  name: string;
  type: string;
  dataType: string;
  nullable: boolean;
  default: unknown;
  comment: string;
  ordinalPosition: number;
  masked?: true;
  maskMode?: MysqlMaskMode;
}

export interface MysqlSchemaIndex {
  name: string;
  unique: boolean;
  columns: string[];
}

export interface MysqlSchemaForeignKey {
  name: string;
  columns: string[];
  referencedTable: string;
  referencedColumns: string[];
}

export interface MysqlSchemaTable {
  name: string;
  comment: string;
  columns: MysqlSchemaColumn[];
  primaryKey: string[];
  indexes: MysqlSchemaIndex[];
  foreignKeys: MysqlSchemaForeignKey[];
}

export interface MysqlSchema {
  tables: MysqlSchemaTable[];
}

type MysqlSchemaRow = Record<string, unknown>;

export interface MapMysqlSchemaRowsInput {
  columns?: MysqlSchemaRow[];
  indexes?: MysqlSchemaRow[];
  foreignKeys?: MysqlSchemaRow[];
  maskedColumns?: Record<string, MysqlMaskMode>;
}

export interface MysqlListSchemaOptions {
  tables?: string[];
}

export interface MysqlSampleTableOptions {
  table: string;
  columns?: string[];
  limit?: number;
}

export interface MysqlSampleTableResult {
  columns: string[];
  rows: Record<string, unknown>[];
  rowCount: number;
  truncated: false;
}

const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;

export function mapMysqlSchemaRows(input: MapMysqlSchemaRowsInput | MysqlSchemaRow[]): MysqlSchema {
  const columns = Array.isArray(input) ? input : input.columns ?? [];
  const indexes = Array.isArray(input) ? [] : input.indexes ?? [];
  const foreignKeys = Array.isArray(input) ? [] : input.foreignKeys ?? [];
  const maskedColumns = Array.isArray(input) ? {} : input.maskedColumns ?? {};
  const tableMap = new Map<string, MysqlSchemaTable>();

  for (const row of [...columns].sort((a, b) => numberValue(rowValue(a, 'ORDINAL_POSITION', 'ordinalPosition')) - numberValue(rowValue(b, 'ORDINAL_POSITION', 'ordinalPosition')))) {
    const tableName = stringValue(rowValue(row, 'TABLE_NAME', 'tableName'));
    if (!tableName) {
      continue;
    }
    const columnName = stringValue(rowValue(row, 'COLUMN_NAME', 'columnName'));
    const table = ensureTable(tableMap, tableName, stringValue(rowValue(row, 'TABLE_COMMENT', 'tableComment')));
    const maskMode = maskedColumns[`${tableName}.${columnName}`.toLowerCase()];
    table.columns.push({
      name: columnName,
      type: stringValue(rowValue(row, 'COLUMN_TYPE', 'columnType')),
      dataType: stringValue(rowValue(row, 'DATA_TYPE', 'dataType')),
      nullable: stringValue(rowValue(row, 'IS_NULLABLE', 'isNullable')).toUpperCase() === 'YES',
      default: rowValue(row, 'COLUMN_DEFAULT', 'default') ?? null,
      comment: stringValue(rowValue(row, 'COLUMN_COMMENT', 'columnComment')),
      ordinalPosition: numberValue(rowValue(row, 'ORDINAL_POSITION', 'ordinalPosition')),
      ...(maskMode ? { masked: true as const, maskMode } : {}),
    });
    if (stringValue(rowValue(row, 'COLUMN_KEY', 'columnKey')).toUpperCase() === 'PRI' && !table.primaryKey.includes(columnName)) {
      table.primaryKey.push(columnName);
    }
  }

  const indexGroups = groupRows(indexes, row => `${stringValue(rowValue(row, 'TABLE_NAME', 'tableName'))}\0${stringValue(rowValue(row, 'INDEX_NAME', 'indexName'))}`);
  for (const rows of indexGroups.values()) {
    const first = rows[0];
    const tableName = stringValue(rowValue(first, 'TABLE_NAME', 'tableName'));
    const indexName = stringValue(rowValue(first, 'INDEX_NAME', 'indexName'));
    if (!tableName || !indexName) {
      continue;
    }
    const table = ensureTable(tableMap, tableName, '');
    const columnsForIndex = [...rows]
      .sort((a, b) => numberValue(rowValue(a, 'SEQ_IN_INDEX', 'seqInIndex')) - numberValue(rowValue(b, 'SEQ_IN_INDEX', 'seqInIndex')))
      .map(row => stringValue(rowValue(row, 'COLUMN_NAME', 'columnName')))
      .filter(Boolean);
    if (indexName === 'PRIMARY') {
      table.primaryKey = columnsForIndex;
    }
    table.indexes.push({
      name: indexName,
      unique: numberValue(rowValue(first, 'NON_UNIQUE', 'nonUnique')) === 0,
      columns: columnsForIndex,
    });
  }

  const fkGroups = groupRows(foreignKeys, row => `${stringValue(rowValue(row, 'TABLE_NAME', 'tableName'))}\0${stringValue(rowValue(row, 'CONSTRAINT_NAME', 'constraintName'))}`);
  for (const rows of fkGroups.values()) {
    const first = rows[0];
    const tableName = stringValue(rowValue(first, 'TABLE_NAME', 'tableName'));
    const constraintName = stringValue(rowValue(first, 'CONSTRAINT_NAME', 'constraintName'));
    if (!tableName || !constraintName) {
      continue;
    }
    const sortedRows = [...rows].sort((a, b) => numberValue(rowValue(a, 'ORDINAL_POSITION', 'ordinalPosition')) - numberValue(rowValue(b, 'ORDINAL_POSITION', 'ordinalPosition')));
    ensureTable(tableMap, tableName, '').foreignKeys.push({
      name: constraintName,
      columns: sortedRows.map(row => stringValue(rowValue(row, 'COLUMN_NAME', 'columnName'))).filter(Boolean),
      referencedTable: stringValue(rowValue(first, 'REFERENCED_TABLE_NAME', 'referencedTableName')),
      referencedColumns: sortedRows.map(row => stringValue(rowValue(row, 'REFERENCED_COLUMN_NAME', 'referencedColumnName'))).filter(Boolean),
    });
  }

  return { tables: [...tableMap.values()] };
}

@Provide()
@Scope(ScopeEnum.Singleton)
export class MysqlIntrospectionService {
  @Inject()
  mysqlPoolManager: MysqlPoolManager;

  async listSchema(source: NetaClawDataSourceEntity, options: MysqlListSchemaOptions = {}): Promise<MysqlSchema> {
    const visibleTables = this.resolveVisibleTables(source, options.tables);
    if (visibleTables.length === 0) {
      return { tables: [] };
    }

    const pool = await this.mysqlPoolManager.getPool(source);
    const tableNamesSchema = await this.queryTableNames(pool, source, visibleTables);
    const namesOnlySchema = source.extra?.schemaVisibility === 'all-names-only'
      ? tableNamesSchema
      : null;
    const detailedTables = this.resolveDetailedTables(source, visibleTables);
    if (detailedTables.length === 0) {
      return namesOnlySchema ?? { tables: [] };
    }

    const placeholders = detailedTables.map(() => '?').join(', ');
    const params = [source.database, ...detailedTables];
    const [columns] = await pool.query(
      `SELECT TABLE_NAME, COLUMN_NAME, ORDINAL_POSITION, COLUMN_TYPE, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, COLUMN_COMMENT, COLUMN_KEY
       FROM information_schema.COLUMNS
       WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (${placeholders})
       ORDER BY TABLE_NAME, ORDINAL_POSITION`,
      params
    );
    const [indexes] = await pool.query(
      `SELECT TABLE_NAME, INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME
       FROM information_schema.STATISTICS
       WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (${placeholders})
       ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`,
      params
    );
    const [foreignKeys] = await pool.query(
      `SELECT TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME, ORDINAL_POSITION
       FROM information_schema.KEY_COLUMN_USAGE
       WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (${placeholders}) AND REFERENCED_TABLE_NAME IS NOT NULL
       ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION`,
      params
    );

    const detailedSchema = mapMysqlSchemaRows({
      columns: columns as MysqlSchemaRow[],
      indexes: indexes as MysqlSchemaRow[],
      foreignKeys: foreignKeys as MysqlSchemaRow[],
      maskedColumns: source.extra?.maskedColumns,
    });
    return this.mergeNameOnlySchema(tableNamesSchema, detailedSchema);
  }

  async sampleTable(source: NetaClawDataSourceEntity, options: MysqlSampleTableOptions): Promise<MysqlSampleTableResult> {
    const table = options.table.trim().toLowerCase();
    if (!IDENTIFIER_RE.test(table) || !this.isAllowedDetailTable(source, table)) {
      throw new Error('table_not_allowed');
    }

    const schema = await this.listSchema(source, { tables: [table] });
    const schemaTable = schema.tables.find(item => item.name.toLowerCase() === table);
    if (!schemaTable) {
      throw new Error('table_not_allowed');
    }
    const visibleColumns = new Map(schemaTable.columns.map(column => [column.name.toLowerCase(), column.name]));
    const maskedColumns = new Set(
      schemaTable.columns
        .filter(column => column.masked)
        .map(column => column.name.toLowerCase())
    );
    const explicitColumnsRequested = !!options.columns?.length;
    const requestedColumns = explicitColumnsRequested
      ? options.columns.map(column => column.trim().toLowerCase())
      : schemaTable.columns
        .filter(column => !column.masked)
        .map(column => column.name.toLowerCase());

    if (requestedColumns.length === 0) {
      throw new Error('masked_column_denied');
    }

    for (const column of requestedColumns) {
      if (!IDENTIFIER_RE.test(column) || !visibleColumns.has(column)) {
        throw new Error('column_not_allowed');
      }
      if (maskedColumns.has(column)) {
        throw new Error('masked_column_denied');
      }
    }

    const limit = this.clampNumber(options.limit, 5, 1, 20);
    const resolvedColumns = requestedColumns.map(column => visibleColumns.get(column) as string);
    const selectColumns = resolvedColumns.map(column => quoteIdentifier(column)).join(', ');
    const pool = await this.mysqlPoolManager.getPool(source);
    const [rows] = await pool.query(
      `SELECT ${selectColumns} FROM ${quoteIdentifier(table)} LIMIT ?`,
      [limit]
    );

    return {
      columns: resolvedColumns,
      rows: rows as Record<string, unknown>[],
      rowCount: (rows as unknown[]).length,
      truncated: false,
    };
  }

  private resolveVisibleTables(source: NetaClawDataSourceEntity, requestedTables?: string[]): string[] {
    const allowedTables = this.normalizeList(source.extra?.allowedTables);
    const blockedTables = new Set(this.normalizeList(source.extra?.blockedTables));
    const requested = requestedTables ? this.normalizeList(requestedTables) : allowedTables;
    const allowNameOnlyVisibility = source.extra?.schemaVisibility === 'all-names-only';

    return requested.filter(table =>
      IDENTIFIER_RE.test(table) &&
      (allowNameOnlyVisibility || allowedTables.includes(table)) &&
      !blockedTables.has(table)
    );
  }

  private resolveDetailedTables(source: NetaClawDataSourceEntity, visibleTables: string[]): string[] {
    const allowedTables = new Set(this.normalizeList(source.extra?.allowedTables));
    return visibleTables.filter(table => allowedTables.has(table));
  }

  private isVisibleTable(source: NetaClawDataSourceEntity, table: string): boolean {
    return this.resolveVisibleTables(source, [table]).length === 1;
  }

  private isAllowedDetailTable(source: NetaClawDataSourceEntity, table: string): boolean {
    return this.resolveDetailedTables(source, [table]).length === 1 &&
      !new Set(this.normalizeList(source.extra?.blockedTables)).has(table);
  }

  private normalizeList(value: unknown): string[] {
    if (!Array.isArray(value)) {
      return [];
    }
    return [...new Set(value.filter((item): item is string => typeof item === 'string').map(item => item.trim().toLowerCase()).filter(Boolean))];
  }

  private clampNumber(value: number | undefined, fallback: number, min: number, max: number): number {
    if (!Number.isFinite(value)) {
      return fallback;
    }
    return Math.min(max, Math.max(min, Math.trunc(value as number)));
  }

  private async queryTableNames(
    pool: Awaited<ReturnType<MysqlPoolManager['getPool']>>,
    source: NetaClawDataSourceEntity,
    visibleTables: string[]
  ): Promise<MysqlSchema> {
    const placeholders = visibleTables.map(() => '?').join(', ');
    const [rows] = await pool.query(
      `SELECT TABLE_NAME, TABLE_COMMENT
       FROM information_schema.TABLES
       WHERE TABLE_SCHEMA = ? AND TABLE_NAME IN (${placeholders})
       ORDER BY TABLE_NAME`,
      [source.database, ...visibleTables]
    );

    return {
      tables: (rows as MysqlSchemaRow[]).map(row => ({
        name: stringValue(rowValue(row, 'TABLE_NAME', 'tableName')),
        comment: stringValue(rowValue(row, 'TABLE_COMMENT', 'tableComment')),
        columns: [],
        primaryKey: [],
        indexes: [],
        foreignKeys: [],
      })).filter(table => table.name),
    };
  }

  private mergeNameOnlySchema(nameOnlySchema: MysqlSchema, detailedSchema: MysqlSchema): MysqlSchema {
    const detailedByName = new Map(detailedSchema.tables.map(table => [table.name.toLowerCase(), table]));
    return {
      tables: nameOnlySchema.tables.map(table => {
        const detailed = detailedByName.get(table.name.toLowerCase());
        return detailed ? { ...detailed, comment: detailed.comment || table.comment } : table;
      }),
    };
  }
}

function ensureTable(tableMap: Map<string, MysqlSchemaTable>, name: string, comment: string): MysqlSchemaTable {
  const existing = tableMap.get(name);
  if (existing) {
    if (!existing.comment && comment) {
      existing.comment = comment;
    }
    return existing;
  }
  const table: MysqlSchemaTable = {
    name,
    comment,
    columns: [],
    primaryKey: [],
    indexes: [],
    foreignKeys: [],
  };
  tableMap.set(name, table);
  return table;
}

function groupRows(rows: MysqlSchemaRow[], getKey: (row: MysqlSchemaRow) => string): Map<string, MysqlSchemaRow[]> {
  const groups = new Map<string, MysqlSchemaRow[]>();
  for (const row of rows) {
    const key = getKey(row);
    const group = groups.get(key) ?? [];
    group.push(row);
    groups.set(key, group);
  }
  return groups;
}

function stringValue(value: unknown): string {
  return value === null || value === undefined ? '' : String(value);
}

function numberValue(value: unknown): number {
  const numeric = Number(value);
  return Number.isFinite(numeric) ? numeric : 0;
}

function rowValue(row: MysqlSchemaRow, uppercaseKey: string, camelCaseKey: string): unknown {
  return row[uppercaseKey] ?? row[camelCaseKey];
}

function quoteIdentifier(identifier: string): string {
  return `\`${identifier.replace(/`/g, '``')}\``;
}
