import { constants } from 'node:fs';
import { lstat, open } from 'node:fs/promises';
import { isIP } from 'node:net';
import path from 'node:path';

import { resolveBattleResultSourceConfig, type BattleResultSourceConfig } from './battleResultSource.js';
import { isLegacyArchiveProfile, LEGACY_ARCHIVE_PROFILES, type LegacyArchiveProfile } from './game.js';
import { fingerprintMariaConnection, type MigrationSourceIdentity } from './incremental.js';

export interface ResolvedMigrationStage {
    kind: 'gateway' | 'game';
    name: string;
    profile?: LegacyArchiveProfile;
    sourceUrl: string;
    targetUrl: string;
    sourceIdentity: MigrationSourceIdentity;
    battleResults?: BattleResultSourceConfig;
}

export interface ResolvedMigrationPlan {
    sourceSet: string;
    stages: ResolvedMigrationStage[];
}

const SAFE_KEY = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,95}$/u;
const ENV_NAME = /^[A-Z][A-Z0-9_]{1,127}$/u;

const assertSecureRegularFile = async (filePath: string, label: string): Promise<void> => {
    const info = await lstat(filePath);
    if (!info.isFile() || info.isSymbolicLink()) {
        throw new Error(`${label} must be a regular file and not a symbolic link`);
    }
    if ((info.mode & 0o077) !== 0) {
        throw new Error(`${label} must not be readable or writable by group/other (expected mode 0600)`);
    }
};

const readSecureText = async (filePath: string, label: string): Promise<string> => {
    await assertSecureRegularFile(filePath, label);
    const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
    try {
        return await handle.readFile('utf8');
    } finally {
        await handle.close();
    }
};

const asRecord = (value: unknown, label: string): Record<string, unknown> => {
    if (value === null || Array.isArray(value) || typeof value !== 'object') {
        throw new Error(`${label} must be an object`);
    }
    return value as Record<string, unknown>;
};

const requiredString = (record: Record<string, unknown>, key: string, label: string): string => {
    const value = record[key];
    if (typeof value !== 'string' || !value.trim()) {
        throw new Error(`${label}.${key} must be a non-empty string`);
    }
    return value.trim();
};

const rejectUnknownKeys = (record: Record<string, unknown>, allowed: readonly string[], label: string): void => {
    const unknown = Object.keys(record).filter((key) => !allowed.includes(key));
    if (unknown.length) {
        throw new Error(`${label} has unknown keys: ${unknown.join(', ')}`);
    }
};

const resolvePassword = async (
    source: Record<string, unknown>,
    configDirectory: string,
    label: string
): Promise<string> => {
    const configured = ['password', 'passwordEnv', 'passwordFile'].filter(
        (key) => typeof source[key] === 'string' && Boolean(String(source[key]).trim())
    );
    if (configured.length !== 1) {
        throw new Error(`${label} must configure exactly one of password, passwordEnv, or passwordFile`);
    }
    if (configured[0] === 'password') {
        return requiredString(source, 'password', label);
    }
    if (configured[0] === 'passwordEnv') {
        const environmentName = requiredString(source, 'passwordEnv', label);
        if (!ENV_NAME.test(environmentName)) throw new Error(`${label}.passwordEnv is not a safe environment name`);
        const value = process.env[environmentName];
        if (!value) throw new Error(`${environmentName} is required by ${label}`);
        return value;
    }
    const configuredPath = requiredString(source, 'passwordFile', label);
    const passwordPath = path.resolve(configDirectory, configuredPath);
    const value = (await readSecureText(passwordPath, `${label}.passwordFile`)).replace(/\r?\n$/u, '');
    if (!value) throw new Error(`${label}.passwordFile is empty`);
    return value;
};

const resolveSource = async (value: unknown, configDirectory: string, label: string): Promise<string> => {
    const source = asRecord(value, label);
    rejectUnknownKeys(
        source,
        ['host', 'port', 'database', 'user', 'password', 'passwordEnv', 'passwordFile', 'tls'],
        label
    );
    const host = requiredString(source, 'host', label);
    const database = requiredString(source, 'database', label);
    const user = requiredString(source, 'user', label);
    const port = source.port === undefined ? 3306 : Number(source.port);
    if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
        throw new Error(`${label}.port must be an integer from 1 through 65535`);
    }
    const dnsName =
        host.length <= 253 && host.split('.').every((part) => /^(?!-)[a-zA-Z0-9-]{1,63}(?<!-)$/u.test(part));
    if (!isIP(host) && !dnsName) throw new Error(`${label}.host must be an IP address or DNS name`);
    if (!/^[a-zA-Z0-9_$.-]{1,64}$/u.test(database)) {
        throw new Error(`${label}.database must be a safe MariaDB database name`);
    }
    if (source.tls !== undefined && typeof source.tls !== 'boolean') {
        throw new Error(`${label}.tls must be a boolean`);
    }
    const password = await resolvePassword(source, configDirectory, label);
    const url = new URL('mariadb://localhost');
    url.hostname = host;
    url.port = String(port);
    url.username = user;
    url.password = password;
    url.pathname = `/${database}`;
    if (source.tls) url.searchParams.set('ssl', 'true');
    return url.toString();
};

const resolveTargetUrl = (record: Record<string, unknown>, label: string): string => {
    const environmentName = requiredString(record, 'targetUrlEnv', label);
    if (!ENV_NAME.test(environmentName)) throw new Error(`${label}.targetUrlEnv is not a safe environment name`);
    const value = process.env[environmentName]?.trim();
    if (!value) throw new Error(`${environmentName} is required by ${label}`);
    return value;
};

const parseStage = (value: unknown, label: string): Record<string, unknown> => {
    const record = asRecord(value, label);
    rejectUnknownKeys(record, ['source', 'targetUrlEnv', 'profile', 'enabled', 'battleResults'], label);
    if (!('source' in record)) throw new Error(`${label}.source is required`);
    return record;
};

export const loadMigrationPlan = async (configPathInput: string): Promise<ResolvedMigrationPlan> => {
    const configPath = path.resolve(configPathInput);
    const rawText = await readSecureText(configPath, 'Migration config');
    let parsed: unknown;
    try {
        parsed = JSON.parse(rawText);
    } catch (error) {
        throw new Error('Migration config is not valid JSON', { cause: error });
    }
    const root = asRecord(parsed, 'Migration config');
    rejectUnknownKeys(root, ['version', 'sourceSet', 'gateway', 'profiles'], 'Migration config');
    if (root.version !== 1) throw new Error('Migration config.version must be 1');
    const sourceSet = requiredString(root, 'sourceSet', 'Migration config');
    if (!SAFE_KEY.test(sourceSet)) throw new Error('Migration config.sourceSet must use safe characters');
    const configDirectory = path.dirname(configPath);
    const stages: ResolvedMigrationStage[] = [];

    if (root.gateway !== undefined) {
        const gateway = parseStage(root.gateway, 'gateway');
        const sourceUrl = await resolveSource(gateway.source, configDirectory, 'gateway.source');
        stages.push({
            kind: 'gateway',
            name: 'gateway',
            sourceUrl,
            targetUrl: resolveTargetUrl(gateway, 'gateway'),
            sourceIdentity: {
                key: `${sourceSet}:gateway`,
                fingerprint: fingerprintMariaConnection(sourceUrl),
            },
        });
    }

    const profiles = root.profiles === undefined ? [] : root.profiles;
    if (!Array.isArray(profiles)) throw new Error('Migration config.profiles must be an array');
    const seen = new Set<string>();
    const profileStages = new Map<LegacyArchiveProfile, ResolvedMigrationStage>();
    for (const [index, value] of profiles.entries()) {
        const label = `profiles[${index}]`;
        const profileConfig = parseStage(value, label);
        if (profileConfig.enabled === false) continue;
        if (profileConfig.enabled !== undefined && profileConfig.enabled !== true) {
            throw new Error(`${label}.enabled must be a boolean`);
        }
        const profile = requiredString(profileConfig, 'profile', label);
        if (!isLegacyArchiveProfile(profile)) {
            throw new Error(`${label}.profile must be one of ${LEGACY_ARCHIVE_PROFILES.join(', ')}`);
        }
        if (seen.has(profile)) throw new Error(`Duplicate profile in migration config: ${profile}`);
        seen.add(profile);
        const sourceUrl = await resolveSource(profileConfig.source, configDirectory, `${label}.source`);
        let battleResults: BattleResultSourceConfig | undefined;
        if (profileConfig.battleResults !== undefined) {
            const battleResultConfig = asRecord(profileConfig.battleResults, `${label}.battleResults`);
            rejectUnknownKeys(battleResultConfig, ['directory', 'sshHost'], `${label}.battleResults`);
            const directory = requiredString(battleResultConfig, 'directory', `${label}.battleResults`);
            const sshHostValue = battleResultConfig.sshHost;
            if (sshHostValue !== undefined && typeof sshHostValue !== 'string') {
                throw new Error(`${label}.battleResults.sshHost must be a string`);
            }
            battleResults = await resolveBattleResultSourceConfig(
                { directory, ...(sshHostValue === undefined ? {} : { sshHost: sshHostValue }) },
                `${sourceSet}:${profile}`,
                profile,
                configDirectory
            );
        }
        profileStages.set(profile, {
            kind: 'game',
            name: profile,
            profile,
            sourceUrl,
            targetUrl: resolveTargetUrl(profileConfig, label),
            sourceIdentity: {
                key: `${sourceSet}:${profile}`,
                fingerprint: fingerprintMariaConnection(sourceUrl),
            },
            ...(battleResults ? { battleResults } : {}),
        });
    }
    for (const profile of LEGACY_ARCHIVE_PROFILES) {
        const stage = profileStages.get(profile);
        if (stage) stages.push(stage);
    }
    if (!stages.length) throw new Error('Migration config has no enabled stages');
    return { sourceSet, stages };
};

export const readPasswordFileForReset = async (passwordPath: string): Promise<string> => {
    const value = (await readSecureText(path.resolve(passwordPath), 'Password file')).replace(/\r?\n$/u, '');
    if (!value) throw new Error('Password file is empty');
    return value;
};
