import { loadActionModuleBundle, type TurnCommandProfile, type TurnSchedule } from '@sammo-ts/logic';
import {
    buildGameEventChannel,
    buildGameReadModelDomainRevisionKey,
    buildGameReadModelRevisionKey,
    createEmptyRealtimeReadModelChanges,
    GameClock,
    hasRealtimeReadModelChanges,
    type GameClockMode,
    type RealtimeEvent,
    type RealtimeReadModelChanges,
} from '@sammo-ts/common';
import { createGamePostgresConnector, createRedisConnector, resolveRedisConfigFromEnv } from '@sammo-ts/infra';
import { NATION_TRAIT_KEYS, NationTraitLoader, loadNationTraitModules } from '@sammo-ts/logic';

import { SystemClock } from '../lifecycle/clock.js';
import { getNextTickTime } from '../lifecycle/getNextTickTime.js';
import { InMemoryControlQueue } from '../lifecycle/inMemoryControlQueue.js';
import type { Clock, TurnDaemonControlQueue, TurnDaemonHooks, TurnRunBudget } from '../lifecycle/types.js';
import { TurnDaemonLifecycle } from '../lifecycle/turnDaemonLifecycle.js';
import { DatabaseTurnDaemonCommandQueue } from '../lifecycle/databaseCommandQueue.js';
import type { MapLoaderOptions } from '../scenario/mapLoader.js';
import { createDatabaseTurnHooks } from './databaseHooks.js';
import type { GeneralTurnHandler, InMemoryTurnWorldOptions, TurnCalendarHandler } from './inMemoryWorld.js';
import { InMemoryTurnWorld } from './inMemoryWorld.js';
import { InMemoryTurnProcessor } from './inMemoryTurnProcessor.js';
import { InMemoryTurnStateStore } from './inMemoryStateStore.js';
import { createGatewayAdminActionConsumer } from './gatewayAdminActions.js';
import { createGatewayProfileGate } from './gatewayProfileGate.js';
import { composeCalendarHandlers } from './calendarHandlers.js';
import { createIncomeHandler } from './incomeHandler.js';
import { createNationTurnMonthlyHandler } from './nationTurnMonthlyHandler.js';
import { calculateNpcNationFinance } from './npcTaxHandler.js';
import { createMonthlyBoundaryPreHandler } from './monthlyBoundaryPreHandler.js';
import { createMonthlyWanderHandler } from './monthlyWanderHandler.js';
import {
    createMonthlyDiplomacyHandler,
    createMonthlyNationCountHandler,
    createMonthlyNationStatsHandler,
    createMonthlyWarSettingHandler,
} from './monthlyNationStatsHandler.js';
import { createFrontStateHandler } from './frontStateHandler.js';
import { createReservedTurnHandler } from './reservedTurnHandler.js';
import { createReservedTurnStore } from './reservedTurnStore.js';
import { createTurnDaemonCommandHandler } from './worldCommandHandler.js';
import { loadTurnCommandProfile } from './turnCommandProfile.js';
import { loadTurnWorldFromDatabase } from './worldLoader.js';
import { shouldUseAi } from './ai/generalAi.js';
import { createUnificationHandler } from './unificationHandler.js';
import { loadPendingUnificationAuctionCancellations } from './unificationAuctionCancellation.js';
import { createAuctionFinalizer } from '../auction/finalizer.js';
import { createAuctionBidder } from '../auction/bidder.js';
import { createNeutralAuctionRegistrar } from '../auction/neutralRegistrar.js';
import { createTournamentRewardFinalizer } from '../tournament/finalizer.js';
import { createTournamentAutoStartHandler } from './tournamentAutoStart.js';
import { createYearbookHandler } from './yearbookHandler.js';
import {
    createMonthlyEventHandler,
    createRandomizeCityTradeRateHandler,
    type MonthlyEventActionHandler,
} from './monthlyEventHandler.js';
import { createRaiseDisasterHandler } from './monthlyDisasterAction.js';
import { createUpdateCitySupplyHandler } from './monthlyCitySupplyAction.js';
import { createUpdateNationLevelHandler } from './monthlyNationLevelAction.js';
import { createProcessSemiAnnualHandler } from './monthlySemiAnnualAction.js';
import { createProcessWarIncomeHandler } from './monthlyWarIncomeAction.js';
import { createCreateAdminNpcHandler } from './monthlyCreateAdminNpcAction.js';
import { createCreateManyNpcHandler } from './monthlyCreateManyNpcAction.js';
import { createRegisterNpcHandler } from './monthlyRegisterNpcAction.js';
import { createRaiseNpcNationHandler } from './monthlyRaiseNpcNationAction.js';
import {
    createAutoDeleteInvaderHandler,
    createInvaderEndingHandler,
    createRaiseInvaderHandler,
} from './monthlyInvaderAction.js';
import { createChangeCityHandler } from './monthlyChangeCityAction.js';
import { createProvideNpcTroopLeaderHandler } from './monthlyProvideNpcTroopLeaderAction.js';
import { createFinishNationBettingHandler, createOpenNationBettingHandler } from './monthlyNationBettingAction.js';
import { createScoutBlockHandler } from './monthlyScoutBlockAction.js';
import { createAddGlobalBetrayHandler, createAssignGeneralSpecialityHandler } from './monthlySpecialityBetrayAction.js';
import { createLostUniqueItemHandler, createMergeInheritPointRankHandler } from './monthlyUniqueInheritAction.js';
import {
    createNewYearHandler,
    createNoticeToHistoryLogHandler,
    createProcessIncomeActionHandler,
    createResetOfficerLockHandler,
} from './monthlyCoreEventAction.js';
import { buildCommandEnv } from './reservedTurnCommands.js';
import { DatabaseTurnDaemonLease, TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
import { EngineStateManager } from './engineStateManager.js';
import { applyRuntimeClockShift } from './runtimeClockShift.js';

export interface TurnDaemonRuntimeOptions {
    profile: string;
    profileName?: string;
    databaseUrl: string;
    gatewayDatabaseUrl?: string;
    defaultBudget?: TurnRunBudget;
    clock?: Clock;
    gameClockMode?: GameClockMode;
    controlQueue?: TurnDaemonControlQueue;
    schedule?: TurnSchedule;
    tickMinutes?: number;
    mapOptions?: MapLoaderOptions;
    generalTurnHandler?: GeneralTurnHandler;
    calendarHandler?: TurnCalendarHandler;
    enableDatabaseFlush?: boolean;
    pauseGateIntervalMs?: number;
    commandProfile?: TurnCommandProfile;
    commandProfilePath?: string;
    adminActionIntervalMs?: number;
    redisUrl?: string;
    commandStreamStartId?: string;
    leaseDurationMs?: number;
    leaseOwnerId?: string;
    enableLeaseHeartbeat?: boolean;
    /**
     * Isolated, single-process fixture acceleration only. Reserved turns are
     * loaded once and no concurrent API writer may touch this database.
     */
    exclusiveFastForward?: boolean;
    databaseTransactionTimeoutMs?: number;
    onActionResolved?: NonNullable<Parameters<typeof createReservedTurnHandler>[0]['onActionResolved']>;
}

export interface TurnDaemonRuntime {
    lifecycle: TurnDaemonLifecycle;
    world: InMemoryTurnWorld;
    controlQueue: TurnDaemonControlQueue;
    stateStore: InMemoryTurnStateStore;
    stateManager: EngineStateManager;
    processor: InMemoryTurnProcessor;
    hooks?: TurnDaemonHooks;
    close(): Promise<void>;
}

const resolveTickMinutes = (tickSeconds: number, override?: number): number => {
    if (override !== undefined) {
        return Math.max(1, override);
    }
    return Math.max(1, Math.round(tickSeconds / 60));
};

const buildFixedSchedule = (tickMinutes: number): TurnSchedule => ({
    entries: [{ startMinute: 0, tickMinutes }],
});

const loadOccupiedAuctionUniqueCounts = async (databaseUrl: string): Promise<Map<string, number>> => {
    const connector = createGamePostgresConnector({ url: databaseUrl });
    await connector.connect();
    try {
        const rows = await connector.prisma.auction.findMany({
            where: {
                type: 'UNIQUE_ITEM',
                status: { in: ['OPEN', 'FINALIZING'] },
                targetCode: { not: null },
            },
            select: { targetCode: true },
        });
        const counts = new Map<string, number>();
        for (const row of rows) {
            if (row.targetCode) {
                counts.set(row.targetCode, (counts.get(row.targetCode) ?? 0) + 1);
            }
        }
        return counts;
    } finally {
        await connector.disconnect();
    }
};

const loadArchivedNationMaxId = async (databaseUrl: string, serverId: string): Promise<number> => {
    const connector = createGamePostgresConnector({ url: databaseUrl });
    await connector.connect();
    try {
        const row = await connector.prisma.oldNation.aggregate({
            where: { serverId },
            _max: { nation: true },
        });
        return row._max.nation ?? 0;
    } finally {
        await connector.disconnect();
    }
};

const resolveRedisConfig = (redisUrl?: string, env: NodeJS.ProcessEnv = process.env) => {
    if (redisUrl) {
        return { url: redisUrl };
    }
    if (!env.REDIS_URL) {
        return null;
    }
    return resolveRedisConfigFromEnv(env);
};

const createTurnDaemonRuntimeWithLease = async (
    options: TurnDaemonRuntimeOptions,
    databaseFlushEnabled: boolean,
    turnDaemonLease: DatabaseTurnDaemonLease | null
): Promise<TurnDaemonRuntime> => {
    if (options.exclusiveFastForward && options.profileName) {
        throw new Error('exclusiveFastForward cannot be used with a gateway-managed profile.');
    }
    // DB에서 월드를 읽고 턴 데몬을 구동할 런타임을 만든다.
    const { state, snapshot } = await loadTurnWorldFromDatabase({
        databaseUrl: options.databaseUrl,
        mapOptions: options.mapOptions,
    });
    const clock = options.clock ?? new SystemClock();

    const tickMinutes = resolveTickMinutes(state.tickSeconds, options.tickMinutes);
    const nextTickSeconds = tickMinutes * 60;
    const tickSecondsChanged = options.tickMinutes !== undefined && nextTickSeconds !== state.tickSeconds;
    const clockBaseTime = tickSecondsChanged
        ? GameClock.baseTimeForProjection(
              new GameClock({
                  baseTime: state.clockBaseTime ?? state.lastTurnTime,
                  tick: state.clockTick ?? state.lastTurnTick ?? 0,
                  mode: state.clockMode ?? 'manual',
                  wallAnchor: state.clockWallAnchor ?? state.lastTurnTime,
                  turnSeconds: state.tickSeconds,
              }).tickToDate(state.clockTick ?? state.lastTurnTick ?? 0),
              state.clockTick ?? state.lastTurnTick ?? 0,
              nextTickSeconds
          )
        : state.clockBaseTime;
    const modeChanged = options.gameClockMode !== undefined && options.gameClockMode !== state.clockMode;
    const resolvedState = {
        ...state,
        ...(options.tickMinutes ? { tickSeconds: nextTickSeconds, clockBaseTime } : {}),
        ...(options.gameClockMode ? { clockMode: options.gameClockMode } : {}),
        ...(modeChanged ? { clockWallAnchor: new Date(clock.nowMs()) } : {}),
    };
    const schedule = options.schedule ?? buildFixedSchedule(tickMinutes);
    const hasEventAction = (name: string): boolean =>
        snapshot.events.some(
            (event) =>
                Array.isArray(event.action) &&
                event.action.some((action) => Array.isArray(action) && action[0] === name)
        );
    const eventRequiresReservedTurns =
        hasEventAction('UpdateNationLevel') ||
        hasEventAction('CreateManyNPC') ||
        hasEventAction('RegNPC') ||
        hasEventAction('RegNeutralNPC') ||
        hasEventAction('RaiseNPCNation') ||
        hasEventAction('RaiseInvader') ||
        hasEventAction('AutoDeleteInvader') ||
        hasEventAction('ProvideNPCTroopLeader');
    const reservedTurnStoreHandle =
        options.generalTurnHandler && !eventRequiresReservedTurns
            ? null
            : await createReservedTurnStore({
                  databaseUrl: options.databaseUrl,
                  leaseOwner: options.leaseOwnerId,
                  leaseDurationMs: options.leaseDurationMs,
              });
    const commandProfile =
        options.commandProfile ??
        (options.commandProfilePath
            ? await loadTurnCommandProfile({
                  filePath: options.commandProfilePath,
              })
            : await loadTurnCommandProfile());
    let worldRef: InMemoryTurnWorld | null = null;
    let redisConnector: ReturnType<typeof createRedisConnector> | null = null;
    const nationTraits = await loadNationTraitModules([...NATION_TRAIT_KEYS], new NationTraitLoader());
    const nationTraitMap = new Map(nationTraits.map((module) => [module.key, module]));
    const monthlyActionModules = await loadActionModuleBundle(
        snapshot.unitSet,
        snapshot.scenarioConfig.environment.scenarioEffect
    );
    const monthlyCommandEnv = buildCommandEnv(snapshot.scenarioConfig, snapshot.unitSet);
    const incomeHandler = createIncomeHandler({
        getWorld: () => worldRef,
        scenarioConfig: snapshot.scenarioConfig,
        nationTraits: nationTraitMap,
    });
    const eventActions = new Map<string, MonthlyEventActionHandler>();
    eventActions.set(
        'RandomizeCityTradeRate',
        createRandomizeCityTradeRateHandler({
            getWorld: () => worldRef,
        })
    );
    eventActions.set(
        'RaiseDisaster',
        createRaiseDisasterHandler({
            getWorld: () => worldRef,
            generalActionModules: monthlyActionModules.general,
        })
    );
    eventActions.set(
        'UpdateCitySupply',
        createUpdateCitySupplyHandler({
            getWorld: () => worldRef,
            map: snapshot.map,
        })
    );
    eventActions.set(
        'ProcessSemiAnnual',
        createProcessSemiAnnualHandler({
            getWorld: () => worldRef,
            nationTraits: nationTraitMap,
        })
    );
    eventActions.set(
        'ProcessWarIncome',
        createProcessWarIncomeHandler({
            getWorld: () => worldRef,
            nationTraits: nationTraitMap,
        })
    );
    eventActions.set('CreateAdminNPC', createCreateAdminNpcHandler());
    if (reservedTurnStoreHandle) {
        eventActions.set(
            'CreateManyNPC',
            createCreateManyNpcHandler({
                getWorld: () => worldRef,
                reservedTurns: reservedTurnStoreHandle.store,
                env: monthlyCommandEnv,
            })
        );
        for (const actionName of ['RegNPC', 'RegNeutralNPC'] as const) {
            eventActions.set(
                actionName,
                createRegisterNpcHandler({
                    actionName,
                    getWorld: () => worldRef,
                    reservedTurns: reservedTurnStoreHandle.store,
                    env: monthlyCommandEnv,
                    worldConfig: snapshot.worldConfig,
                    scenarioFiction: snapshot.scenarioMeta?.fiction,
                })
            );
        }
        eventActions.set(
            'RaiseNPCNation',
            createRaiseNpcNationHandler({
                getWorld: () => worldRef,
                reservedTurns: reservedTurnStoreHandle.store,
                env: monthlyCommandEnv,
                map: snapshot.map,
                loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
            })
        );
        eventActions.set(
            'RaiseInvader',
            createRaiseInvaderHandler({
                getWorld: () => worldRef,
                reservedTurns: reservedTurnStoreHandle.store,
                env: monthlyCommandEnv,
                loadArchivedNationMaxId: (serverId) => loadArchivedNationMaxId(options.databaseUrl, serverId),
            })
        );
        eventActions.set(
            'AutoDeleteInvader',
            createAutoDeleteInvaderHandler({
                getWorld: () => worldRef,
                reservedTurns: reservedTurnStoreHandle.store,
            })
        );
        eventActions.set(
            'ProvideNPCTroopLeader',
            createProvideNpcTroopLeaderHandler({
                getWorld: () => worldRef,
                reservedTurns: reservedTurnStoreHandle.store,
                env: monthlyCommandEnv,
            })
        );
        eventActions.set(
            'UpdateNationLevel',
            createUpdateNationLevelHandler({
                getWorld: () => worldRef,
                reservedTurns: reservedTurnStoreHandle.store,
                itemModules: monthlyActionModules.itemModules,
                loadAdditionalOccupiedUniqueCounts: () => loadOccupiedAuctionUniqueCounts(options.databaseUrl),
            })
        );
    }
    eventActions.set(
        'InvaderEnding',
        createInvaderEndingHandler({
            getWorld: () => worldRef,
        })
    );
    eventActions.set(
        'ChangeCity',
        createChangeCityHandler({
            getWorld: () => worldRef,
        })
    );
    eventActions.set(
        'OpenNationBetting',
        createOpenNationBettingHandler({
            getWorld: () => worldRef,
        })
    );
    eventActions.set(
        'FinishNationBetting',
        createFinishNationBettingHandler({
            getWorld: () => worldRef,
        })
    );
    for (const actionName of ['BlockScoutAction', 'UnblockScoutAction'] as const) {
        eventActions.set(
            actionName,
            createScoutBlockHandler({
                actionName,
                getWorld: () => worldRef,
            })
        );
    }
    eventActions.set(
        'AssignGeneralSpeciality',
        createAssignGeneralSpecialityHandler({
            getWorld: () => worldRef,
        })
    );
    eventActions.set(
        'AddGlobalBetray',
        createAddGlobalBetrayHandler({
            getWorld: () => worldRef,
        })
    );
    eventActions.set(
        'LostUniqueItem',
        createLostUniqueItemHandler({
            getWorld: () => worldRef,
            itemModules: monthlyActionModules.itemModules,
        })
    );
    eventActions.set(
        'MergeInheritPointRank',
        createMergeInheritPointRankHandler({
            getWorld: () => worldRef,
        })
    );
    eventActions.set('ProcessIncome', createProcessIncomeActionHandler(incomeHandler));
    eventActions.set('NoticeToHistoryLog', createNoticeToHistoryLogHandler({ getWorld: () => worldRef }));
    eventActions.set('NewYear', createNewYearHandler({ getWorld: () => worldRef }));
    eventActions.set('ResetOfficerLock', createResetOfficerLockHandler({ getWorld: () => worldRef }));
    const monthlyEventHandler = createMonthlyEventHandler({
        getWorld: () => worldRef,
        startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
        actions: eventActions,
    });
    const unification = options.calendarHandler
        ? null
        : createUnificationHandler({
              profileName: options.profileName ?? options.profile,
              getWorld: () => worldRef,
              loadPendingUniqueAuctions: databaseFlushEnabled
                  ? () => loadPendingUnificationAuctionCancellations(options.databaseUrl)
                  : undefined,
              dispatchUnitedEvents: (context) => monthlyEventHandler.dispatchTarget('united', context),
          });
    const nationTurnMonthlyHandler = createNationTurnMonthlyHandler({
        getWorld: () => worldRef,
    });
    const monthlyBoundaryPreHandler = createMonthlyBoundaryPreHandler({
        getWorld: () => worldRef,
        startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
        commandEnv: monthlyCommandEnv,
    });
    let monthlyNationPowerRollCount = snapshot.nations.length;
    let monthlyTournamentRollConsumed = false;
    const monthlyNationStatsHandler = createMonthlyNationStatsHandler({
        getWorld: () => worldRef,
        onNationPowerRollCount: (count) => {
            monthlyNationPowerRollCount = count;
        },
    });
    const monthlyDiplomacyHandler = createMonthlyDiplomacyHandler({
        getWorld: () => worldRef,
    });
    const monthlyNationCountHandler = createMonthlyNationCountHandler({
        getWorld: () => worldRef,
    });
    const monthlyWarSettingHandler = createMonthlyWarSettingHandler({
        getWorld: () => worldRef,
    });
    const monthlyWanderHandler = createMonthlyWanderHandler({
        getWorld: () => worldRef,
        startYear: snapshot.scenarioMeta?.startYear ?? state.currentYear,
        commandEnv: monthlyCommandEnv,
    });
    const frontStateHandler = createFrontStateHandler({
        getWorld: () => worldRef,
        map: snapshot.map ?? null,
    });
    const neutralAuctionRegistrar = await createNeutralAuctionRegistrar({
        databaseUrl: options.databaseUrl,
        profileName: options.profileName ?? options.profile,
        getWorld: () => worldRef,
        getRedisClient: () => redisConnector?.client,
        getWorldConfig: () => snapshot.worldConfig ?? null,
        getNationPowerRollCount: () => monthlyNationPowerRollCount,
        getTournamentRollConsumed: () => monthlyTournamentRollConsumed,
        now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()),
    });
    const tournamentAutoStartHandler = createTournamentAutoStartHandler({
        profileName: options.profileName ?? options.profile,
        getWorld: () => worldRef,
        getRedisClient: () => redisConnector?.client,
        getWorldConfig: () => snapshot.worldConfig ?? null,
        getNationPowerRollCount: () => monthlyNationPowerRollCount,
        onTournamentRollConsumed: (consumed) => {
            monthlyTournamentRollConsumed = consumed;
        },
        // Deterministic/manual runtimes must schedule the tournament from the
        // same clock that advances the game world. Production still falls
        // back to the system clock.
        now: () => worldRef?.getGameNow(new Date(clock.nowMs())) ?? new Date(clock.nowMs()),
    });
    const yearbookHandler = createYearbookHandler({
        profileName: options.profileName ?? options.profile,
        getWorld: () => worldRef,
    });
    const calendarHandler = composeCalendarHandlers(
        monthlyEventHandler,
        hasEventAction('ProcessIncome') ? null : incomeHandler,
        yearbookHandler.handler,
        monthlyBoundaryPreHandler,
        nationTurnMonthlyHandler,
        monthlyNationStatsHandler,
        monthlyDiplomacyHandler,
        monthlyWarSettingHandler,
        monthlyWanderHandler,
        monthlyNationCountHandler,
        options.calendarHandler ?? unification?.handler,
        tournamentAutoStartHandler,
        neutralAuctionRegistrar.handler,
        frontStateHandler
    );
    let occupiedAuctionUniqueItemKeys: string[] = [];
    let refreshOccupiedAuctionUniqueItemKeys = async (): Promise<void> => {};
    const prefetchedNationTurns = new Set<string>();
    const worldOptions: InMemoryTurnWorldOptions = {
        schedule,
        generalTurnHandler:
            options.generalTurnHandler ??
            (await createReservedTurnHandler({
                reservedTurns: reservedTurnStoreHandle!.store,
                scenarioConfig: snapshot.scenarioConfig,
                scenarioMeta: snapshot.scenarioMeta,
                map: snapshot.map,
                unitSet: snapshot.unitSet,
                getWorld: () => worldRef,
                commandProfile,
                commandEnv: monthlyCommandEnv,
                calculateNpcNationFinance: (financeWorld, nation, currentMonth) =>
                    calculateNpcNationFinance(financeWorld, nation, currentMonth, {
                        commandEnv: monthlyCommandEnv,
                        scenarioConfig: snapshot.scenarioConfig,
                        unitSet: snapshot.unitSet,
                        nationTraits: nationTraitMap,
                    }),
                getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
                onActionResolved: options.onActionResolved,
            })),
        calendarHandler: calendarHandler ?? undefined,
        autoAdvanceDiplomacyMonth: false,
    };
    const world = new InMemoryTurnWorld(resolvedState, snapshot, worldOptions);
    worldRef = world;

    const stateManager = new EngineStateManager();
    stateManager.register('world', {
        capture: () => world.captureState(),
        restore: (captured) => world.restoreState(captured),
        inspect: () => world.inspectState(),
    });
    if (reservedTurnStoreHandle) {
        stateManager.register('reservedTurns', {
            capture: () => reservedTurnStoreHandle.store.captureTransactionState(),
            restore: (captured) => reservedTurnStoreHandle.store.restoreState(captured),
            inspect: () => reservedTurnStoreHandle.store.inspectState(),
        });
    }
    stateManager.register('runtimeCaches', {
        capture: () => ({
            monthlyNationPowerRollCount,
            monthlyTournamentRollConsumed,
            occupiedAuctionUniqueItemKeys: [...occupiedAuctionUniqueItemKeys],
            prefetchedNationTurns: Array.from(prefetchedNationTurns),
        }),
        restore: (captured) => {
            monthlyNationPowerRollCount = captured.monthlyNationPowerRollCount;
            monthlyTournamentRollConsumed = captured.monthlyTournamentRollConsumed;
            occupiedAuctionUniqueItemKeys = [...captured.occupiedAuctionUniqueItemKeys];
            prefetchedNationTurns.clear();
            for (const key of captured.prefetchedNationTurns) {
                prefetchedNationTurns.add(key);
            }
        },
    });

    const stateStore = new InMemoryTurnStateStore(world);
    let fastForwardPreparedMonth = '';
    const processor = new InMemoryTurnProcessor(world, {
        tickMinutes,
        beforeExecuteGeneral: reservedTurnStoreHandle
            ? async (general) => {
                  if (options.exclusiveFastForward) {
                      const state = world.getState();
                      const monthKey = `${state.currentYear}-${state.currentMonth}`;
                      if (fastForwardPreparedMonth !== monthKey) {
                          await refreshOccupiedAuctionUniqueItemKeys();
                          fastForwardPreparedMonth = monthKey;
                      }
                      return;
                  }
                  const promises: Promise<unknown>[] = [];
                  promises.push(
                      reservedTurnStoreHandle.store.prepareTurnsForExecution(
                          general.id,
                          general.nationId > 0 && general.officerLevel >= 5
                              ? {
                                    nationId: general.nationId,
                                    officerLevel: general.officerLevel,
                                }
                              : undefined
                      )
                  );
                  promises.push(refreshOccupiedAuctionUniqueItemKeys());
                  if (general.nationId > 0 && general.officerLevel >= 5 && shouldUseAi(general, world.getState())) {
                      const key = `${general.nationId}:${world.getState().currentYear}:${world.getState().currentMonth}`;
                      if (!prefetchedNationTurns.has(key)) {
                          prefetchedNationTurns.add(key);
                          const generalIds = world
                              .listGenerals()
                              .filter((candidate) => candidate.nationId === general.nationId)
                              .map((candidate) => candidate.id);
                          promises.push(reservedTurnStoreHandle.store.prefetchGeneralTurns(generalIds));
                      }
                  }
                  await Promise.all(promises);
              }
            : undefined,
    });
    const controlQueue = options.controlQueue ?? new InMemoryControlQueue();

    let hooks: TurnDaemonHooks | undefined;
    let publishRealtimeEvent: ((event: RealtimeEvent) => Promise<void>) | null = null;
    let publishReadModelChanges: ((changes: RealtimeReadModelChanges) => Promise<number>) | null = null;
    let takeCommittedReadModelChanges: (() => RealtimeReadModelChanges | null) | null = null;
    let close = async () => {};
    let auctionFinalizer: Awaited<ReturnType<typeof createAuctionFinalizer>> | null = null;
    let auctionBidder: Awaited<ReturnType<typeof createAuctionBidder>> | null = null;
    let tournamentRewardFinalizer: Awaited<ReturnType<typeof createTournamentRewardFinalizer>> | null = null;
    let pauseGate: (() => Promise<boolean>) | undefined;
    let adminActionConsumer: Awaited<ReturnType<typeof createGatewayAdminActionConsumer>> | null = null;
    const gatewayGate = options.profileName
        ? await createGatewayProfileGate({
              databaseUrl: options.databaseUrl,
              gatewayDatabaseUrl: options.gatewayDatabaseUrl,
              profileName: options.profileName,
              cacheMs: options.pauseGateIntervalMs,
          })
        : null;
    if (gatewayGate) {
        pauseGate = gatewayGate.shouldPause;
    }
    if (databaseFlushEnabled) {
        const dbHooks = await createDatabaseTurnHooks(options.databaseUrl, world, {
            profileName: options.profileName ?? options.profile,
            reservedTurns: reservedTurnStoreHandle?.store,
            turnDaemonLease: turnDaemonLease ?? undefined,
            transactionTimeoutMs: options.databaseTransactionTimeoutMs,
        });
        auctionBidder = await createAuctionBidder({
            databaseUrl: options.databaseUrl,
            world,
        });
        auctionFinalizer = await createAuctionFinalizer({
            databaseUrl: options.databaseUrl,
            world,
        });
        tournamentRewardFinalizer = await createTournamentRewardFinalizer({
            databaseUrl: options.databaseUrl,
            world,
        });
        hooks = {
            ...dbHooks.hooks,
            onRunError: async (error) => {
                await dbHooks.hooks.onRunError?.(error);
                await gatewayGate?.markPaused(error);
            },
        };
        takeCommittedReadModelChanges = dbHooks.takeCommittedReadModelChanges;
        close = async () => {
            if (auctionBidder) {
                await auctionBidder.close();
            }
            if (auctionFinalizer) {
                await auctionFinalizer.close();
            }
            if (tournamentRewardFinalizer) {
                await tournamentRewardFinalizer.close();
            }
            await dbHooks.close();
            if (reservedTurnStoreHandle) {
                await reservedTurnStoreHandle.close();
            }
            await gatewayGate?.close();
            await adminActionConsumer?.stop();
            await turnDaemonLease?.close();
        };
    } else if (reservedTurnStoreHandle) {
        hooks = {
            onRunError: async (error) => {
                await gatewayGate?.markPaused(error);
            },
        };
        close = async () => {
            await reservedTurnStoreHandle.close();
            await gatewayGate?.close();
            await adminActionConsumer?.stop();
        };
    } else if (gatewayGate) {
        hooks = {
            onRunError: async (error) => {
                await gatewayGate?.markPaused(error);
            },
        };
        close = async () => {
            await gatewayGate.close();
            await adminActionConsumer?.stop();
        };
    } else if (adminActionConsumer) {
        close = async () => {
            await adminActionConsumer?.stop();
        };
    }

    const redisConfig = resolveRedisConfig(options.redisUrl);
    if (redisConfig) {
        redisConnector = createRedisConnector(redisConfig);
        await redisConnector.connect();
        const redisClient = redisConnector.client;
        const realtimeChannel = buildGameEventChannel(options.profileName ?? options.profile);
        publishRealtimeEvent = async (event: RealtimeEvent) => {
            await redisClient.publish(realtimeChannel, JSON.stringify(event));
        };
        const revisionKey = buildGameReadModelRevisionKey(options.profileName ?? options.profile);
        const domainRevisionKey = buildGameReadModelDomainRevisionKey(options.profileName ?? options.profile);
        publishReadModelChanges = async (changes) => {
            if (
                changes.worldChanged ||
                (changes.mapCityIds ?? changes.cityIds).length > 0 ||
                (changes.mapNationIds ?? changes.nationIds).length > 0
            ) {
                await redisClient.hIncrBy(domainRevisionKey, 'world', 1);
            }
            return redisClient.incr(revisionKey);
        };
    }

    if (publishRealtimeEvent) {
        const basePublishEvents = hooks?.publishEvents;
        const basePublishCommandEvents = hooks?.publishCommandEvents;
        const publishCommittedChanges = async (changes: RealtimeReadModelChanges): Promise<number | undefined> => {
            if (!hasRealtimeReadModelChanges(changes)) {
                return undefined;
            }
            return publishReadModelChanges?.(changes);
        };
        // Durable mutation summaries invalidate only the affected read models.
        hooks = {
            ...hooks,
            publishEvents: async (result) => {
                try {
                    const changes = takeCommittedReadModelChanges?.() ?? createEmptyRealtimeReadModelChanges();
                    if (result.processedTurns > 0) {
                        changes.worldChanged = true;
                    }
                    const revision = await publishCommittedChanges(changes);
                    await publishRealtimeEvent({
                        type: 'turnCompleted',
                        at: new Date().toISOString(),
                        lastTurnTime: result.lastTurnTime,
                        changes,
                        revision,
                    });
                } catch {
                    // 실시간 이벤트 전송 실패는 턴 처리 결과에 영향을 주지 않는다.
                }
                await basePublishEvents?.(result);
            },
            publishCommandEvents: async (result) => {
                try {
                    const changes = takeCommittedReadModelChanges?.();
                    if (changes && result.type === 'shiftSchedule' && result.ok) {
                        changes.lobbyChanged = true;
                    }
                    if (changes && hasRealtimeReadModelChanges(changes)) {
                        const revision = await publishCommittedChanges(changes);
                        if (revision !== undefined) {
                            await publishRealtimeEvent({
                                type: 'readModelChanged',
                                at: new Date().toISOString(),
                                changes,
                                revision,
                            });
                        }
                    }
                } catch {
                    // 명령은 이미 commit되었으므로 이벤트 실패로 되돌리지 않는다.
                }
                await basePublishCommandEvents?.(result);
            },
        };
    }

    const commandConnector = hooks ? createGamePostgresConnector({ url: options.databaseUrl }) : null;
    const databaseCommandQueue = commandConnector ? new DatabaseTurnDaemonCommandQueue(commandConnector.prisma) : null;
    if (commandConnector && databaseCommandQueue) {
        await commandConnector.connect();
        await databaseCommandQueue.initialize();
        refreshOccupiedAuctionUniqueItemKeys = async () => {
            const rows = await commandConnector.prisma.auction.findMany({
                where: {
                    type: 'UNIQUE_ITEM',
                    status: { in: ['OPEN', 'FINALIZING'] },
                    targetCode: { not: null },
                },
                select: { targetCode: true },
            });
            occupiedAuctionUniqueItemKeys = rows.flatMap((row) => (row.targetCode ? [row.targetCode] : []));
        };
    }

    const baseClose = close;
    close = async () => {
        await baseClose();
        await neutralAuctionRegistrar.close();
        if (redisConnector) {
            await redisConnector.disconnect();
        }
        await commandConnector?.disconnect();
    };

    const resolvedControlQueue = options.controlQueue ?? databaseCommandQueue ?? controlQueue;
    const commandHandler = createTurnDaemonCommandHandler({
        world,
        reservedTurns: reservedTurnStoreHandle?.store,
        scenarioMeta: snapshot.scenarioMeta,
        map: snapshot.map,
        commandProfile,
        getAdditionalOccupiedUniqueItemKeys: () => occupiedAuctionUniqueItemKeys,
        auctionFinalizer: auctionFinalizer ?? undefined,
        auctionBidder: auctionBidder ?? undefined,
        tournamentRewardFinalizer: tournamentRewardFinalizer ?? undefined,
    });

    const defaultBudget: TurnRunBudget = options.defaultBudget ?? {
        budgetMs: 5000,
        maxGenerals: 200,
        catchUpCap: 1,
    };

    const lifecycle = new TurnDaemonLifecycle(
        {
            clock,
            controlQueue: resolvedControlQueue,
            getNextTickTime: (lastTurnTime) => getNextTickTime(lastTurnTime, tickMinutes),
            stateStore,
            processor,
            hooks,
            pauseGate: async () => turnDaemonLease?.isLost() || ((await pauseGate?.()) ?? false),
            commandHandler,
            commandResponder: options.controlQueue ? undefined : (databaseCommandQueue ?? undefined),
            // The exclusive fixture runner aborts the entire in-memory runtime
            // on failure and has no concurrent writer. Avoid cloning the whole
            // accumulated world before every due tick in that isolated mode;
            // production and gateway-managed runtimes keep rollback savepoints.
            stateManager: options.exclusiveFastForward ? undefined : stateManager,
        },
        { profile: options.profile, defaultBudget }
    );

    if (options.profileName) {
        adminActionConsumer = await createGatewayAdminActionConsumer({
            databaseUrl: options.databaseUrl,
            gatewayDatabaseUrl: options.gatewayDatabaseUrl,
            profileName: options.profileName,
            pollIntervalMs: options.adminActionIntervalMs,
            handler: async (action) => {
                const reason = action.reason ?? `admin:${action.action ?? 'action'}`;
                if (turnDaemonLease?.isLost()) {
                    return { status: 'REQUESTED', detail: 'turn-daemon lease 재획득을 기다리는 중입니다.' };
                }
                if (action.action === 'RESET_NOW' || action.action === 'RESET_SCHEDULED') {
                    // 리셋은 오케스트레이터에서 빌드+재기동으로 처리한다.
                    return { status: 'REQUESTED', detail: 'waiting for orchestrator reset' };
                }
                if (action.action === 'ACCELERATE' || action.action === 'DELAY') {
                    if (!commandConnector) {
                        return { status: 'FAILED', detail: '게임 command database 연결이 없습니다.' };
                    }
                    return applyRuntimeClockShift({
                        action,
                        profileName: options.profileName!,
                        db: commandConnector.prisma,
                        redis: redisConnector?.client,
                    });
                }
                switch (action.action) {
                    case 'RESUME':
                        resolvedControlQueue.enqueue({ type: 'resume', reason });
                        return { status: 'APPLIED', detail: 'resume queued' };
                    case 'PAUSE':
                        resolvedControlQueue.enqueue({ type: 'pause', reason });
                        return { status: 'APPLIED', detail: 'pause queued' };
                    case 'STOP':
                    case 'SHUTDOWN':
                        resolvedControlQueue.enqueue({ type: 'shutdown', reason });
                        return { status: 'APPLIED', detail: 'shutdown queued' };
                    default:
                        return { status: 'IGNORED', detail: 'not implemented' };
                }
            },
        });
        adminActionConsumer.start();
    }

    return {
        lifecycle,
        world,
        controlQueue: resolvedControlQueue,
        stateStore,
        stateManager,
        processor,
        hooks,
        close,
    };
};

export const createTurnDaemonRuntime = async (options: TurnDaemonRuntimeOptions): Promise<TurnDaemonRuntime> => {
    const databaseFlushEnabled = options.enableDatabaseFlush ?? true;
    const turnDaemonLease = databaseFlushEnabled
        ? await DatabaseTurnDaemonLease.connect(options.databaseUrl, {
              profile: options.profileName ?? options.profile,
              ownerId: options.leaseOwnerId,
              leaseDurationMs: options.leaseDurationMs,
              heartbeat: options.enableLeaseHeartbeat,
          })
        : null;
    if (turnDaemonLease && !(await turnDaemonLease.acquire())) {
        await turnDaemonLease.close();
        throw new TurnDaemonLeaseUnavailableError(options.profileName ?? options.profile);
    }
    try {
        return await createTurnDaemonRuntimeWithLease(options, databaseFlushEnabled, turnDaemonLease);
    } catch (error) {
        await turnDaemonLease?.close();
        throw error;
    }
};
