import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { createHash } from 'crypto';
import { Pool, PoolClient } from 'pg';
import { envs } from 'src/config/envs';

/** Ejecuta una sentencia parametrizada dentro de la transacción acotada. */
export type Consulta = <T = Record<string, unknown>>(sql: string, params?: unknown[]) => Promise<T[]>;

export interface AlcanceConsulta {
  usuarioId: string;
  /** Sin empresa solo funcionan las vistas de sesión y v_guias; el resto lanza excepción. */
  empresaId?: string | null;
  /** Sucursales permitidas; null = sin restricción. */
  sucursales?: readonly string[] | null;
  /** Establecimientos permitidos (documentos fiscales); null = sin restricción. */
  establecimientos?: readonly string[] | null;
  /** Privilegios del usuario: las vistas de negocio exigen el suyo (soporte_ro.puede). */
  privilegios?: Iterable<string>;
}

/**
 * Firma del alcance: sha256(clave|empresa|usuario|sucursales|establecimientos|privilegios),
 * con la MISMA serialización que soporte_ro.alcance_firmado() (concat_ws('|', ...)).
 */
export function firmarAlcance(clave: string, valores: string[]): string {
  return createHash('sha256')
    .update([clave, ...valores].join('|'), 'utf8')
    .digest('hex');
}

const lista = (v: readonly string[] | null | undefined) => (v ? [...v].sort().join(',') : '*');

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/**
 * Conexión de SOLO LECTURA a la base del ERP, con el rol `soporte_ia`.
 *
 * Toda consulta pasa por `runScoped`: abre una transacción READ ONLY, fija el
 * alcance (empresa, usuario, sucursales, establecimientos, privilegios) más su
 * FIRMA con set_config(..., true) —local a la transacción— y recién ahí consulta
 * las vistas de `soporte_ro`. La firma impide que un SQL escrito por el modelo
 * cambie el alcance con set_config: las guardas de las vistas la verifican. Nunca `SET` a
 * secas: persistiría en la conexión y el siguiente usuario del pool heredaría
 * la empresa anterior.
 *
 * El alcance (usuario y empresa) lo fija el servidor desde la sesión verificada.
 * Ningún parámetro que venga del modelo o del cliente llega acá.
 *
 * Fail-closed: si el rol conectado es superusuario o puede saltear la RLS, el
 * servicio NO arranca (a diferencia del Chat IA del ERP, que en ese caso cae en
 * silencio a la conexión principal).
 */
@Injectable()
export class ErpReadonlyService implements OnModuleInit, OnModuleDestroy {
  private readonly logger = new Logger(ErpReadonlyService.name);
  private readonly pool = new Pool({
    connectionString: envs.erpDatabaseUrl,
    max: envs.erpDbPoolMax,
    idleTimeoutMillis: 30_000,
    connectionTimeoutMillis: 5_000,
    application_name: 'novasis-soporte-ia',
  });

  async onModuleInit() {
    const rows = await this.query<{ usuario: string; rolsuper: boolean; rolbypassrls: boolean }>(
      `SELECT current_user AS usuario, r.rolsuper, r.rolbypassrls
         FROM pg_roles r WHERE r.rolname = current_user`,
    );
    const rol = rows[0];
    if (!rol || rol.rolsuper || rol.rolbypassrls) {
      throw new Error(
        `ERP_DATABASE_URL debe conectar con el rol de solo lectura soporte_ia ` +
          `(conectado como "${rol?.usuario}", superuser=${rol?.rolsuper}, bypassrls=${rol?.rolbypassrls}).`,
      );
    }
    this.logger.log(`Base del ERP: conectado como "${rol.usuario}" (solo lectura)`);
  }

  async onModuleDestroy() {
    await this.pool.end().catch(() => undefined);
  }

  async runScoped<T>(alcance: AlcanceConsulta, fn: (consulta: Consulta) => Promise<T>): Promise<T> {
    if (!UUID.test(alcance.usuarioId)) {
      throw new Error('runScoped: usuarioId inválido');
    }
    if (alcance.empresaId && !UUID.test(alcance.empresaId)) {
      throw new Error('runScoped: empresaId inválido');
    }

    const client = await this.pool.connect();
    try {
      await client.query('BEGIN READ ONLY');
      const valores = [
        alcance.empresaId ?? '',
        alcance.usuarioId,
        lista(alcance.sucursales),
        lista(alcance.establecimientos),
        [...(alcance.privilegios ?? [])].sort().join(','),
      ];
      await client.query(
        `SELECT set_config('app.empresa_id', $1, true), set_config('app.usuario_id', $2, true),
                set_config('app.sucursales', $3, true), set_config('app.establecimientos', $4, true),
                set_config('app.privilegios', $5, true), set_config('app.firma', $6, true)`,
        [...valores, firmarAlcance(envs.alcanceSecreto, valores)],
      );
      const resultado = await fn(this.consultaSobre(client));
      await client.query('COMMIT');
      return resultado;
    } catch (err) {
      await client.query('ROLLBACK').catch(() => undefined);
      throw err;
    } finally {
      client.release();
    }
  }

  /**
   * Columnas de las vistas de soporte_ro que este rol puede consultar (information_schema
   * solo muestra las que tienen GRANT). Es el esquema que ve el consultor.
   */
  async columnasVistas(): Promise<Array<{ vista: string; columna: string; tipo: string }>> {
    return this.query<{ vista: string; columna: string; tipo: string }>(
      `SELECT table_name AS vista, column_name AS columna, data_type AS tipo
         FROM information_schema.columns
        WHERE table_schema = 'soporte_ro'
        ORDER BY table_name, ordinal_position`,
    );
  }

  /** Para el health check. No toca vistas ni fija alcance. */
  async ping(): Promise<boolean> {
    await this.query('SELECT 1');
    return true;
  }

  private consultaSobre(client: PoolClient): Consulta {
    return async <T>(sql: string, params: unknown[] = []) => {
      const r = await client.query(sql, params);
      return r.rows as T[];
    };
  }

  private async query<T>(sql: string): Promise<T[]> {
    const r = await this.pool.query(sql);
    return r.rows as T[];
  }
}
