import { LevelCalculator, Standards, type StandardsParams, StandardsParamsSchema, StandardsDataSchema, type StandardsData, type StandardsConfig, type LevelCalculatorOutput } from "@blade-and-brawn/calculator";
import type { ActivityPerformance, Player } from "@blade-and-brawn/domain";
import { log } from "../util";
import { db } from "../database/db";
import { Value } from "@sinclair/typebox/value";

export class CalculatorUnavailableError extends Error {
    status: number = 503
}

export class CalculatorService {
    private calculator: LevelCalculator | null = null;
    Standards: CalculatorStandardsService;

    constructor(private name: string) {
        this.Standards = new CalculatorStandardsService(this.name);
        // eagerly load
        this.refresh().catch((err) => log.error({ err }, "failed to load initial standards config"));
    }

    async calculate(player: Player, activityPerformances: ActivityPerformance[]): Promise<LevelCalculatorOutput> {
        await this.refresh();
        if (!this.calculator) throw new CalculatorUnavailableError("Level calculator is not ready");
        return this.calculator.calculate(player, activityPerformances);
    }

    private async refresh(): Promise<void> {
        const standardsConfig = await this.Standards.Config.get();

        if (this.calculator) {
            // avoid instantiating a new Standards object if nothing changed
            if (JSON.stringify(standardsConfig) === JSON.stringify(this.calculator.standards.cfg)) return;

            this.calculator.standards = new Standards(standardsConfig);
        }
        else {
            this.calculator = new LevelCalculator(new Standards(standardsConfig));
        }
    }
}

class CalculatorStandardsService {
    Config: CalculatorStandardsConfigService;

    constructor(private name: string) {
        this.Config = new CalculatorStandardsConfigService(this.name);
    }
}

class CalculatorStandardsConfigService {
    private cache = {
        refreshInterval: 30_000,
        lastRefreshedAt: 0,
        isStale: function () {
            return Date.now() - this.lastRefreshedAt >= this.refreshInterval;
        },
        isIncomplete: function () {
            return !this.data.id || !this.data.datasetId || !this.data.config
        },
        data: {
            id: null as string | null,
            datasetId: null as string | null,
            config: null as StandardsConfig | null
        },
    }

    constructor(private name: string) { }

    async get(): Promise<StandardsConfig & { id: string }> {
        await this.refresh({ onlyIfStale: true });
        if (!this.cache.data.id || !this.cache.data.config) throw new CalculatorUnavailableError("Unable to determine the calculator's standards configuration");
        return { id: this.cache.data.id, ...this.cache.data.config };
    }

    async refresh(opt: { onlyIfStale?: boolean } = {}): Promise<void> {
        if (!this.cache.isIncomplete() && (opt.onlyIfStale && !this.cache.isStale())) return;

        try {
            const result = await db.selectFrom("calculators")
                .innerJoin("standards_configs", "standards_configs.id", "calculators.standards_config_id")
                .innerJoin("standards_datasets", "standards_datasets.id", "standards_configs.dataset_id")
                .select([
                    "standards_configs.id as id",
                    "standards_configs.dataset_id as datasetId",
                    "standards_configs.params as params",
                    "standards_datasets.data as data",
                ])
                .where("calculators.name", "=", this.name)
                .limit(1)
                .executeTakeFirstOrThrow({ errorConstructor: () => new Error("No standards config selected") });
            Value.Assert(StandardsParamsSchema, result.params);
            Value.Assert(StandardsDataSchema, result.data);

            this.cache.data.id = result.id;
            this.cache.data.datasetId = result.datasetId;
            this.cache.data.config = {
                data: result.data,
                params: result.params
            };
            // only mark as refreshed once we've actually confirmed the selected
            // config; a failed attempt must not make stale data look fresh
            this.cache.lastRefreshedAt = Date.now();
        } catch (err) {
            throw new CalculatorUnavailableError("Unable to determine the calculator's standards configuration", { cause: err });
        }
    }

    async switch(standardsConfigId: string) {
        const result = await db.updateTable("calculators")
            .set({ standards_config_id: standardsConfigId })
            .where("name", "=", this.name)
            .execute();
        if (result[0]?.numUpdatedRows === 0n) throw new Error(`No calculator with name "${this.name}"`);
    }
}
