import { businessDate, isWithinSchedule } from '@pintrush/db';
import type { Clock, TimerHandle } from '../clock';
import { log } from '../logger';
import type { Catalog, SessionStore } from '../store/types';
import { RoomActor, type RoomActorDeps } from './RoomActor';

const SCHEDULER_MS = 15_000;
const PURGE_MS = 24 * 3_600_000;
const GUEST_RETENTION_MS = 90 * 24 * 3_600_000;
/** Championship lobbies open this long before the scheduled start. */
const LOBBY_LEAD_MS = 15 * 60_000;
export const DEMO_VENUE_SLUG = 'demo';

export interface RoomManagerDeps extends Omit<RoomActorDeps, 'store'> {
  durableStore: SessionStore;
  /** Store for the public demo room (non-durable: bots play around the clock). */
  demoStore: SessionStore;
}

/** Loads RoomActors on demand, recovers live sessions on boot, runs the scheduler and the purge job. */
export class RoomManager {
  private readonly actors = new Map<string, RoomActor>();
  private readonly loading = new Map<string, Promise<RoomActor | null>>();
  private schedulerTimer: TimerHandle | null = null;
  private purgeTimer: TimerHandle | null = null;
  private schedulerRunning = false;

  constructor(private readonly deps: RoomManagerDeps) {}

  get catalog(): Catalog {
    return this.deps.catalog;
  }

  get clock(): Clock {
    return this.deps.clock;
  }

  loadedActors(): RoomActor[] {
    return [...this.actors.values()];
  }

  async getActor(roomId: string): Promise<RoomActor | null> {
    const existing = this.actors.get(roomId);
    if (existing) return existing;
    const pending = this.loading.get(roomId);
    if (pending) return pending;
    const p = (async () => {
      const room = await this.deps.catalog.findRoom(roomId);
      if (!room || room.status !== 'ACTIVE') return null;
      const venue = await this.deps.catalog.findVenue(room.venueId);
      if (!venue) return null;
      const isDemo = venue.slug === DEMO_VENUE_SLUG;
      const actor = new RoomActor({ ...this.deps, store: isDemo ? this.deps.demoStore : this.deps.durableStore }, room, venue, isDemo);
      this.actors.set(roomId, actor);
      void actor.refreshAttract();
      return actor;
    })().finally(() => this.loading.delete(roomId));
    this.loading.set(roomId, p);
    return p;
  }

  /** Resolve the room a QR code points at: an explicit room of that venue, else its first active room. */
  async resolveJoin(venueCode: string, roomId?: string): Promise<RoomActor | null> {
    const venue = await this.deps.catalog.findVenueByJoinCode(venueCode);
    if (!venue) return null;
    const rooms = await this.deps.catalog.listRooms(venue.id);
    const room = (roomId && rooms.find((r) => r.id === roomId)) || rooms[0];
    return room ? this.getActor(room.id) : null;
  }

  /** Reload every session that was live when the process stopped. */
  async recover(): Promise<number> {
    const live = await this.deps.durableStore.loadLiveSessions();
    // Always-on first so a recovered championship ends up as the active session.
    live.sort((a, b) => (a.event.kind === b.event.kind ? 0 : a.event.kind === 'ALWAYS_ON' ? -1 : 1));
    let n = 0;
    for (const { session, event } of live) {
      if (!session.state) continue;
      const actor = await this.getActor(session.roomId);
      if (!actor) continue;
      try {
        await actor.restoreSession(event, session.id, session.state, session.seq, session.stateVersion);
        n++;
      } catch (err) {
        log.error('session recovery failed', { sessionId: session.id, err });
      }
    }
    return n;
  }

  start(): void {
    if (!this.deps.config.enableScheduler) return;
    void this.runScheduler();
    this.schedulerTimer = this.deps.clock.setInterval(() => void this.runScheduler(), SCHEDULER_MS);
    void this.runPurge();
    this.purgeTimer = this.deps.clock.setInterval(() => void this.runPurge(), PURGE_MS);
  }

  /** Always-on sessions follow each venue's opening hours; championship lobbies open 15 min early. */
  async runScheduler(): Promise<void> {
    if (this.schedulerRunning) return;
    this.schedulerRunning = true;
    const now = this.deps.clock.now();
    try {
      const venues = await this.deps.catalog.listAlwaysOnVenues();
      const alwaysOnVenueIds = new Set(venues.map((v) => v.id));
      for (const venue of venues) {
        const open = isWithinSchedule(venue.settings, venue.timezone, now);
        const date = businessDate(venue.timezone, now);
        for (const room of await this.deps.catalog.listRooms(venue.id)) {
          try {
            const loaded = this.actors.get(room.id);
            // A no-show lobby must never block always-on (and last night's session closes at the rollover).
            await loaded?.expireStaleLobbies();
            await loaded?.endStaleAlwaysOn(date);
            if (open) {
              const actor = loaded ?? (await this.getActor(room.id));
              await actor?.ensureAlwaysOn(date);
            } else {
              // Closing time: pause, so reopening the same business date resumes tonight's game.
              await loaded?.pauseAlwaysOn();
            }
          } catch (err) {
            log.error('always-on scheduling failed', { roomId: room.id, err });
          }
        }
      }
      // Venues that switched always-on off: pause tonight's session; close old ones; close stale lobbies.
      for (const actor of this.actors.values()) {
        if (alwaysOnVenueIds.has(actor.venueId)) continue;
        await actor.expireStaleLobbies();
        const venue = await this.deps.catalog.findVenue(actor.venueId);
        if (venue) await actor.endStaleAlwaysOn(businessDate(venue.timezone, now));
        await actor.pauseAlwaysOn();
      }
      for (const event of await this.deps.catalog.championshipsToOpen(now + LOBBY_LEAD_MS)) {
        try {
          const actor = await this.getActor(event.roomId);
          await actor?.openEvent(event.id);
        } catch (err) {
          log.error('could not open championship lobby', { eventId: event.id, err });
        }
      }
      for (const actor of this.actors.values()) await actor.refreshAttract();
      this.unloadIdle();
    } catch (err) {
      log.error('scheduler run failed', { err });
    } finally {
      this.schedulerRunning = false;
    }
  }

  private unloadIdle(): void {
    for (const [id, actor] of this.actors) {
      if (actor.sessionCount === 0 && actor.socketCount === 0) {
        void actor.stop();
        this.actors.delete(id);
      }
    }
  }

  async runPurge(): Promise<void> {
    try {
      const r = await this.deps.durableStore.purgeGuestData(this.deps.clock.now() - GUEST_RETENTION_MS);
      if (r.players || r.audits) log.info('purged guest data older than 90 days', r);
      // The demo room keeps everything in memory; drop finished demo sessions and day-old players.
      await this.deps.demoStore.purgeGuestData(this.deps.clock.now() - 24 * 3_600_000);
    } catch (err) {
      log.error('guest data purge failed', { err });
    }
  }

  /** Stop accepting commands in every room (first step of a graceful shutdown). */
  beginShutdown(): void {
    for (const a of this.actors.values()) a.beginShutdown();
  }

  async stop(): Promise<void> {
    if (this.schedulerTimer) this.deps.clock.clearInterval(this.schedulerTimer);
    if (this.purgeTimer) this.deps.clock.clearInterval(this.purgeTimer);
    await Promise.all([...this.actors.values()].map((a) => a.stop()));
    this.actors.clear();
  }
}
