"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
    return function (target, key) { decorator(target, key, paramIndex); }
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
var MapRepository_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.MapRepository = void 0;
const common_1 = require("@nestjs/common");
const i18n_iso_countries_1 = require("i18n-iso-countries");
const kysely_1 = require("kysely");
const nestjs_kysely_1 = require("nestjs-kysely");
const node_fs_1 = require("node:fs");
const promises_1 = require("node:fs/promises");
const node_readline_1 = __importDefault(require("node:readline"));
const constants_1 = require("../constants");
const decorators_1 = require("../decorators");
const enum_1 = require("../enum");
const config_repository_1 = require("./config.repository");
const logging_repository_1 = require("./logging.repository");
const system_metadata_repository_1 = require("./system-metadata.repository");
let MapRepository = MapRepository_1 = class MapRepository {
    configRepository;
    metadataRepository;
    logger;
    db;
    constructor(configRepository, metadataRepository, logger, db) {
        this.configRepository = configRepository;
        this.metadataRepository = metadataRepository;
        this.logger = logger;
        this.db = db;
        this.logger.setContext(MapRepository_1.name);
    }
    async init() {
        this.logger.log('Initializing metadata repository');
        const { resourcePaths } = this.configRepository.getEnv();
        const geodataDate = await (0, promises_1.readFile)(resourcePaths.geodata.dateFile, 'utf8');
        const geocodingMetadata = await this.metadataRepository.get(enum_1.SystemMetadataKey.ReverseGeocodingState);
        if (geocodingMetadata?.lastUpdate === geodataDate) {
            return;
        }
        await Promise.all([this.importGeodata(), this.importNaturalEarthCountries()]);
        await this.metadataRepository.set(enum_1.SystemMetadataKey.ReverseGeocodingState, {
            lastUpdate: geodataDate,
            lastImportFileName: constants_1.citiesFile,
        });
        this.logger.log('Geodata import completed');
    }
    getMapMarkers(ownerIds, albumIds, { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = {}) {
        return this.db
            .selectFrom('asset')
            .innerJoin('asset_exif', (builder) => builder
            .onRef('asset.id', '=', 'asset_exif.assetId')
            .on('asset_exif.latitude', 'is not', null)
            .on('asset_exif.longitude', 'is not', null))
            .select([
            'id',
            'asset_exif.latitude as lat',
            'asset_exif.longitude as lon',
            'asset_exif.city',
            'asset_exif.state',
            'asset_exif.country',
        ])
            .$narrowType()
            .$if(isArchived === true, (qb) => qb.where((eb) => eb.or([
            eb('asset.visibility', '=', enum_1.AssetVisibility.Timeline),
            eb('asset.visibility', '=', enum_1.AssetVisibility.Archive),
        ])))
            .$if(isArchived === false || isArchived === undefined, (qb) => qb.where('asset.visibility', '=', enum_1.AssetVisibility.Timeline))
            .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite))
            .$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter))
            .$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore))
            .where('deletedAt', 'is', null)
            .where((eb) => {
            const expression = [];
            if (ownerIds.length > 0) {
                expression.push(eb('ownerId', 'in', ownerIds));
            }
            if (albumIds.length > 0) {
                expression.push(eb.exists((eb) => eb
                    .selectFrom('album_asset')
                    .whereRef('asset.id', '=', 'album_asset.assetId')
                    .where('album_asset.albumId', 'in', albumIds)));
            }
            return eb.or(expression);
        })
            .orderBy('fileCreatedAt', 'desc')
            .execute();
    }
    async reverseGeocode(point) {
        this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
        const response = await this.db
            .selectFrom('geodata_places')
            .selectAll()
            .where((0, kysely_1.sql) `earth_box(ll_to_earth_public(${point.latitude}, ${point.longitude}), ${constants_1.reverseGeocodeMaxDistance})`, '@>', (0, kysely_1.sql) `ll_to_earth_public(latitude, longitude)`)
            .orderBy((0, kysely_1.sql) `(earth_distance(ll_to_earth_public(${point.latitude}, ${point.longitude}), ll_to_earth_public(latitude, longitude)))`)
            .limit(1)
            .executeTakeFirst();
        if (response) {
            this.logger.verboseFn(() => `Raw: ${JSON.stringify(response, null, 2)}`);
            const { countryCode, name: city, admin1Name } = response;
            const country = (0, i18n_iso_countries_1.getName)(countryCode, 'en') ?? null;
            const state = admin1Name;
            return { country, state, city };
        }
        this.logger.log(`Empty response from database for city reverse geocoding lat: ${point.latitude}, lon: ${point.longitude}. Likely cause: no nearby large populated place (500+ within ${constants_1.reverseGeocodeMaxDistance / 1000}km). Falling back to country boundaries.`);
        const ne_response = await this.db
            .selectFrom('naturalearth_countries')
            .selectAll()
            .where('coordinates', '@>', (0, kysely_1.sql) `point(${point.longitude}, ${point.latitude})`)
            .limit(1)
            .executeTakeFirst();
        if (!ne_response) {
            this.logger.log(`Empty response from database for natural earth country reverse geocoding lat: ${point.latitude}, lon: ${point.longitude}`);
            return { country: null, state: null, city: null };
        }
        this.logger.verboseFn(() => `Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
        const { admin_a3 } = ne_response;
        const country = (0, i18n_iso_countries_1.getName)(admin_a3, 'en') ?? null;
        const state = null;
        const city = null;
        return { country, state, city };
    }
    async importNaturalEarthCountries() {
        const { resourcePaths } = this.configRepository.getEnv();
        const geoJSONData = JSON.parse(await (0, promises_1.readFile)(resourcePaths.geodata.naturalEarthCountriesPath, 'utf8'));
        if (geoJSONData.type !== 'FeatureCollection' || !Array.isArray(geoJSONData.features)) {
            this.logger.fatal('Invalid GeoJSON FeatureCollection');
            return;
        }
        const entities = [];
        for (const feature of geoJSONData.features) {
            for (const entry of feature.geometry.coordinates) {
                const coordinates = feature.geometry.type === 'MultiPolygon' ? entry[0] : entry;
                const featureRecord = {
                    admin: feature.properties.ADMIN,
                    admin_a3: feature.properties.ADM0_A3,
                    type: feature.properties.TYPE,
                    coordinates: `(${coordinates.map((point) => `(${point[0]},${point[1]})`).join(', ')})`,
                };
                entities.push(featureRecord);
                if (feature.geometry.type === 'Polygon') {
                    break;
                }
            }
        }
        await this.db.transaction().execute(async (manager) => {
            await (0, kysely_1.sql) `CREATE TABLE naturalearth_countries_tmp
                (
                  LIKE naturalearth_countries INCLUDING ALL EXCLUDING INDEXES
                )`.execute(manager);
            await manager.schema.dropTable('naturalearth_countries').execute();
            await manager.schema.alterTable('naturalearth_countries_tmp').renameTo('naturalearth_countries').execute();
        });
        await this.db.insertInto('naturalearth_countries').values(entities).execute();
        await (0, kysely_1.sql) `ALTER TABLE naturalearth_countries ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100)`.execute(this.db);
    }
    async importGeodata() {
        const { resourcePaths } = this.configRepository.getEnv();
        const [admin1, admin2] = await Promise.all([
            this.loadAdmin(resourcePaths.geodata.admin1),
            this.loadAdmin(resourcePaths.geodata.admin2),
        ]);
        await this.db.schema.dropTable('geodata_places_tmp').ifExists().execute();
        await this.db.transaction().execute(async (manager) => {
            await (0, kysely_1.sql) `CREATE TABLE geodata_places_tmp
                (
                  LIKE geodata_places INCLUDING ALL EXCLUDING INDEXES
                )`.execute(manager);
            await manager.schema.dropTable('geodata_places').execute();
            await manager.schema.alterTable('geodata_places_tmp').renameTo('geodata_places').execute();
        });
        await this.db.schema
            .createIndex('IDX_geodata_gist_earthcoord')
            .on('geodata_places')
            .using('gist')
            .expression((0, kysely_1.sql) `ll_to_earth_public(latitude, longitude)`)
            .execute();
        await this.loadCities500(admin1, admin2);
        await this.createGeodataIndices();
    }
    async loadCities500(admin1Map, admin2Map) {
        const { resourcePaths } = this.configRepository.getEnv();
        const cities500 = resourcePaths.geodata.cities500;
        if (!(0, node_fs_1.existsSync)(cities500)) {
            throw new Error(`Geodata file ${cities500} not found`);
        }
        this.logger.log(`Starting geodata import`);
        const startTime = performance.now();
        const input = (0, node_fs_1.createReadStream)(cities500, { highWaterMark: 512 * 1024 * 1024 });
        let bufferGeodata = [];
        const lineReader = node_readline_1.default.createInterface({ input });
        let count = 0;
        let futures = [];
        for await (const line of lineReader) {
            const lineSplit = line.split('\t');
            if ((lineSplit[7] === 'PPLX' && lineSplit[8] !== 'AU') || lineSplit[7] === 'PPLH') {
                continue;
            }
            const geoData = {
                id: Number.parseInt(lineSplit[0]),
                name: lineSplit[1],
                alternateNames: lineSplit[3],
                latitude: Number.parseFloat(lineSplit[4]),
                longitude: Number.parseFloat(lineSplit[5]),
                countryCode: lineSplit[8],
                admin1Code: lineSplit[10],
                admin2Code: lineSplit[11],
                modificationDate: lineSplit[18],
                admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`) ?? null,
                admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`) ?? null,
            };
            bufferGeodata.push(geoData);
            if (bufferGeodata.length >= 5000) {
                const curLength = bufferGeodata.length;
                futures.push(this.db
                    .insertInto('geodata_places')
                    .values(bufferGeodata)
                    .execute()
                    .then(() => {
                    count += curLength;
                    if (count % 10_000 === 0) {
                        this.logger.log(`${count} geodata records imported`);
                    }
                }));
                bufferGeodata = [];
                if (futures.length >= 9) {
                    await Promise.all(futures);
                    futures = [];
                }
            }
        }
        if (bufferGeodata.length > 0) {
            await this.db.insertInto('geodata_places').values(bufferGeodata).execute();
            count += bufferGeodata.length;
        }
        await Promise.all(futures);
        const duration = performance.now() - startTime;
        const seconds = duration / 1000;
        const recordsPerSecond = Math.round(count / seconds);
        this.logger.log(`Successfully imported ${count} geodata records in ${seconds.toFixed(2)}s (${recordsPerSecond} records/second)`);
    }
    async loadAdmin(filePath) {
        if (!(0, node_fs_1.existsSync)(filePath)) {
            this.logger.error(`Geodata file ${filePath} not found`);
            throw new Error(`Geodata file ${filePath} not found`);
        }
        const input = (0, node_fs_1.createReadStream)(filePath, { highWaterMark: 512 * 1024 * 1024 });
        const lineReader = node_readline_1.default.createInterface({ input });
        const adminMap = new Map();
        for await (const line of lineReader) {
            const lineSplit = line.split('\t');
            adminMap.set(lineSplit[0], lineSplit[1]);
        }
        return adminMap;
    }
    createGeodataIndices() {
        return Promise.all([
            (0, kysely_1.sql) `ALTER TABLE geodata_places ADD PRIMARY KEY (id) WITH (FILLFACTOR = 100)`.execute(this.db),
            this.db.schema
                .createIndex(`idx_geodata_places_alternate_names`)
                .on('geodata_places')
                .using('gin (f_unaccent("alternateNames") gin_trgm_ops)')
                .execute(),
            this.db.schema
                .createIndex(`idx_geodata_places_name`)
                .on('geodata_places')
                .using('gin (f_unaccent(name) gin_trgm_ops)')
                .execute(),
            this.db.schema
                .createIndex(`idx_geodata_places_admin1_name`)
                .on('geodata_places')
                .using('gin (f_unaccent("admin1Name") gin_trgm_ops)')
                .execute(),
            this.db.schema
                .createIndex(`idx_geodata_places_admin2_name`)
                .on('geodata_places')
                .using('gin (f_unaccent("admin2Name") gin_trgm_ops)')
                .execute(),
        ]);
    }
};
exports.MapRepository = MapRepository;
__decorate([
    (0, decorators_1.GenerateSql)({ params: [[decorators_1.DummyValue.UUID], [decorators_1.DummyValue.UUID]] }),
    __metadata("design:type", Function),
    __metadata("design:paramtypes", [Array, Array, Object]),
    __metadata("design:returntype", void 0)
], MapRepository.prototype, "getMapMarkers", null);
exports.MapRepository = MapRepository = MapRepository_1 = __decorate([
    (0, common_1.Injectable)(),
    __param(3, (0, nestjs_kysely_1.InjectKysely)()),
    __metadata("design:paramtypes", [config_repository_1.ConfigRepository,
        system_metadata_repository_1.SystemMetadataRepository,
        logging_repository_1.LoggingRepository,
        kysely_1.Kysely])
], MapRepository);
//# sourceMappingURL=map.repository.js.map