import { DataTypes, Op, Sequelize } from 'sequelize';
import { isPlainObject, get as getAtPath, set as setAtPath, isEqual, keyBy } from 'lodash';
import { settingsCache } from '@tamanu/settings/cache';
import { SYNC_DIRECTIONS, SETTINGS_SCOPES } from '@tamanu/constants';
import { extractDefaults, getScopedSchema } from '@tamanu/settings/schema';
import { encryptSecret, getSettingsPskKeyBuffer } from '@tamanu/shared/utils/crypto';
import { Model } from './Model';
import { buildSyncLookupSelect } from '../sync/buildSyncLookupSelect';
const SETTINGS_SCOPES_VALUES = Object.values(SETTINGS_SCOPES);
/**
 * Stores nested settings data, where each leaf node in the nested object has a record in the table,
 * with a key based on the full path of keys to get there, joined by dots
 * The model is responsible for providing a nice interface, so that consumers don't have to think
 * about that storage mechanism, instead just setting and getting as they please
 * For e.g.:
 * Setting.set({
 *   schedules: {
 *     outpatientDischarger: {
 *       schedule: '0 11 * * *',
 *       batchSize: 1000,
 *     },
 *     automaticLabTestResultPublisher: false,
 *   },
 * });
 * becomes:
 * id    | key                                       | value
 * xxx   | schedules.outpatientDischarger.schedule   | 0 11 * * *
 * yyy   | schedules.outpatientDischarger.batchSize  | 1000
 * zzz   | schedules.automaticLabTestResultPublisher | false
 */ export class Setting extends Model {
    static initModel({ primaryKey, ...options }) {
        super.init({
            id: primaryKey,
            key: {
                type: DataTypes.TEXT,
                allowNull: false
            },
            value: DataTypes.JSONB,
            scope: {
                type: DataTypes.TEXT,
                allowNull: false,
                defaultValue: SETTINGS_SCOPES.GLOBAL
            }
        }, {
            ...options,
            syncDirection: SYNC_DIRECTIONS.PULL_FROM_CENTRAL,
            // Synchronous in-process invalidation; raw SQL / cross-process is covered
            // by the NOTIFY listener (see `registerSettingsCacheInvalidator`).
            hooks: {
                afterSave () {
                    settingsCache.reset();
                },
                afterBulkCreate () {
                    settingsCache.reset();
                },
                afterBulkUpdate () {
                    settingsCache.reset();
                },
                afterBulkDestroy () {
                    settingsCache.reset();
                },
                afterBulkRestore () {
                    settingsCache.reset();
                }
            },
            indexes: [
                {
                    // settings_alive_key_unique_cnt
                    // overly broad constraint, narrowed by the next two indices
                    unique: true,
                    fields: [
                        'key',
                        'facility_id',
                        'deleted_at'
                    ]
                },
                {
                    // settings_alive_key_unique_with_facility_idx
                    unique: true,
                    fields: [
                        'key',
                        'facility_id'
                    ],
                    where: {
                        deleted_at: null,
                        facility_id: {
                            [Op.ne]: null
                        }
                    }
                },
                {
                    // settings_alive_key_unique_without_facility_idx
                    unique: true,
                    fields: [
                        'key',
                        'scope'
                    ],
                    where: {
                        deleted_at: null,
                        facility_id: null
                    }
                }
            ]
        });
    }
    static initRelations(models) {
        this.belongsTo(models.Facility, {
            foreignKey: 'facilityId',
            as: 'facility'
        });
    }
    /**
   * IMPORTANT: Duplicated from mobile/models/Setting.ts
   * Please update both places when modify
   */ static async get(key = '', facilityId = null, scopeOverride = null) {
        const determineScope = ()=>{
            if (scopeOverride) {
                return scopeOverride;
            }
            if (facilityId) {
                return SETTINGS_SCOPES.FACILITY;
            }
            return null;
        };
        const scope = determineScope();
        const settings = await Setting.findAll({
            where: {
                ...key ? {
                    key: {
                        [Op.or]: {
                            [Op.eq]: key,
                            [Op.like]: `${key}.%`
                        }
                    }
                } : {},
                ...scope ? {
                    scope
                } : {},
                facilityId: {
                    ...facilityId ? {
                        [Op.eq]: facilityId
                    } : {
                        [Op.is]: null
                    }
                }
            },
            // we want facility keys to come last so they override global keys
            order: [
                [
                    'key',
                    'ASC'
                ],
                [
                    Sequelize.fn('coalesce', Sequelize.col('facility_id'), '###'),
                    'ASC'
                ]
            ]
        });
        const settingsObject = {};
        for (const currentSetting of settings){
            setAtPath(settingsObject, currentSetting.key, currentSetting.value);
        }
        if (key === '') {
            return settingsObject;
        }
        // just return the object or value below the requested key
        // e.g. if schedules.outPatientDischarger was requested, the return object will look like
        // {  schedule: '0 11 * * *', batchSize: 1000 }
        // rather than
        // { schedules: { outPatientDischarger: { schedule: '0 11 * * *', batchSize: 1000 } } }
        return getAtPath(settingsObject, key);
    }
    static async set(key = '', value, scope = SETTINGS_SCOPES.GLOBAL, facilityId = null) {
        const records = buildSettingsRecords(key, value, facilityId, scope);
        const schema = getScopedSchema(scope);
        const defaultsForScope = extractDefaults(schema);
        const existingSettings = await this.findAll({
            where: {
                key: records.map((r)=>r.key),
                scope,
                facilityId
            },
            paranoid: false
        });
        const existingByKey = keyBy(existingSettings, 'key');
        await Promise.all(records.map(async (record)=>{
            const existing = existingByKey[record.key];
            if (existing) {
                if (existing.deletedAt) {
                    await this.restore({
                        where: {
                            id: existing.id
                        }
                    });
                }
                if (!isEqual(existing.value, record.value)) {
                    // only update existing records that have changed
                    await this.update({
                        value: record.value
                    }, {
                        where: {
                            id: existing.id
                        }
                    });
                }
            } else {
                // only create records for values that differ from the defaults
                if (!isEqual(record.value, getAtPath(defaultsForScope, record.key))) {
                    await this.create(record);
                }
            }
        }));
        const keyWhere = key ? {
            [Op.or]: {
                [Op.eq]: key,
                [Op.like]: `${key}.%`
            }
        } : {};
        // delete any records that are no longer needed
        await this.update({
            deletedAt: Sequelize.fn('now')
        }, {
            where: {
                key: {
                    [Op.and]: {
                        ...keyWhere,
                        [Op.notIn]: records.map((r)=>r.key)
                    }
                },
                scope,
                facilityId
            }
        });
    }
    static buildSyncFilter() {
        return `WHERE (facility_id in (:facilityIds) OR scope = '${SETTINGS_SCOPES.GLOBAL}') AND ${this.tableName}.updated_at_sync_tick > :since`;
    }
    static async buildSyncLookupQueryDetails() {
        return {
            select: await buildSyncLookupSelect(this, {
                facilityId: 'settings.facility_id'
            })
        };
    }
    /**
   * Sets an encrypted secret in the settings table.
   */ static async setSecret(name, value, scope = SETTINGS_SCOPES.GLOBAL, facilityId = null) {
        const keyBuffer = await getSettingsPskKeyBuffer();
        const encryptedValue = await encryptSecret(keyBuffer, value);
        await this.set(name, encryptedValue, scope, facilityId);
    }
    /**
   * Removes a secret from the settings table. Used when an admin clears a
   * secret field — without this, the cleared value would be encrypted-and-
   * stored as an empty string and the UI would show the placeholder again on
   * reload, making it look like the secret was still set.
   */ static async unsetSecret(name, scope = SETTINGS_SCOPES.GLOBAL, facilityId = null) {
        await this.destroy({
            where: {
                key: name,
                scope,
                facilityId
            }
        });
    }
}
function buildSettingsRecords(keyPrefix, value, facilityId, scope = SETTINGS_SCOPES.GLOBAL) {
    if (isPlainObject(value)) {
        return Object.entries(value).flatMap(([k, v])=>buildSettingsRecords([
                keyPrefix,
                k
            ].filter(Boolean).join('.'), v, facilityId, scope));
    }
    return [
        {
            key: keyPrefix,
            value,
            facilityId,
            scope
        }
    ];
}

//# sourceMappingURL=Setting.js.map