'use strict';

/**
 * Contract: schema apply
 *
 * Apply drift dari SDF ke database via ALTER TABLE incremental.
 * Komplemen schema diff (detect only) dan schema migrate (full create/drop).
 *
 * Default: additive only (ADD COLUMN/INDEX/UNIQUE). Drift onlyInDb atau
 * mismatched di-skip dengan warning kecuali opt-in flag aktif.
 *
 * Exit code:
 *   0 - semua drift applicable ter-apply (atau no drift)
 *   1 - ada drift yang di-skip karena butuh opt-in (--allow-drop / --allow-modify)
 *   2 - error
 */

const fs = require('fs');
const os = require('os');
const path = require('path');

const { loadSchemaPath } = require('../../lib/dbschema-kit/loader');
const { mapTableMetaToIR } = require('../../lib/dbschema-kit/introspect-mapper');
const { loadConfig } = require('../../lib/dbschema-kit/connection');
const { diffModels } = require('../../lib/dbschema-kit/diff-engine');
const { generateAlterStatements } = require('../../lib/dbschema-kit/apply-engine');
const { resolveConfig, printDefaultConfigWarning } = require('../../lib/utils/config-resolver');

function loadIntrospector() {
    const stubPath = process.env.DBSCHEMA_KIT_TEST_INTROSPECT_STUB;
    if (stubPath) {
        return require(path.resolve(stubPath));
    }
    return defaultIntrospector;
}

function loadApplyExecutor() {
    const stubPath = process.env.DBSCHEMA_KIT_TEST_APPLY_STUB;
    if (stubPath) {
        return require(path.resolve(stubPath));
    }
    return require('../../lib/dbschema-kit/apply-executor');
}

function mapDialectToDbType(dialect) {
    switch (dialect) {
        case 'postgres': return 'postgresql';
        case 'mysql': return 'mysql';
        case 'oracle': return 'oracle';
        case 'sqlite': return 'sqlite';
        default: return dialect;
    }
}

function buildEphemeralEnvFile(config, dbType) {
    const lines = [`DB_TYPE=${dbType}`];
    if (config.host !== undefined) lines.push(`DB_HOST=${config.host}`);
    if (config.port !== undefined) lines.push(`DB_PORT=${config.port}`);
    if (dbType === 'oracle') {
        lines.push(`DB_NAME=${config.serviceName || ''}`);
    } else if (config.database !== undefined) {
        lines.push(`DB_NAME=${config.database}`);
    }
    if (config.user !== undefined) lines.push(`DB_USER=${config.user}`);
    if (config.password !== undefined) lines.push(`DB_PASSWORD=${config.password}`);
    return lines.join('\n') + '\n';
}

function createTmpEnvFile(content) {
    const tmpDir = os.tmpdir();
    const tmpName = `restforge-apply-${process.pid}-${Date.now()}.env`;
    const fullPath = path.join(tmpDir, tmpName);
    fs.writeFileSync(fullPath, content, 'utf8');
    return fullPath;
}

async function collectTableMeta(introspector, target) {
    const ref = target.schemaName ? `${target.schemaName}.${target.tableName}` : target.tableName;
    const detailedColumns = await introspector.getDetailedColumnInfo(ref);
    const constraints = await introspector.getConstraints(ref);
    const foreignKeys = await introspector.getForeignKeys(ref);
    const indexes = await introspector.getIndexes(ref);
    const checks = await introspector.getCheckConstraints(ref);

    const pkConstraint = constraints.find((c) => c.type === 'PRIMARY KEY');
    const uniqueConstraints = constraints
        .filter((c) => c.type === 'UNIQUE')
        .map((c) => ({ name: c.name, columns: c.columns }));

    return {
        tableName: target.tableName,
        schemaName: target.schemaName || null,
        columns: detailedColumns,
        primaryKey: pkConstraint ? pkConstraint.columns : [],
        uniques: uniqueConstraints,
        foreignKeys,
        indexes,
        // introspect-mapper expects { name, expression }; getCheckConstraints
        // returns { name, clause } (D5 wiring, Phase 03).
        checks: Array.isArray(checks)
            ? checks.map((c) => ({ name: c.name, expression: c.clause }))
            : []
    };
}

const defaultIntrospector = {
    introspect: async ({ config, tables }) => {
        const dbType = mapDialectToDbType(config.dialect);
        if (dbType === 'sqlite') {
            throw new Error('schema:apply does not yet support sqlite. Use postgres, mysql, or oracle.');
        }

        const { DatabaseIntrospector } = require('../../lib/utils/database-introspector');
        const introspector = new DatabaseIntrospector({ quiet: true });

        const ephemeralEnv = buildEphemeralEnvFile(config, dbType);
        const tmpPath = createTmpEnvFile(ephemeralEnv);

        try {
            const ok = introspector.loadConfig(tmpPath);
            if (!ok) throw new Error('Failed to load config into DatabaseIntrospector');
            const connected = await introspector.connect();
            if (!connected) throw new Error('Failed to connect to database');

            const out = [];
            for (const target of tables) {
                try {
                    out.push(await collectTableMeta(introspector, target));
                } catch (err) {
                    out.push({
                        tableName: target.tableName,
                        schemaName: target.schemaName || null,
                        columns: [],
                        primaryKey: [],
                        uniques: [],
                        foreignKeys: [],
                        indexes: [],
                        checks: [],
                        _missing: true,
                        _error: err && err.message ? err.message : String(err)
                    });
                }
            }
            return { tables: out };
        } finally {
            try { await introspector.disconnect(); } catch (e) { /* ignore */ }
            try { fs.unlinkSync(tmpPath); } catch (e) { /* ignore */ }
        }
    }
};

function buildConnectionSummary(config) {
    if (config.dialect === 'sqlite') {
        return `sqlite @ ${config.file}`;
    }
    if (config.dialect === 'oracle') {
        return `oracle @ ${config.host}:${config.port}/${config.serviceName}`;
    }
    return `${config.dialect} @ ${config.host}:${config.port}/${config.database}`;
}

function formatStatementForDisplay(stmt, dialect) {
    if (dialect === 'oracle' && /^\s*BEGIN\b/i.test(stmt)) {
        return stmt + '\n/';
    }
    return stmt + ';';
}

function printDryRun(result, dialect, outStream) {
    const out = outStream || process.stdout;
    out.write('-- DDL Preview (schema apply, dry-run) --\n');
    out.write('\n');

    const tables = Object.keys(result.statementsByTable);
    for (const table of tables) {
        out.write(`[${table}]\n`);
        for (const stmt of result.statementsByTable[table]) {
            out.write(formatStatementForDisplay(stmt, dialect));
            out.write('\n');
        }
        out.write('\n');
    }

    if (tables.length === 0) {
        out.write('No applicable ALTER statements.\n');
        out.write('\n');
    }

    if (result.skipped.length > 0) {
        out.write('Warnings:\n');
        for (const sk of result.skipped) {
            out.write(`  ${sk.description} (${sk.reason})\n`);
        }
        out.write('\n');
    }

    out.write('Summary:\n');
    out.write(`  Tables affected:    ${result.summary.tablesAffected}\n`);
    out.write(`  Additions:          ${result.summary.totalAdditions}\n`);
    out.write(`  Modifications:      ${result.summary.totalModifications}\n`);
    out.write(`  Deletions:          ${result.summary.totalDeletions}\n`);
    out.write(`  Skipped (opt-in):   ${result.summary.totalSkipped}\n`);
    out.write('\n');
    out.write('Dry-run complete. No changes applied.\n');
}

function buildExitCode(result) {
    const hasOptInSkip = result.skipped.some((s) =>
        s.reason === 'requires --allow-drop' || s.reason === 'requires --allow-modify'
    );
    return hasOptInSkip ? 1 : 0;
}

module.exports = {
    resource: 'schema',
    verb: 'apply',
    description: 'Apply drift dari SDF ke database via ALTER TABLE incremental (komplemen schema diff)',
    category: 'management',
    flags: {
        path: {
            type: 'string',
            required: true,
            description: 'Path file atau folder schema (mis. ./schema atau ./schema/users.js)'
        },
        config: {
            type: 'string',
            required: false,
            default: null,
            description: 'File config database (.env). Fallback ke `.restforge/defaults.json` bila tidak disediakan eksplisit (set via `config set-default`)'
        },
        table: {
            type: 'string',
            required: false,
            default: null,
            description: 'Apply hanya satu tabel spesifik (default: semua model di SDF)'
        },
        'dry-run': {
            type: 'boolean',
            required: false,
            default: false,
            description: 'Preview ALTER tanpa apply ke database'
        },
        'allow-drop': {
            type: 'boolean',
            required: false,
            default: false,
            description: 'Opt-in: izinkan DROP COLUMN/INDEX/UNIQUE constraint (destruktif data)'
        },
        'allow-modify': {
            type: 'boolean',
            required: false,
            default: false,
            description: 'Opt-in: izinkan ALTER COLUMN length/nullable (potential data loss)'
        }
    },
    examples: [
        'npx restforge schema apply --path=./schema --config=db.env --dry-run',
        'npx restforge schema apply --path=./schema --config=db.env',
        'npx restforge schema apply --path=./schema --config=db.env --allow-drop',
        'npx restforge schema apply --path=./schema/visitors.js --config=db.env --table=visitors --dry-run'
    ],
    async handler(args) {
        // 1. Resolve config
        const resolvedConfigResult = resolveConfig(args.config, process.cwd());
        if (!resolvedConfigResult) {
            console.error('Error: --config=<file> is required.');
            console.error("Tip: set a default with 'npx restforge config set-default --config=<file>' to omit --config in future runs");
            const err = new Error('--config=<file> is required');
            err.exitCode = 2;
            throw err;
        }
        if (resolvedConfigResult.source === 'default') {
            printDefaultConfigWarning(resolvedConfigResult.defaultName);
        }

        let config;
        try {
            config = loadConfig(resolvedConfigResult.path);
        } catch (err) {
            console.error(`Error: ${err.message}`);
            const e = new Error(err.message);
            e.exitCode = 2;
            throw e;
        }

        // 2. Load SDF
        const schemaPath = args.path;
        const absPath = path.resolve(process.cwd(), schemaPath);

        let sdfModels;
        try {
            sdfModels = loadSchemaPath(absPath);
        } catch (err) {
            console.error(`Error: ${err.message}`);
            const e = new Error(err.message);
            e.exitCode = 2;
            throw e;
        }

        if (sdfModels.size === 0) {
            console.error(`Error: No schema models found at '${schemaPath}'.`);
            const e = new Error(`No schema models found at '${schemaPath}'`);
            e.exitCode = 2;
            throw e;
        }

        // 3. Filter target tables
        let targetEntries = Array.from(sdfModels.entries());
        if (args.table) {
            targetEntries = targetEntries.filter(([qualified, ir]) => {
                return ir.tableName === args.table || qualified === args.table;
            });
            if (targetEntries.length === 0) {
                console.error(`Error: Table '${args.table}' not found in SDF.`);
                const e = new Error(`Table '${args.table}' not found in SDF`);
                e.exitCode = 2;
                throw e;
            }
        }

        const targetsForDb = targetEntries.map(([_, ir]) => ({
            tableName: ir.tableName,
            schemaName: ir.schemaName || null
        }));

        // 4. Introspect database
        let introspector;
        try {
            introspector = loadIntrospector();
        } catch (err) {
            console.error(`Error: Failed to load introspector: ${err.message}`);
            const e = new Error(err.message);
            e.exitCode = 2;
            throw e;
        }

        let introspectResult;
        try {
            introspectResult = await introspector.introspect({ config, tables: targetsForDb });
        } catch (err) {
            const message = err && err.message ? err.message : String(err);
            console.error(`Error: ${message}`);
            const e = new Error(message);
            e.exitCode = 2;
            throw e;
        }

        if (!introspectResult || !Array.isArray(introspectResult.tables)) {
            console.error('Error: Introspector did not return a tables array.');
            const e = new Error('Introspector did not return a tables array');
            e.exitCode = 2;
            throw e;
        }

        // 5. Map DB meta to IR
        const dialect = config.dialect;
        const dbModels = new Map();
        for (const tableMeta of introspectResult.tables) {
            if (tableMeta._missing) {
                const qualified = tableMeta.schemaName
                    ? `${tableMeta.schemaName}.${tableMeta.tableName}`
                    : tableMeta.tableName;
                dbModels.set(qualified, {
                    tableName: tableMeta.tableName,
                    schemaName: tableMeta.schemaName || null,
                    qualifiedName: qualified,
                    fields: {},
                    primaryKey: [],
                    indexes: [],
                    uniques: [],
                    relations: {},
                    checks: []
                });
                continue;
            }
            const ir = mapTableMetaToIR(tableMeta, dialect);
            dbModels.set(ir.qualifiedName || ir.tableName, ir);
        }

        // 6. Build SDF map matching key convention
        const sdfMap = new Map();
        for (const [_, ir] of targetEntries) {
            sdfMap.set(ir.qualifiedName || ir.tableName, ir);
        }

        // 7. Compute deltas via diff-engine
        const deltas = diffModels(sdfMap, dbModels);

        // 8. Generate ALTER statements
        let alterResult;
        try {
            alterResult = generateAlterStatements(deltas, {
                dialect,
                allowDrop: args['allow-drop'] === true,
                allowModify: args['allow-modify'] === true,
                sdfModels: sdfMap
            });
        } catch (err) {
            console.error(`Error: ${err.message}`);
            const e = new Error(err.message);
            e.exitCode = 2;
            throw e;
        }

        // 9. Dry-run path
        if (args['dry-run']) {
            printDryRun(alterResult, dialect);
            const exitCode = buildExitCode(alterResult);
            process.stdout.write(`Exit code: ${exitCode}\n`);
            if (exitCode !== 0) {
                const err = new Error(`schema apply dry-run: ${alterResult.summary.totalSkipped} drift entries require opt-in`);
                err.exitCode = exitCode;
                throw err;
            }
            return;
        }

        // 10. No-op path
        if (alterResult.statements.length === 0) {
            const exitCode = buildExitCode(alterResult);
            if (alterResult.skipped.length === 0) {
                console.log('No drift to apply.');
            } else {
                console.log('No applicable ALTER statements.');
                console.log('Warnings:');
                for (const sk of alterResult.skipped) {
                    console.log(`  ${sk.description} (${sk.reason})`);
                }
            }
            if (exitCode !== 0) {
                const err = new Error(`schema apply: ${alterResult.summary.totalSkipped} drift entries require opt-in`);
                err.exitCode = exitCode;
                throw err;
            }
            return;
        }

        // 11. Apply path
        console.log(`Connecting to database (${buildConnectionSummary(config)})...`);
        console.log(`Applying ${alterResult.statements.length} ALTER statement(s)...`);
        console.log('');

        const tables = Object.keys(alterResult.statementsByTable);
        const tableHeaderPrinted = new Set();

        function progressHandler({ index, total, statement, status, error }) {
            if (status === 'running') return;
            const entry = alterResult.statements[index - 1];
            const tableLabel = entry ? entry.table : '?';
            if (!tableHeaderPrinted.has(tableLabel)) {
                console.log(`[${tableLabel}]`);
                tableHeaderPrinted.add(tableLabel);
            }
            if (status === 'success') {
                console.log(`  ✓ ${statement};`);
                return;
            }
            if (status === 'error') {
                console.error(`  ✗ ${statement};`);
                const message = error && error.message ? error.message : String(error);
                console.error(`    Error: ${message}`);
            }
        }

        let executor;
        try {
            executor = loadApplyExecutor();
        } catch (err) {
            console.error(`Error: Failed to load apply executor: ${err.message}`);
            const e = new Error(err.message);
            e.exitCode = 2;
            throw e;
        }

        const sqlStatements = alterResult.statements.map((s) => s.sql);

        let applyResult;
        try {
            applyResult = await executor.applyStatements({
                statements: sqlStatements,
                dialect,
                config,
                onProgress: progressHandler
            });
        } catch (err) {
            if (err && err.status === 'ROLLBACK') {
                console.error('');
                console.error('ROLLBACK applied. Database state unchanged.');
                console.error(`  Statements applied before rollback: ${err.applied || 0}`);
                console.error(`  Failed statement index: ${err.failedIndex}`);
                const e = new Error(err.message || 'ROLLBACK applied');
                e.exitCode = 2;
                throw e;
            }
            const message = err && err.message ? err.message : String(err);
            console.error(`Error: ${message}`);
            const e = new Error(message);
            e.exitCode = 2;
            throw e;
        }

        console.log('');
        if (applyResult && applyResult.status === 'SUCCESS') {
            console.log('Summary:');
            console.log(`  ${applyResult.applied} statement(s) applied successfully in ${applyResult.durationMs}ms.`);

            for (const sk of alterResult.skipped) {
                console.log(`  ⚠ Skipped: ${sk.target} (${sk.reason})`);
            }

            const exitCode = buildExitCode(alterResult);
            if (exitCode !== 0) {
                const err = new Error(`schema apply: ${alterResult.summary.totalSkipped} drift entries require opt-in`);
                err.exitCode = exitCode;
                throw err;
            }
            return;
        }

        if (applyResult && applyResult.status === 'PARTIAL') {
            console.error('Partial apply detected:');
            console.error(`  ${applyResult.applied || 0} applied`);
            console.error(`  ${applyResult.failed || 1} failed`);
            const err = new Error('Partial apply detected. Manual cleanup may be required.');
            err.exitCode = 2;
            throw err;
        }

        console.log(`Status: ${applyResult ? applyResult.status : 'UNKNOWN'}`);
    }
};
