'use strict';

/**
 * Introspect Mapper — convert raw DB metadata into dbschema-kit IR.
 *
 * Input shape (typically produced by aggregating database-introspector calls
 * for a single table):
 *   {
 *     tableName: string,
 *     schemaName?: string|null,                               // Phase 14
 *     columns: Array<{
 *       column_name, data_type, udt_name, column_default, is_nullable,
 *       character_maximum_length, numeric_precision, numeric_scale, extra
 *     }>,
 *     primaryKey: string[],                                  // column names
 *     uniques:    Array<{ name?, columns: string[] }>,
 *     foreignKeys: Array<{
 *       name?, columns: string[],
 *       references: { schema?, table, columns: string[] },
 *       onDelete?, onUpdate?
 *     }>,
 *     indexes:    Array<{ name?, columns: string[], unique?, primary? }>,
 *     checks:     Array<{ name?, expression: string, columns?: string[] }>
 *   }
 *
 * Output: IR object compatible with the schema-printer (same shape produced
 * by `defineModel()` after `buildIR()`).
 *
 * @module lib/dbschema-kit/introspect-mapper
 */

function mapTableMetaToIR(tableMeta, dialect) {
    if (!tableMeta || typeof tableMeta !== 'object') {
        throw new Error('mapTableMetaToIR: tableMeta is required');
    }
    if (typeof tableMeta.tableName !== 'string' || tableMeta.tableName === '') {
        throw new Error('mapTableMetaToIR: tableMeta.tableName must be a non-empty string');
    }

    const dialectId = (dialect || 'postgres').toLowerCase();
    const tableName = tableMeta.tableName;
    const schemaName = (typeof tableMeta.schemaName === 'string' && tableMeta.schemaName !== '')
        ? tableMeta.schemaName
        : null;
    const qualifiedName = schemaName ? `${schemaName}.${tableName}` : tableName;
    const columns = Array.isArray(tableMeta.columns) ? tableMeta.columns : [];
    const primaryKey = Array.isArray(tableMeta.primaryKey) ? tableMeta.primaryKey.slice() : [];
    const rawUniques = Array.isArray(tableMeta.uniques) ? tableMeta.uniques : [];
    const rawForeignKeys = Array.isArray(tableMeta.foreignKeys) ? tableMeta.foreignKeys : [];
    const rawIndexes = Array.isArray(tableMeta.indexes) ? tableMeta.indexes : [];
    const rawChecks = Array.isArray(tableMeta.checks) ? tableMeta.checks : [];

    // ── Build fields from columns ────────────────────────────
    const fields = {};
    for (const col of columns) {
        if (!col || typeof col.column_name !== 'string') continue;
        fields[col.column_name] = mapColumnToField(col, dialectId);
    }

    // ── Apply NOT NULL from PK columns (PG/MySQL/Oracle all enforce this) ──
    for (const pkCol of primaryKey) {
        if (fields[pkCol]) fields[pkCol].notnull = true;
    }

    // ── Single-column PK shorthand ───────────────────────────
    if (primaryKey.length === 1 && fields[primaryKey[0]]) {
        fields[primaryKey[0]].pk = true;
    }

    // ── Uniques: split single vs composite ───────────────────
    const compositeUniques = [];
    for (const u of rawUniques) {
        if (!u || !Array.isArray(u.columns) || u.columns.length === 0) continue;
        if (u.columns.length === 1) {
            const col = u.columns[0];
            if (fields[col]) fields[col].unique = true;
            continue;
        }
        compositeUniques.push({ columns: u.columns.slice() });
    }

    // ── Indexes: drop the implicit ones backing PK / UNIQUE ──
    const explicitIndexes = filterImplicitIndexes(rawIndexes, primaryKey, rawUniques, fields);

    // ── Foreign keys → explicit relations ────────────────────
    const relations = {};
    const usedRelNames = new Set();
    for (const fk of rawForeignKeys) {
        if (!fk || !Array.isArray(fk.columns) || fk.columns.length === 0) continue;
        if (fk.columns.length > 1) {
            // Composite FK is not expressible via shorthand or current relation
            // shape — surface as a comment field on the IR for the printer.
            continue;
        }
        if (!fk.references || typeof fk.references.table !== 'string') continue;
        if (!Array.isArray(fk.references.columns) || fk.references.columns.length === 0) continue;

        const localKey = fk.columns[0];
        const refTable = fk.references.table;
        const refCol = fk.references.columns[0];
        const refSchema = (typeof fk.references.schema === 'string' && fk.references.schema !== '')
            ? fk.references.schema
            : null;

        // Cross-schema FK: when target lives in a different schema than the source,
        // emit a qualified target so cross-model-validator can resolve unambiguously.
        // Same-schema (or unknown target schema) stays unqualified for portability.
        const targetIsCrossSchema = refSchema && schemaName && refSchema !== schemaName;
        const relTarget = targetIsCrossSchema ? `${refSchema}.${refTable}` : refTable;

        const relName = pickRelationName(refTable, localKey, usedRelNames);
        usedRelNames.add(relName);

        const rel = {
            type: 'belongsTo',
            target: relTarget,
            localKey,
            references: refCol,
            _autoGenerated: false,
            _sourceField: null
        };

        // Preserve the actual DB constraint name (conname). relName above is derived
        // from the target table for SDF generation and does not match the constraint
        // stored in the database, so the apply-engine needs the real conname to emit a
        // DROP that targets an existing constraint. Internal field (prefix `_`): the
        // schema-printer whitelist (VALID_REL_FIELDS) keeps it out of SDF output.
        if (typeof fk.name === 'string' && fk.name !== '') {
            rel._dbConstraintName = fk.name;
        }

        const onDelete = mapReferentialAction(fk.onDelete);
        if (onDelete) rel.onDelete = onDelete;
        const onUpdate = mapReferentialAction(fk.onUpdate);
        if (onUpdate) rel.onUpdate = onUpdate;

        relations[relName] = rel;
    }

    // ── Checks: parse expression strings (or pass through if pre-parsed) ──
    const checks = [];
    for (const c of rawChecks) {
        if (!c) continue;
        if (typeof c.field === 'string' && typeof c.op === 'string') {
            // Already parsed shape (test fixtures may pass this).
            checks.push(Object.assign({}, c));
            continue;
        }
        if (typeof c.expression === 'string') {
            const parsed = parseCheckExpression(c.expression);
            if (parsed._unparsable) {
                checks.push({ _unparsable: true, expression: c.expression });
            } else {
                if (c.name) parsed.name = c.name;
                checks.push(parsed);
            }
        }
    }

    return {
        tableName,
        schemaName,
        qualifiedName,
        fields,
        primaryKey: primaryKey.length > 1 ? primaryKey : [],
        indexes: explicitIndexes,
        uniques: compositeUniques,
        relations,
        checks,
        _meta: {
            hasSinglePkShorthand: primaryKey.length === 1,
            hasCompositePrimaryKey: primaryKey.length > 1,
            autoGeneratedRelations: []
        }
    };
}

// ─────────────────────────────────────────────────────────────
// Column → field mapping
// ─────────────────────────────────────────────────────────────

function mapColumnToField(col, dialect) {
    const sourceType = String(col.data_type || '').toLowerCase().trim();
    const udt = String(col.udt_name || '').toLowerCase().trim();
    const length = col.character_maximum_length;
    const precision = col.numeric_precision;
    const scale = col.numeric_scale;

    const field = {};
    const mapped = mapDbTypeToFieldType(sourceType, udt, { length, precision, scale });
    Object.assign(field, mapped);

    // Nullable flag varies across drivers: PG uses 'YES'/'NO', Oracle uses 'Y'/'N'.
    const nullable = col.is_nullable;
    const isNotNull = nullable === 'NO' || nullable === 'N' || nullable === false;
    if (isNotNull) field.notnull = true;

    if (col.column_default !== null && col.column_default !== undefined && col.column_default !== '') {
        const defaultMapped = mapDefaultValue(col.column_default, mapped.type);
        if (defaultMapped === null) {
            field._defaultComment = stripDefaultCast(col.column_default);
        } else if (defaultMapped !== undefined) {
            field.default = defaultMapped;
        }
    }

    return field;
}

function mapDbTypeToFieldType(sourceType, udt, mods) {
    const t = sourceType;
    const u = udt;
    const len = mods.length;
    const prec = mods.precision;
    const scl = mods.scale;

    if (t === 'character varying' || t === 'varchar' || t === 'character' || t === 'char' || u === 'varchar') {
        if (len && len > 0) return { type: 'string', length: Number(len) };
        return { type: 'string', length: 255, _comment: `unspecified length for '${sourceType}', defaulted to 255` };
    }
    if (t === 'text' || t === 'clob' || t === 'nclob' || u === 'text') {
        return { type: 'text' };
    }
    if (t === 'uuid' || u === 'uuid') {
        return { type: 'string', length: 36 };
    }
    if (t === 'boolean' || t === 'bool' || u === 'bool') {
        return { type: 'boolean' };
    }
    if (t === 'smallint' || t === 'int2' || t === 'integer' || t === 'int' || t === 'int4' || u === 'int4' || u === 'int2') {
        return { type: 'integer' };
    }
    if (t === 'bigint' || t === 'int8' || u === 'int8') {
        return { type: 'bigint' };
    }
    if (t === 'numeric' || t === 'decimal' || t === 'number' || u === 'numeric') {
        if (prec != null && scl != null) {
            return { type: 'decimal', precision: Number(prec), scale: Number(scl) };
        }
        return { type: 'decimal', precision: 18, scale: 2, _comment: `unspecified precision/scale for '${sourceType}', defaulted to 18,2` };
    }
    if (t === 'real' || t === 'float4' || t === 'double precision' || t === 'float8' || t === 'binary_float' || t === 'binary_double') {
        return { type: 'decimal', precision: 18, scale: 6, _comment: `floating-point type '${sourceType}' approximated as decimal:18,6` };
    }
    if (t === 'date') {
        return { type: 'date' };
    }
    if (t.startsWith('timestamp') || t === 'datetime') {
        return { type: 'timestamp' };
    }
    if (t === 'json' || t === 'jsonb' || u === 'json' || u === 'jsonb') {
        return { type: 'json' };
    }

    return { type: 'text', _comment: `unsupported type '${sourceType}'` };
}

function mapDefaultValue(rawValue, fieldType) {
    if (rawValue === null || rawValue === undefined) return undefined;
    let v = String(rawValue).trim();

    // Strip trailing PostgreSQL type cast: 'active'::character varying
    v = v.replace(/::[A-Za-z0-9_\s\(\),"\[\]]+$/u, '').trim();

    // Strip surrounding parens added by some drivers (Oracle, MySQL TIMESTAMP wrappers)
    while (v.startsWith('(') && v.endsWith(')')) {
        const inner = v.slice(1, -1).trim();
        if (inner === '') break;
        v = inner;
    }

    if (v === '') return undefined;

    // Sequence-style defaults are not expressible in dbschema-kit.
    if (/^nextval\s*\(/i.test(v)) return null;
    if (/^seq_/i.test(v) && v.includes('.nextval')) return null;

    // Function defaults
    if (/^current_timestamp/i.test(v) || /^now\s*\(\s*\)/i.test(v) || /^getdate\s*\(\s*\)/i.test(v) || /^sysdate$/i.test(v) || /^systimestamp$/i.test(v)) {
        return { kind: 'function', name: 'now', args: [] };
    }
    if (/^current_date$/i.test(v)) {
        return { kind: 'constant', value: 'current_date' };
    }

    // String literal: 'value' (handle SQL-escaped '' inside)
    if (/^'.*'$/s.test(v)) {
        const inner = v.slice(1, -1).replace(/''/g, "'");
        return { kind: 'literal', value: inner };
    }

    // Boolean literals
    if (/^true$/i.test(v)) return { kind: 'literal', value: true };
    if (/^false$/i.test(v)) return { kind: 'literal', value: false };

    // MySQL TINYINT(1) booleans
    if (fieldType === 'boolean') {
        if (v === '1') return { kind: 'literal', value: true };
        if (v === '0') return { kind: 'literal', value: false };
    }

    // Numeric literal
    if (/^-?\d+(\.\d+)?$/.test(v)) {
        return { kind: 'literal', value: Number(v) };
    }

    // Function call form: name()
    if (/^[a-z_][a-z0-9_]*\s*\(\s*\)$/i.test(v)) {
        const name = v.replace(/\s*\(\s*\)$/, '').toLowerCase();
        return { kind: 'function', name, args: [] };
    }

    // Bare identifier (constant) — only allow safe identifiers
    if (/^[a-z_][a-z0-9_]*$/i.test(v)) {
        return { kind: 'constant', value: v.toLowerCase() };
    }

    return null;
}

function stripDefaultCast(rawValue) {
    return String(rawValue).trim().replace(/::[A-Za-z0-9_\s\(\),"\[\]]+$/u, '').trim();
}

// ─────────────────────────────────────────────────────────────
// Index filter (drop PK-backed and unique-backed)
// ─────────────────────────────────────────────────────────────

function filterImplicitIndexes(indexes, primaryKey, uniques, fields) {
    const pkKey = (primaryKey || []).slice().sort().join('|');
    const uniqueSets = (uniques || [])
        .filter((u) => Array.isArray(u.columns))
        .map((u) => u.columns.slice().sort().join('|'));

    const out = [];
    const seen = new Set();
    for (const idx of indexes) {
        if (!idx || !Array.isArray(idx.columns) || idx.columns.length === 0) continue;
        if (idx.primary === true) continue;

        const key = idx.columns.slice().sort().join('|');
        if (pkKey !== '' && key === pkKey) continue;
        if (uniqueSets.includes(key)) continue;
        if (seen.has(key)) continue;
        seen.add(key);

        out.push({ columns: idx.columns.slice(), composite: idx.columns.length > 1 });
    }
    return out;
}

// ─────────────────────────────────────────────────────────────
// FK helpers
// ─────────────────────────────────────────────────────────────

/**
 * Pick relation key for a belongsTo FK based on target table name.
 * Constraint name from the database is intentionally ignored — emitter prepends
 * `fk_<table>_` prefix, so reusing the constraint name causes redundant double prefix.
 *
 * Resolution order:
 *   1. Target table name (e.g. FK to `item_product` → relation key `item_product`)
 *   2. On collision: localKey with `_id` suffix dropped (e.g. `parent_id` → `parent`)
 *   3. On further collision: `<refTable>_<localKeyStripped>` combined
 *   4. Last resort: numbered suffix
 */
function pickRelationName(refTable, localKey, usedNames) {
    const tableCandidate = sanitizeIdentifier(refTable) || 'related';
    if (!usedNames.has(tableCandidate)) return tableCandidate;

    // Collision: derive from localKey, dropping trailing _id
    const fromKey = sanitizeIdentifier(localKey).replace(/_id$/, '');
    if (fromKey && fromKey !== tableCandidate && !usedNames.has(fromKey)) {
        return fromKey;
    }

    // Further collision: combine refTable + key-derived suffix
    const combined = sanitizeIdentifier(`${refTable}_${fromKey}`);
    if (combined && !usedNames.has(combined)) return combined;

    // Last resort: numbered suffix on the combined name (or table name if combined empty)
    const base = combined || tableCandidate;
    let i = 2;
    while (usedNames.has(`${base}_${i}`)) i++;
    return `${base}_${i}`;
}

function sanitizeIdentifier(name) {
    if (typeof name !== 'string') return '';
    const cleaned = name.trim().toLowerCase().replace(/[^a-z0-9_]/g, '_');
    if (!cleaned) return '';
    if (!/^[a-z_]/.test(cleaned)) return `_${cleaned}`;
    return cleaned;
}

function mapReferentialAction(value) {
    if (value === undefined || value === null || value === '') return undefined;
    const v = String(value).toUpperCase().trim();
    if (v === 'CASCADE') return 'cascade';
    if (v === 'SET NULL') return 'setNull';
    if (v === 'RESTRICT') return 'restrict';
    if (v === 'NO ACTION') return 'restrict';
    if (v === 'SET DEFAULT') return 'restrict';
    return undefined;
}

// ─────────────────────────────────────────────────────────────
// CHECK expression parser
// ─────────────────────────────────────────────────────────────

function parseCheckExpression(rawExpr) {
    let e = String(rawExpr || '').trim();

    // Drop leading "CHECK" keyword if present
    e = e.replace(/^CHECK\s*/i, '').trim();

    // Strip a single layer of outer parens, repeatedly, only when balanced.
    while (e.startsWith('(') && e.endsWith(')') && parensBalancedOuter(e)) {
        e = e.slice(1, -1).trim();
    }

    // IN-list:  col IN ('a', 'b', ...)  or  "COL" IN (1, 2)
    const inMatch = e.match(/^"?([A-Za-z_][A-Za-z0-9_]*)"?\s+IN\s*\(([^)]*)\)$/i);
    if (inMatch) {
        const field = inMatch[1].toLowerCase();
        const values = parseValueList(inMatch[2]);
        if (values) return { field, op: 'in', value: values };
    }

    // Comparison:  col >= 0   col != 5   col = 'x'
    const cmpMatch = e.match(/^"?([A-Za-z_][A-Za-z0-9_]*)"?\s*(>=|<=|<>|!=|=|>|<)\s*(.+)$/);
    if (cmpMatch) {
        const field = cmpMatch[1].toLowerCase();
        const opSym = cmpMatch[2];
        const rhs = cmpMatch[3].trim();
        const opMap = { '>': 'gt', '>=': 'gte', '<': 'lt', '<=': 'lte', '=': 'eq', '!=': 'neq', '<>': 'neq' };
        const op = opMap[opSym];
        if (op) {
            // gt/gte/lt/lte require numeric value
            if (op === 'eq' || op === 'neq') {
                const literal = parseSingleLiteral(rhs);
                if (literal !== undefined) return { field, op, value: literal };
            } else {
                const numMatch = rhs.match(/^-?\d+(\.\d+)?$/);
                if (numMatch) return { field, op, value: Number(rhs) };
            }
        }
    }

    return { _unparsable: true, expression: rawExpr };
}

function parensBalancedOuter(s) {
    let depth = 0;
    for (let i = 0; i < s.length; i++) {
        const ch = s[i];
        if (ch === '(') depth++;
        else if (ch === ')') {
            depth--;
            if (depth === 0 && i < s.length - 1) return false;
        }
    }
    return depth === 0;
}

function parseValueList(text) {
    // Tokenise on commas while respecting single-quoted strings.
    const parts = [];
    let buf = '';
    let inQuote = false;
    for (let i = 0; i < text.length; i++) {
        const ch = text[i];
        if (ch === "'") {
            inQuote = !inQuote;
            buf += ch;
            continue;
        }
        if (ch === ',' && !inQuote) {
            parts.push(buf.trim());
            buf = '';
            continue;
        }
        buf += ch;
    }
    if (buf.trim() !== '') parts.push(buf.trim());

    const out = [];
    for (const p of parts) {
        const lit = parseSingleLiteral(p);
        if (lit === undefined) return null;
        out.push(lit);
    }
    return out.length > 0 ? out : null;
}

function parseSingleLiteral(text) {
    const t = text.trim();
    if (/^'.*'$/.test(t)) return t.slice(1, -1).replace(/''/g, "'");
    if (/^-?\d+(\.\d+)?$/.test(t)) return Number(t);
    if (/^true$/i.test(t)) return true;
    if (/^false$/i.test(t)) return false;
    return undefined;
}

module.exports = { mapTableMetaToIR };
