'use strict';

/**
 * Schema Diff - Shared payload-vs-database comparison
 *
 * Module ini ekstraksi logic dari `SchemaValidator` di payload-runner.js untuk
 * dipakai bersama oleh `endpoint create` (validasi pre-codegen) tanpa mengikat
 * caller ke seluruh ergonomi `payload diff` command (mis. summary console output
 * yang fokus ke batch validation).
 *
 * Audit-column-awareness:
 *   Default behavior `runtime` adalah inject 4 kolom audit (`created_at`,
 *   `created_by`, `updated_at`, `updated_by`) ke INSERT/UPDATE saat
 *   `payload.auditColumns` tidak di-set `false`/`null`. Fungsi ini meng-compute
 *   effective field list (eksplisit + audit) dan men-flag kolom audit yang
 *   *required* tapi tidak ada di database secara eksplisit, sehingga error
 *   runtime "column does not exist" dapat di-detect saat generate-time.
 *
 * Query-source-awareness:
 *   `fieldName` dapat berisi kolom hasil JOIN (mis. `category_name` dari
 *   `viewQuery`/`datatablesQuery`/`exportQuery`/`viewName`) sesuai spec
 *   `catalogs/rdf/data-source.md`. Validator meng-compute UNION columns dari
 *   seluruh query sources sebelum drift check, sehingga JOIN field tidak
 *   di-flag sebagai drift `[-]`.
 *
 * @module lib/payload/schema-diff
 */

const fs = require('fs');
const path = require('path');
const { resolveEffectiveFieldList, resolveAuditColumnNames, DEFAULT_AUDIT_COLUMNS } = require('../utils/audit-columns');
const { SOFT_DELETE_COLUMNS, isSoftDeleteEnabled } = require('../dbschema-kit/soft-delete-constants');

const DEFAULT_EXCLUDED_COLUMNS = DEFAULT_AUDIT_COLUMNS;

/**
 * Normalisasi tipe dari fieldValidation payload ke kategori yang sama dengan
 * `normalizeDatabaseType`. Dipakai untuk men-detect type drift antara payload
 * dan database.
 *
 * @param {Object} validation - Entry dari payload.fieldValidation
 * @returns {string|null}
 */
function normalizePayloadValidationType(validation) {
    if (!validation) return null;
    const t = (validation.type || '').toLowerCase();
    switch (t) {
        case 'uuid': return 'uuid';
        case 'integer': return 'integer';
        case 'number': return 'numeric';
        case 'boolean': return 'boolean';
        case 'date': return 'date';
        case 'datetime': return 'timestamp';
        case 'string': return 'varchar';
        case 'json':
        case 'array': return 'json';
        default: return t || null;
    }
}

/**
 * Normalisasi tipe data database ke kategori umum untuk perbandingan
 * (mirror dari SchemaValidator.normalizeType di payload-runner.js).
 *
 * @param {string} dataType
 * @param {string} udtName
 * @param {Object} col
 * @returns {string}
 */
function normalizeDatabaseType(dataType, udtName, col) {
    const dt = (dataType || '').toLowerCase();
    const udt = (udtName || '').toLowerCase();

    if (dt === 'uuid' || udt === 'uuid') return 'uuid';
    if (dt === 'boolean' || udt === 'bool') return 'boolean';

    if (['integer', 'bigint', 'smallint', 'serial', 'bigserial', 'smallserial',
         'int', 'tinyint', 'mediumint'].includes(dt) ||
        ['int4', 'int8', 'int2', 'serial4', 'serial8', 'serial2'].includes(udt)) {
        return 'integer';
    }

    if (['numeric', 'decimal', 'real', 'double precision', 'float', 'double'].includes(dt) ||
        ['numeric', 'float4', 'float8'].includes(udt)) {
        return 'numeric';
    }

    if (['character varying', 'varchar', 'varchar2', 'nvarchar2', 'nvarchar'].includes(dt)) return 'varchar';
    if (['text', 'clob', 'nclob', 'longtext', 'mediumtext', 'tinytext'].includes(dt)) return 'text';
    if (['char', 'character', 'nchar'].includes(dt)) return 'char';

    if (dt === 'date') return 'date';
    if (dt === 'datetime') return 'timestamp';
    if (dt.startsWith('timestamp')) return 'timestamp';
    if (dt.startsWith('time')) return 'time';

    if (['json', 'jsonb'].includes(dt) || ['json', 'jsonb'].includes(udt)) return 'json';

    return dt || 'unknown';
}

// String family: varchar/text/char direpresentasikan sebagai satu tipe 'string'
// di RDF (fieldValidation), sehingga ketiganya interchangeable saat drift check.
const STRING_FAMILY = new Set(['varchar', 'text', 'char']);
function isStringFamilyEquivalent(a, b) {
    return STRING_FAMILY.has(a) && STRING_FAMILY.has(b);
}

/**
 * Bandingkan satu payload dengan schema database aktual.
 *
 * Mode strict ini audit-column-aware:
 *   - tidak men-skip kolom audit default dari sisi "added" (kolom audit yang
 *     di-required oleh payload tapi tidak ada di database dilaporkan sebagai
 *     `auditMissing`).
 *   - compute removed = field di payload (eksplisit) yang tidak ada di database
 *   - compute typeChanges = field yang ada di payload + database tapi tipenya
 *     beda (pakai `fieldValidation` di payload sebagai source of truth)
 *
 * Dipakai bersama oleh endpoint create, payload validate, payload diff, dan
 * payload sync sebagai source of truth tunggal untuk drift detection.
 *
 * @param {Object} payload - Processed payload object
 * @param {Object} db - DatabaseIntrospector terkoneksi
 * @returns {Promise<{
 *   tableName: string,
 *   status: 'ok' | 'drift' | 'error',
 *   removed: Array<{ column: string, source: 'payload' | 'audit' }>,
 *   added: Array<{ column: string, type: string, nullable: boolean }>,
 *   typeChanges: Array<{ column: string, payloadType: string, databaseType: string }>,
 *   auditMissing: string[],
 *   summary: string
 * }>}
 */
async function compareSchemaStrict(payload, db, options = {}) {
    const tableName = payload.tableName;
    const result = {
        tableName,
        status: 'ok',
        removed: [],
        added: [],
        typeChanges: [],
        auditMissing: [],
        querySourceWarnings: [],
        summary: ''
    };

    const dbColumns = await db.getDetailedColumnInfo(tableName);
    if (!Array.isArray(dbColumns) || dbColumns.length === 0) {
        result.status = 'error';
        result.summary = `Table "${tableName}" not found in database`;
        return result;
    }

    // Boolean-aware: pada dialect varchar (mysql/oracle/sqlite), kolom boolean
    // disimpan sebagai VARCHAR dengan penanda CHECK `IN ('true','false')`.
    // Tanpa normalisasi ini, drift check membandingkan payload type 'boolean'
    // vs DB type 'varchar' dan salah melaporkan type change. Override data_type
    // ke 'boolean' untuk kolom bermarker agar konsisten dengan introspeksi
    // payload generate (enrichDetailedColumnsWithBoolean di payload-runner).
    if (typeof db.getBooleanColumns === 'function') {
        const booleanColumns = await db.getBooleanColumns(tableName);
        if (Array.isArray(booleanColumns) && booleanColumns.length > 0) {
            const boolSet = new Set(booleanColumns.map(c => String(c).toLowerCase()));
            for (const col of dbColumns) {
                if (boolSet.has(String(col.column_name).toLowerCase())) {
                    col.data_type = 'boolean';
                    col.udt_name = 'boolean';
                }
            }
        }
    }

    const dbColumnSet = new Set(dbColumns.map(c => c.column_name));
    const dbColumnList = dbColumns.map(c => c.column_name);
    const dbColumnMap = {};
    for (const col of dbColumns) {
        dbColumnMap[col.column_name] = col;
    }

    // Resolve UNION column dari query sources (viewName/viewQuery/datatablesQuery/exportQuery).
    // JOIN field di fieldName (mis. category_name dari viewQuery) valid kalau ada di salah
    // satu output query. Sesuai catalogs/rdf/data-source.md.
    const querySourceResult = await resolveQuerySourceColumns(payload, db, options);
    const validColumnSet = new Set(dbColumnSet);
    for (const col of querySourceResult.columns) {
        validColumnSet.add(col);
    }
    result.querySourceWarnings = querySourceResult.warnings;

    const { explicit, audit } = resolveEffectiveFieldList(payload);

    const explicitSet = new Set(explicit);
    for (const field of explicit) {
        if (!validColumnSet.has(field)) {
            result.removed.push({ column: field, source: 'payload' });
        }
    }

    for (const auditCol of audit) {
        if (!dbColumnSet.has(auditCol)) {
            result.auditMissing.push(auditCol);
        }
    }

    for (const dbCol of dbColumns) {
        const name = dbCol.column_name;
        if (explicitSet.has(name)) continue;
        if (DEFAULT_EXCLUDED_COLUMNS.includes(name)) continue;
        if (audit.includes(name)) continue;
        // Soft-delete middle-ground (audit-parity): tiga kolom soft-delete sengaja
        // dikeluarkan dari RDF fieldName, jadi JANGAN tandai sebagai drift `added`
        // (round-trip). WAJIB conditional isSoftDeleteEnabled(payload) — tabel
        // non-soft-delete yang kebetulan punya kolom bernama is_deleted tetap di-flag
        // drift normal (blok softDelete dipertahankan di RDF → konteks tersedia).
        if (isSoftDeleteEnabled(payload) && SOFT_DELETE_COLUMNS.includes(name)) continue;
        result.added.push({
            column: name,
            type: normalizeDatabaseType(dbCol.data_type, dbCol.udt_name, dbCol),
            nullable: dbCol.is_nullable === 'YES'
        });
    }

    // Type drift: hanya untuk field yang ada di kedua sisi DAN punya entry
    // di payload.fieldValidation. Tanpa fieldValidation, type drift tidak bisa
    // di-detect (payload tidak men-claim tipe spesifik).
    const validationMap = {};
    if (Array.isArray(payload.fieldValidation)) {
        for (const fv of payload.fieldValidation) {
            if (fv && fv.name) validationMap[fv.name] = fv;
        }
    }
    for (const field of explicit) {
        const dbCol = dbColumnMap[field];
        if (!dbCol) continue;
        const validation = validationMap[field];
        if (!validation) continue;

        const dbType = normalizeDatabaseType(dbCol.data_type, dbCol.udt_name, dbCol);
        const payloadType = normalizePayloadValidationType(validation);
        if (!payloadType) continue;

        // varchar/text/char saling ekuivalen: RDF hanya punya satu tipe 'string'
        // (dipetakan ke 'varchar') untuk ketiganya, sedangkan introspeksi DB
        // membedakannya. Tanpa ini, kolom TEXT (mis. MySQL yang melaporkan
        // character_maximum_length) salah dilaporkan drift varchar -> text.
        if (dbType !== payloadType && !isStringFamilyEquivalent(dbType, payloadType)) {
            result.typeChanges.push({
                column: field,
                payloadType,
                databaseType: dbType
            });
        }
    }

    const driftCount = result.removed.length + result.added.length
        + result.auditMissing.length + result.typeChanges.length;
    if (driftCount > 0) {
        result.status = 'drift';
        const parts = [];
        if (result.removed.length > 0) parts.push(`${result.removed.length} payload field(s) missing from database`);
        if (result.typeChanges.length > 0) parts.push(`${result.typeChanges.length} type change(s)`);
        if (result.added.length > 0) parts.push(`${result.added.length} new database column(s) not in payload`);
        if (result.auditMissing.length > 0) parts.push(`${result.auditMissing.length} audit column(s) required but missing`);
        result.summary = parts.join(', ');
    } else {
        result.summary = 'Schema is in sync';
    }

    result.totalColumnsChecked = dbColumnList.length;
    return result;
}

/**
 * Format drift result ke output console untuk endpoint create.
 * Format mengikuti spec phase-01-implementation.md:
 *   [-] kolom_a  (in payload, not in database)
 *   [+] kolom_b  (in database, not in payload)
 *   [+] created_at, ... (required by auditColumns=true, not in database)
 *
 * @param {Object} comparison - Hasil compareSchemaStrict()
 * @param {Object} options
 * @param {string} options.payloadFileName - Nama file payload (untuk resolution message)
 * @param {string} options.tableName - Nama table
 * @returns {string[]} Lines untuk di-print
 */
function formatDriftReport(comparison, options = {}) {
    const lines = [];
    const payloadFile = options.payloadFileName || comparison.tableName;
    const tableName = comparison.tableName;

    lines.push('  [BLOCKED] Payload-database drift detected:');

    for (const item of comparison.removed) {
        lines.push(`    [-] ${item.column.padEnd(12)} (in payload, not in database)`);
    }

    if (Array.isArray(comparison.typeChanges)) {
        for (const item of comparison.typeChanges) {
            lines.push(`    [~] ${item.column.padEnd(12)} (type: ${item.payloadType} -> ${item.databaseType})`);
        }
    }

    for (const item of comparison.added) {
        lines.push(`    [+] ${item.column.padEnd(12)} (in database, not in payload)`);
    }

    if (comparison.auditMissing.length > 0) {
        const cols = comparison.auditMissing.join(', ');
        lines.push(`    [+] ${cols}`);
        lines.push('                     (required by auditColumns=true, not in database)');
    }

    if (Array.isArray(comparison.querySourceWarnings) && comparison.querySourceWarnings.length > 0) {
        lines.push('');
        lines.push('  Query source warnings (column resolution may be incomplete):');
        for (const w of comparison.querySourceWarnings) {
            lines.push(`    [!] ${w.source}: ${w.message}`);
        }
    }

    lines.push('');
    lines.push('  Resolution:');
    lines.push(`    1. Run: npx restforge payload sync --table=${tableName} --config=<ENV>`);
    if (comparison.auditMissing.length > 0) {
        lines.push(`    2. Add audit columns to schema OR set "auditColumns": false in payload/${payloadFile}`);
        lines.push('    3. Re-run endpoint create after resolving');
    } else {
        lines.push('    2. Tambah kolom missing ke schema untuk match dengan payload');
        lines.push('    3. Re-run endpoint create after resolving');
    }

    return lines;
}

/**
 * Resolve UNION column dari query sources (viewName, viewQuery, datatablesQuery,
 * exportQuery) untuk dipakai sebagai expanded valid column set saat drift check.
 *
 * `detailQuery` (di masterDetail.detailConfig) sengaja TIDAK di-resolve di scope
 * root karena master-detail punya `fieldName` terpisah yang di-validate di scope
 * detail sendiri.
 *
 * Behavior tolerant: query source yang gagal di-describe (SQL syntax error,
 * file tidak ditemukan, dll) dicatat sebagai warning tapi tidak abort. Drift
 * check tetap lanjut dengan UNION dari sources yang berhasil. Trade-off:
 * false-positive drift mungkin terjadi kalau query gagal, tapi user dapat
 * warning yang explicit.
 *
 * @param {Object} payload
 * @param {Object} db - DatabaseIntrospector terkoneksi
 * @param {Object} options
 * @param {string} [options.payloadDir] - Folder payload untuk resolve file: reference
 * @returns {Promise<{columns: string[], warnings: Array<{source: string, message: string}>}>}
 */
async function resolveQuerySourceColumns(payload, db, options = {}) {
    const columns = new Set();
    const warnings = [];
    const payloadDir = options.payloadDir || null;
    const tableName = payload.tableName;

    // 1. viewName: introspect sebagai table (information_schema.columns covers VIEW)
    if (typeof payload.viewName === 'string' && payload.viewName.trim().length > 0) {
        try {
            const viewCols = await db.getDetailedColumnInfo(payload.viewName.trim());
            if (Array.isArray(viewCols) && viewCols.length > 0) {
                for (const c of viewCols) columns.add(c.column_name);
            } else {
                warnings.push({
                    source: `viewName=${payload.viewName}`,
                    message: 'View not found or has no columns'
                });
            }
        } catch (err) {
            warnings.push({
                source: `viewName=${payload.viewName}`,
                message: err.message || String(err)
            });
        }
    }

    // 2. SQL query sources
    const sqlSources = [
        { key: 'viewQuery', value: payload.viewQuery },
        { key: 'datatablesQuery', value: payload.datatablesQuery },
        { key: 'exportQuery', value: payload.exportQuery }
    ];

    for (const { key, value } of sqlSources) {
        if (!value || typeof value !== 'string') continue;

        const resolved = resolveSqlSource(value, payloadDir, key);
        if (!resolved.ok) {
            warnings.push({ source: key, message: resolved.error });
            continue;
        }

        const sql = substituteQueryPlaceholders(resolved.sql, tableName);
        if (typeof db.describeQueryColumns !== 'function') {
            // Backward compat: introspector versi lama tanpa describeQueryColumns.
            // Skip silently agar tidak break caller existing.
            continue;
        }

        try {
            const describe = await db.describeQueryColumns(sql);
            if (describe.ok && Array.isArray(describe.columns)) {
                for (const c of describe.columns) columns.add(c);
            } else if (describe.error) {
                warnings.push({
                    source: key,
                    message: `${describe.error.code}: ${describe.error.message}`
                });
            }
        } catch (err) {
            warnings.push({
                source: key,
                message: err.message || String(err)
            });
        }
    }

    return {
        columns: [...columns],
        warnings
    };
}

/**
 * Resolve SQL source dari string (inline SQL atau file: reference).
 * Base path file: reference = options.payloadDir (folder payload/).
 *
 * @param {string} value - Inline SQL atau "file:relative/path.sql"
 * @param {string|null} payloadDir - Base directory untuk file: reference
 * @param {string} sourceKey - Key untuk error message context
 * @returns {{ok: boolean, sql?: string, error?: string}}
 */
function resolveSqlSource(value, payloadDir, sourceKey) {
    const trimmed = value.trim();

    if (trimmed.startsWith('file:')) {
        const relativePath = trimmed.substring(5);
        if (!payloadDir) {
            return {
                ok: false,
                error: `Cannot resolve file: reference (${relativePath}) — payloadDir not provided`
            };
        }
        const fullPath = path.join(payloadDir, relativePath);
        if (!fs.existsSync(fullPath)) {
            return {
                ok: false,
                error: `Referenced SQL file not found: ${relativePath}`
            };
        }
        try {
            const content = fs.readFileSync(fullPath, 'utf8');
            if (!content || content.trim().length === 0) {
                return {
                    ok: false,
                    error: `Referenced SQL file is empty: ${relativePath}`
                };
            }
            return { ok: true, sql: content };
        } catch (err) {
            return {
                ok: false,
                error: `Cannot read SQL file ${relativePath}: ${err.message}`
            };
        }
    }

    return { ok: true, sql: trimmed };
}

/**
 * Substitusi placeholder runtime dalam SQL sebelum describe.
 * Saat ini hanya `${tableName}` yang di-handle (konsisten dengan generator).
 *
 * @param {string} sql
 * @param {string} tableName
 * @returns {string}
 */
function substituteQueryPlaceholders(sql, tableName) {
    if (!sql || !tableName) return sql;
    return sql.replace(/\$\{tableName\}/g, tableName);
}

module.exports = {
    DEFAULT_EXCLUDED_COLUMNS,
    normalizeDatabaseType,
    normalizePayloadValidationType,
    compareSchemaStrict,
    formatDriftReport,
    resolveQuerySourceColumns,
    resolveSqlSource
};
