'use strict';

/**
 * Diff Engine — compare dua IR (Intermediate Representation) dari dbschema-kit
 * dan hasilkan structured delta. Operasi bersifat semantic-level, bukan
 * text-level: nama field, tipe ter-normalisasi, length, nullability, primary
 * key, index, unique constraint, dan foreign key di-compare langsung.
 *
 * Input IR diharapkan kompatibel dengan output `defineModel()` (SDF) maupun
 * `mapTableMetaToIR()` (database introspection). Diff engine tidak melakukan
 * I/O dan tidak memodifikasi input.
 *
 * @module lib/dbschema-kit/diff-engine
 */

// ─────────────────────────────────────────────────────────────
// Type normalization (alias mapping per dialect)
// ─────────────────────────────────────────────────────────────

// Mapping kanonikal dari semua alias type (SDF + DB) ke representasi
// internal yang dipakai saat compare. Mapping ini intentionally konservatif:
// hanya alias yang umum di-emit oleh dialect driver yang di-cover.
const TYPE_ALIAS_MAP = {
    // String varieties
    'string': 'string',
    'varchar': 'string',
    'character varying': 'string',
    'character': 'string',
    'char': 'string',
    'nvarchar': 'string',
    'nchar': 'string',
    // Text
    'text': 'text',
    'clob': 'text',
    'nclob': 'text',
    'mediumtext': 'text',
    'longtext': 'text',
    // Integer
    'integer': 'integer',
    'int': 'integer',
    'int4': 'integer',
    'int2': 'integer',
    'smallint': 'integer',
    'mediumint': 'integer',
    // Bigint
    'bigint': 'bigint',
    'int8': 'bigint',
    // Decimal / numeric
    'decimal': 'decimal',
    'numeric': 'decimal',
    'number': 'decimal',
    // Boolean
    'boolean': 'boolean',
    'bool': 'boolean',
    'tinyint(1)': 'boolean',
    // Date / time
    'date': 'date',
    'timestamp': 'timestamp',
    'timestamp without time zone': 'timestamp',
    'timestamp with time zone': 'timestamp',
    'timestamptz': 'timestamp',
    'datetime': 'timestamp',
    // UUID
    'uuid': 'uuid',
    // JSON
    'json': 'json',
    'jsonb': 'json'
};

function normalizeType(rawType) {
    if (rawType === undefined || rawType === null) return null;
    const key = String(rawType).toLowerCase().trim();
    if (TYPE_ALIAS_MAP[key]) return TYPE_ALIAS_MAP[key];
    return key;
}

// ─────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────

function isPlainObject(v) {
    return v !== null && typeof v === 'object' && !Array.isArray(v);
}

function arrayEquals(a, b) {
    if (!Array.isArray(a) || !Array.isArray(b)) return false;
    if (a.length !== b.length) return false;
    for (let i = 0; i < a.length; i++) {
        if (a[i] !== b[i]) return false;
    }
    return true;
}

function sortedCopy(arr) {
    return Array.isArray(arr) ? arr.slice().sort() : [];
}

function isNullable(field) {
    // notnull=true berarti tidak nullable; notnull=undefined berarti default nullable.
    return field && field.notnull === true ? false : true;
}

function resolvePrimaryKey(ir) {
    // IR convention: primaryKey array hanya terisi untuk composite PK.
    // Single PK ditandai via fields[name].pk = true.
    if (!ir || !isPlainObject(ir)) return [];
    if (Array.isArray(ir.primaryKey) && ir.primaryKey.length > 0) {
        return ir.primaryKey.slice();
    }
    const out = [];
    const fields = isPlainObject(ir.fields) ? ir.fields : {};
    for (const [name, field] of Object.entries(fields)) {
        if (field && field.pk === true) out.push(name);
    }
    return out;
}

// ─────────────────────────────────────────────────────────────
// Field comparison
// ─────────────────────────────────────────────────────────────

function buildFieldSummary(name, field) {
    const out = { name };
    if (field) {
        if (field.type !== undefined) out.type = field.type;
        if (field.length !== undefined) out.length = field.length;
        if (field.precision !== undefined) out.precision = field.precision;
        if (field.scale !== undefined) out.scale = field.scale;
        out.nullable = isNullable(field);
    } else {
        out.nullable = true;
    }
    return out;
}

function compareFieldDetails(sdfField, dbField) {
    const reasons = [];

    const sdfType = normalizeType(sdfField && sdfField.type);
    const dbType = normalizeType(dbField && dbField.type);
    if (sdfType !== dbType) {
        reasons.push(`type: SDF='${sdfField && sdfField.type}' vs DB='${dbField && dbField.type}'`);
    }

    // Length: relevan hanya untuk type=string. Untuk type lain (text/integer/dll.)
    // length umumnya tidak digunakan dan diabaikan agar tidak false-positive.
    if (sdfType === 'string' && dbType === 'string') {
        const sdfLen = sdfField && sdfField.length;
        const dbLen = dbField && dbField.length;
        if (sdfLen !== undefined && dbLen !== undefined && Number(sdfLen) !== Number(dbLen)) {
            reasons.push(`length: SDF=${sdfLen} vs DB=${dbLen}`);
        }
    }

    // Precision / scale untuk decimal.
    if (sdfType === 'decimal' && dbType === 'decimal') {
        const sdfP = sdfField && sdfField.precision;
        const dbP = dbField && dbField.precision;
        if (sdfP !== undefined && dbP !== undefined && Number(sdfP) !== Number(dbP)) {
            reasons.push(`precision: SDF=${sdfP} vs DB=${dbP}`);
        }
        const sdfS = sdfField && sdfField.scale;
        const dbS = dbField && dbField.scale;
        if (sdfS !== undefined && dbS !== undefined && Number(sdfS) !== Number(dbS)) {
            reasons.push(`scale: SDF=${sdfS} vs DB=${dbS}`);
        }
    }

    const sdfNullable = isNullable(sdfField);
    const dbNullable = isNullable(dbField);
    if (sdfNullable !== dbNullable) {
        reasons.push(`nullable: SDF=${sdfNullable} vs DB=${dbNullable}`);
    }

    const defaultReason = compareDefaultValue(sdfField, dbField);
    if (defaultReason) reasons.push(defaultReason);

    return reasons;
}

// ─────────────────────────────────────────────────────────────
// Default value comparison
// ─────────────────────────────────────────────────────────────

// Kanonikal payload dari struct default { kind, value, name, args } untuk
// strict compare. Output adalah string yang stable (deterministic) sehingga
// equality check cukup dengan ===.
function canonicalDefaultPayload(def) {
    if (!def || typeof def !== 'object') return '';
    if (def.kind === 'literal') {
        // JSON.stringify menjaga distingsi tipe (string vs number vs boolean).
        return JSON.stringify(def.value);
    }
    if (def.kind === 'function') {
        const name = String(def.name || '').toLowerCase();
        const args = Array.isArray(def.args) ? def.args : [];
        return `${name}(${args.join(',')})`;
    }
    if (def.kind === 'constant') {
        return String(def.value == null ? '' : def.value).toLowerCase();
    }
    return '';
}

// Weak normalization untuk _defaultComment dan fallback string compare.
// Whitespace di-collapse, lowercase, lalu trim agar variasi cosmetic tidak
// memicu false-positive.
function normalizeWeakDefault(str) {
    return String(str == null ? '' : str).toLowerCase().replace(/\s+/g, ' ').trim();
}

function describeDefault(def, comment) {
    if (def && typeof def === 'object') {
        return canonicalDefaultPayload(def);
    }
    if (comment) return `(comment) ${normalizeWeakDefault(comment)}`;
    return '(none)';
}

function compareDefaultValue(sdfField, dbField) {
    const sdfDef = sdfField && sdfField.default;
    const dbDef = dbField && dbField.default;
    const sdfComment = sdfField && sdfField._defaultComment;
    const dbComment = dbField && dbField._defaultComment;

    const sdfHasStruct = sdfDef !== undefined && sdfDef !== null;
    const dbHasStruct = dbDef !== undefined && dbDef !== null;
    const sdfHasComment = !!sdfComment;
    const dbHasComment = !!dbComment;

    // Case 1: keduanya tidak punya default sama sekali → match.
    if (!sdfHasStruct && !dbHasStruct && !sdfHasComment && !dbHasComment) {
        return null;
    }

    // Case 2: salah satu sisi punya signal default (struct atau comment),
    // sisi lain kosong total → drift.
    const sdfHasAny = sdfHasStruct || sdfHasComment;
    const dbHasAny = dbHasStruct || dbHasComment;
    if (sdfHasAny !== dbHasAny) {
        return `default: SDF=${describeDefault(sdfDef, sdfComment)} vs DB=${describeDefault(dbDef, dbComment)}`;
    }

    // Case 3: keduanya punya struktur kanonikal → strict compare kind + value.
    if (sdfHasStruct && dbHasStruct) {
        if (sdfDef.kind !== dbDef.kind) {
            return `default kind: SDF='${sdfDef.kind}' vs DB='${dbDef.kind}'`;
        }
        const sdfPayload = canonicalDefaultPayload(sdfDef);
        const dbPayload = canonicalDefaultPayload(dbDef);
        if (sdfPayload !== dbPayload) {
            return `default: SDF=${sdfPayload} vs DB=${dbPayload}`;
        }
        return null;
    }

    // Case 4: minimal salah satu sisi hanya punya _defaultComment (atau struktur
    // di satu sisi, comment di sisi lain). Fallback ke weak compare.
    const sdfStr = normalizeWeakDefault(sdfHasStruct ? canonicalDefaultPayload(sdfDef) : sdfComment);
    const dbStr = normalizeWeakDefault(dbHasStruct ? canonicalDefaultPayload(dbDef) : dbComment);
    if (sdfStr !== dbStr) {
        return `default (weak): SDF='${sdfStr}' vs DB='${dbStr}'`;
    }
    return null;
}

function diffFields(sdfFields, dbFields) {
    const sdf = isPlainObject(sdfFields) ? sdfFields : {};
    const db = isPlainObject(dbFields) ? dbFields : {};

    const sdfNames = Object.keys(sdf);
    const dbNames = Object.keys(db);

    const onlyInSdf = [];
    const onlyInDb = [];
    const mismatched = [];

    for (const name of sdfNames) {
        if (!(name in db)) {
            onlyInSdf.push(buildFieldSummary(name, sdf[name]));
        }
    }

    for (const name of dbNames) {
        if (!(name in sdf)) {
            onlyInDb.push(buildFieldSummary(name, db[name]));
        }
    }

    for (const name of sdfNames) {
        if (!(name in db)) continue;
        const reasons = compareFieldDetails(sdf[name], db[name]);
        if (reasons.length > 0) {
            mismatched.push({
                name,
                sdf: buildFieldSummary(name, sdf[name]),
                db: buildFieldSummary(name, db[name]),
                reasons
            });
        }
    }

    return { onlyInSdf, onlyInDb, mismatched };
}

// ─────────────────────────────────────────────────────────────
// Primary key comparison
// ─────────────────────────────────────────────────────────────

function diffPrimaryKey(sdfIR, dbIR) {
    const sdf = resolvePrimaryKey(sdfIR);
    const db = resolvePrimaryKey(dbIR);
    // PK match jika kolom sama persis (order-sensitive: PK order matters di SQL).
    const match = arrayEquals(sdf, db);
    return { sdf, db, match };
}

// ─────────────────────────────────────────────────────────────
// Collection diff (indexes, uniques, foreign keys)
// ─────────────────────────────────────────────────────────────

// Build map dari kolom set kanonikal. Bila item punya nama dan unik, name
// digunakan sebagai key; selain itu dipakai gabungan kolom yang ter-sort agar
// stable order columns tidak dianggap berbeda.
function buildItemMap(items, getKey) {
    const map = new Map();
    if (!Array.isArray(items)) return map;
    for (const item of items) {
        if (!item) continue;
        const key = getKey(item);
        if (!key) continue;
        if (!map.has(key)) map.set(key, item);
    }
    return map;
}

function columnsKey(item) {
    if (!item || !Array.isArray(item.columns)) return null;
    return item.columns.slice().sort().join('|');
}

function indexKey(item) {
    return columnsKey(item);
}

function diffIndexes(sdfIndexes, dbIndexes) {
    const sdfMap = buildItemMap(sdfIndexes, indexKey);
    const dbMap = buildItemMap(dbIndexes, indexKey);

    const onlyInSdf = [];
    const onlyInDb = [];
    const mismatched = [];

    for (const [key, item] of sdfMap.entries()) {
        if (!dbMap.has(key)) {
            onlyInSdf.push({ columns: Array.isArray(item.columns) ? item.columns.slice() : [] });
        }
    }
    for (const [key, item] of dbMap.entries()) {
        if (!sdfMap.has(key)) {
            onlyInDb.push({ columns: Array.isArray(item.columns) ? item.columns.slice() : [] });
        }
    }
    // Mismatch: same column-set tapi order berbeda. MVP-nya kita treat
    // matching column-set sebagai match dan tidak menandai order sebagai drift,
    // karena index column order biasanya ditentukan oleh dialect optimizer.
    // (Kebijakan ini dapat di-revisi fase berikutnya.)

    return { onlyInSdf, onlyInDb, mismatched };
}

function diffUniques(sdfUniques, dbUniques) {
    // Strategi sama dengan index: match by column-set kanonikal.
    const sdfMap = buildItemMap(sdfUniques, columnsKey);
    const dbMap = buildItemMap(dbUniques, columnsKey);

    const onlyInSdf = [];
    const onlyInDb = [];
    const mismatched = [];

    for (const [key, item] of sdfMap.entries()) {
        if (!dbMap.has(key)) {
            onlyInSdf.push({ columns: Array.isArray(item.columns) ? item.columns.slice() : [] });
        }
    }
    for (const [key, item] of dbMap.entries()) {
        if (!sdfMap.has(key)) {
            onlyInDb.push({ columns: Array.isArray(item.columns) ? item.columns.slice() : [] });
        }
    }

    return { onlyInSdf, onlyInDb, mismatched };
}

// Foreign key normalization: ambil triplet (localKey, target, references) plus
// onDelete/onUpdate canonical. introspect-mapper.mapReferentialAction() sudah
// menormalisasi raw DB action ke canonical (cascade/setNull/restrict), dan SDF
// menyimpan canonical lewat ir-builder.normalizeRelation(). Triplet dipakai
// untuk FK matching; action di-compare terpisah di compareFkActions().
function extractForeignKeys(ir) {
    const out = [];
    if (!ir || !isPlainObject(ir)) return out;
    const relations = isPlainObject(ir.relations) ? ir.relations : {};
    for (const [name, rel] of Object.entries(relations)) {
        if (!rel || rel.type !== 'belongsTo') continue;
        if (!rel.localKey || !rel.target) continue;
        out.push({
            name,
            localKey: rel.localKey,
            target: stripSchemaPrefix(rel.target),
            references: rel.references || null,
            onDelete: rel.onDelete,
            onUpdate: rel.onUpdate,
            // Carry the actual DB constraint name (introspect-mapper sets it on the
            // rel) past this whitelist so DROP/MODIFY can target the real conname.
            // Undefined for SDF-side FKs, which never emit a DROP.
            _dbConstraintName: rel._dbConstraintName
        });
    }
    return out;
}

function stripSchemaPrefix(name) {
    if (typeof name !== 'string') return name;
    const dot = name.indexOf('.');
    if (dot === -1) return name;
    return name.slice(dot + 1);
}

function fkKey(item) {
    if (!item) return null;
    return `${item.localKey}->${item.target}.${item.references || ''}`;
}

// SQL standard: FK tanpa ON DELETE/UPDATE eksplisit berperilaku seperti NO ACTION
// (alias dari RESTRICT di kebanyakan dialect dan di canonical map kita).
// Normalisasi ini wajib agar SDF yang skip declare action tidak dianggap drift
// terhadap DB yang punya RESTRICT eksplisit.
function normalizeFkAction(value) {
    if (value === undefined || value === null || value === '') {
        return 'restrict';
    }
    return String(value).toLowerCase();
}

function compareFkActions(sdfFk, dbFk) {
    const reasons = [];
    const sdfOnDelete = normalizeFkAction(sdfFk && sdfFk.onDelete);
    const dbOnDelete = normalizeFkAction(dbFk && dbFk.onDelete);
    if (sdfOnDelete !== dbOnDelete) {
        reasons.push(`onDelete: SDF='${sdfOnDelete}' vs DB='${dbOnDelete}'`);
    }
    const sdfOnUpdate = normalizeFkAction(sdfFk && sdfFk.onUpdate);
    const dbOnUpdate = normalizeFkAction(dbFk && dbFk.onUpdate);
    if (sdfOnUpdate !== dbOnUpdate) {
        reasons.push(`onUpdate: SDF='${sdfOnUpdate}' vs DB='${dbOnUpdate}'`);
    }
    return reasons;
}

function formatFkEntry(fk) {
    return {
        name: fk.name,
        localKey: fk.localKey,
        target: fk.target,
        references: fk.references,
        onDelete: normalizeFkAction(fk.onDelete),
        onUpdate: normalizeFkAction(fk.onUpdate),
        // Actual DB constraint name for onlyInDb (DROP); undefined for onlyInSdf (ADD).
        dbConstraintName: fk._dbConstraintName
    };
}

function formatFkActions(fk) {
    return {
        onDelete: normalizeFkAction(fk.onDelete),
        onUpdate: normalizeFkAction(fk.onUpdate)
    };
}

function diffForeignKeys(sdfIR, dbIR) {
    const sdf = extractForeignKeys(sdfIR);
    const db = extractForeignKeys(dbIR);

    const sdfMap = new Map();
    for (const fk of sdf) sdfMap.set(fkKey(fk), fk);
    const dbMap = new Map();
    for (const fk of db) dbMap.set(fkKey(fk), fk);

    const onlyInSdf = [];
    const onlyInDb = [];
    const mismatched = [];

    for (const [key, sdfFk] of sdfMap.entries()) {
        if (!dbMap.has(key)) {
            onlyInSdf.push(formatFkEntry(sdfFk));
            continue;
        }
        const dbFk = dbMap.get(key);
        const reasons = compareFkActions(sdfFk, dbFk);
        if (reasons.length > 0) {
            mismatched.push({
                name: sdfFk.name,
                dbName: dbFk.name,
                // Actual DB constraint name (conname) from the DB side, used to DROP
                // the existing constraint before re-adding with SDF-derived naming.
                dbConstraintName: dbFk._dbConstraintName,
                localKey: sdfFk.localKey,
                target: sdfFk.target,
                references: sdfFk.references,
                sdf: formatFkActions(sdfFk),
                db: formatFkActions(dbFk),
                reasons
            });
        }
    }
    for (const [key, dbFk] of dbMap.entries()) {
        if (!sdfMap.has(key)) {
            onlyInDb.push(formatFkEntry(dbFk));
        }
    }

    return { onlyInSdf, onlyInDb, mismatched };
}

// ─────────────────────────────────────────────────────────────
// Check constraint comparison (set-based match-by-content)
// ─────────────────────────────────────────────────────────────

// Hash kanonikal untuk check item:
//   - Parsed: ${field}:${op}:${valueKey} (IN-list value di-sort agar order
//     tidak signifikan).
//   - Unparsable: 'unparsable:' + normalized expression string.
function checkKey(c) {
    if (!c) return null;
    if (c._unparsable) {
        return `unparsable:${normalizeWeakDefault(c.expression)}`;
    }
    if (typeof c.field !== 'string' || typeof c.op !== 'string') return null;
    let valueKey;
    if (Array.isArray(c.value)) {
        valueKey = c.value
            .slice()
            .map((v) => JSON.stringify(v))
            .sort()
            .join(',');
    } else {
        valueKey = JSON.stringify(c.value);
    }
    return `${c.field}:${c.op}:${valueKey}`;
}

function formatCheck(c) {
    if (!c) return null;
    if (c._unparsable) return { expression: c.expression, _unparsable: true };
    const out = { field: c.field, op: c.op, value: c.value };
    if (c.name) out.name = c.name;
    return out;
}

function diffChecks(sdfChecks, dbChecks) {
    const sdfMap = new Map();
    if (Array.isArray(sdfChecks)) {
        for (const c of sdfChecks) {
            const k = checkKey(c);
            if (k && !sdfMap.has(k)) sdfMap.set(k, c);
        }
    }
    const dbMap = new Map();
    if (Array.isArray(dbChecks)) {
        for (const c of dbChecks) {
            const k = checkKey(c);
            if (k && !dbMap.has(k)) dbMap.set(k, c);
        }
    }

    const onlyInSdf = [];
    const onlyInDb = [];
    const mismatched = [];

    for (const [k, c] of sdfMap.entries()) {
        if (!dbMap.has(k)) onlyInSdf.push(formatCheck(c));
    }
    for (const [k, c] of dbMap.entries()) {
        if (!sdfMap.has(k)) onlyInDb.push(formatCheck(c));
    }

    return { onlyInSdf, onlyInDb, mismatched };
}

// ─────────────────────────────────────────────────────────────
// Top-level API
// ─────────────────────────────────────────────────────────────

function hasAnyDrift(delta) {
    if (!delta) return false;
    if (delta.fields.onlyInSdf.length > 0) return true;
    if (delta.fields.onlyInDb.length > 0) return true;
    if (delta.fields.mismatched.length > 0) return true;
    if (!delta.primaryKey.match) return true;
    if (delta.indexes.onlyInSdf.length > 0) return true;
    if (delta.indexes.onlyInDb.length > 0) return true;
    if (delta.indexes.mismatched.length > 0) return true;
    if (delta.uniques.onlyInSdf.length > 0) return true;
    if (delta.uniques.onlyInDb.length > 0) return true;
    if (delta.uniques.mismatched.length > 0) return true;
    if (delta.foreignKeys.onlyInSdf.length > 0) return true;
    if (delta.foreignKeys.onlyInDb.length > 0) return true;
    if (delta.foreignKeys.mismatched.length > 0) return true;
    if (delta.checks && delta.checks.onlyInSdf.length > 0) return true;
    if (delta.checks && delta.checks.onlyInDb.length > 0) return true;
    if (delta.checks && delta.checks.mismatched.length > 0) return true;
    return false;
}

function diffModel(sdfIR, dbIR) {
    if (!isPlainObject(sdfIR)) {
        throw new Error('diffModel: sdfIR must be a plain object IR');
    }
    if (!isPlainObject(dbIR)) {
        throw new Error('diffModel: dbIR must be a plain object IR');
    }

    const tableName = sdfIR.tableName || dbIR.tableName || null;

    const delta = {
        tableName,
        hasDrift: false,
        fields: diffFields(sdfIR.fields, dbIR.fields),
        primaryKey: diffPrimaryKey(sdfIR, dbIR),
        indexes: diffIndexes(sdfIR.indexes, dbIR.indexes),
        uniques: diffUniques(sdfIR.uniques, dbIR.uniques),
        foreignKeys: diffForeignKeys(sdfIR, dbIR),
        checks: diffChecks(sdfIR.checks, dbIR.checks)
    };
    delta.hasDrift = hasAnyDrift(delta);

    return delta;
}

function diffModels(sdfModels, dbModels) {
    const sdfMap = toMap(sdfModels);
    const dbMap = toMap(dbModels);

    const results = [];
    const visited = new Set();

    for (const [key, sdfIR] of sdfMap.entries()) {
        visited.add(key);
        const dbIR = dbMap.get(key) || emptyIR(sdfIR.tableName || key);
        results.push(diffModel(sdfIR, dbIR));
    }
    for (const [key, dbIR] of dbMap.entries()) {
        if (visited.has(key)) continue;
        results.push(diffModel(emptyIR(dbIR.tableName || key), dbIR));
    }

    return results;
}

function emptyIR(tableName) {
    return {
        tableName: tableName || '',
        schemaName: null,
        qualifiedName: tableName || '',
        fields: {},
        primaryKey: [],
        indexes: [],
        uniques: [],
        relations: {},
        checks: []
    };
}

function toMap(input) {
    if (input instanceof Map) return input;
    const map = new Map();
    if (Array.isArray(input)) {
        for (const ir of input) {
            if (ir && (ir.qualifiedName || ir.tableName)) {
                map.set(ir.qualifiedName || ir.tableName, ir);
            }
        }
        return map;
    }
    if (isPlainObject(input)) {
        for (const [key, ir] of Object.entries(input)) {
            map.set(key, ir);
        }
        return map;
    }
    return map;
}

module.exports = {
    diffModel,
    diffModels,
    normalizeType,
    // exported for unit test transparency
    _internal: {
        resolvePrimaryKey,
        diffFields,
        diffPrimaryKey,
        diffIndexes,
        diffUniques,
        diffForeignKeys,
        extractForeignKeys,
        diffChecks,
        compareDefaultValue,
        canonicalDefaultPayload,
        normalizeWeakDefault,
        normalizeFkAction,
        compareFkActions
    }
};
