import { MAX_ACCUMULATED_MS } from '../shared/constants';
import type {
  GameState,
  EnemyState,
  HeroState,
  FloatingDamageData,
  LootDrop,
  TownData,
  NearbyHeroData,
  NPCData,
  BuildingData,
} from './types';
import { GamePhase } from './types';
import { GameRenderer, worldToScreen } from './renderer';
import { buildWorldTerrainContext } from './procedural';
import { Camera } from './camera';
import { getViewport } from '../shared/telegram';

/**
 * Server-authoritative game engine.
 *
 * This engine does NOT simulate walking, combat, encounters, or any
 * game logic. All state comes from the server via WebSocket messages.
 *
 * Responsibilities:
 *   - Hold current game state (hero, enemy, phase)
 *   - Interpolate hero position between server updates (2 Hz -> 60 fps)
 *   - Run the requestAnimationFrame render loop
 *   - Camera follow + shake
 *   - Emit state change and damage callbacks for React UI
 *   - Thought bubble display (server-driven rest state)
 */

// ---- Thought Bubble Pool ----

const HERO_THOUGHTS = [
  "I wonder what's ahead...",
  'Need better gear...',
  'Getting stronger!',
  'This forest is huge',
  'I smell adventure',
  'Gold... need more gold',
  'What was that sound?',
  'Time to rest a bit',
  'My sword needs sharpening',
  'Almost leveled up!',
];

// ---- Callbacks ----

export type DamageCallback = (damage: FloatingDamageData) => void;

/** Drift threshold in world tiles before snapping to server position. */
const POSITION_DRIFT_SNAP_THRESHOLD = 2.0;

/** Interpolation window matching the server's 2 Hz send rate. */
const MOVE_UPDATE_INTERVAL_S = 0.5;

export class GameEngine {
  renderer: GameRenderer;
  camera: Camera;

  private _running = false;
  private _rafId: number | null = null;
  private _lastTime = 0;

  /** Current game state (exposed to React via onStateChange) */
  private _gameState: GameState = {
    phase: GamePhase.Walking,
    hero: null,
    enemy: null,
    loot: null,
    lastVictoryLoot: null,
    tick: 0,
    serverTimeMs: 0,
    routeWaypoints: null,
  };

  // ---- Server-driven position interpolation ----
  private _heroDisplayX = 0;
  private _heroDisplayY = 0;
  private _prevPositionX = 0;
  private _prevPositionY = 0;
  private _targetPositionX = 0;
  private _targetPositionY = 0;
  _moveTargetX = 0;
  _moveTargetY = 0;
  _heroSpeed = 0;
  private _lastMoveUpdateTime = 0;

  /** Road waypoints from route_assigned, for optional path rendering. */
  _routeWaypoints: Array<{ x: number; y: number }> = [];

  /** Loot popup auto-clear timer */
  private _lootTimerMs = 0;

  /** Thought bubble text shown during rest pauses (null = hidden). */
  private _thoughtText: string | null = null;
  private _thoughtStartMs = 0;

  /** Towns for map rendering */
  private _towns: TownData[] = [];
  /** All NPCs from towns for rendering */
  private _allNPCs: NPCData[] = [];
  /** Nearby heroes from the shared world (polled periodically) */
  private _nearbyHeroes: NearbyHeroData[] = [];

  /** Callbacks */
  private _onStateChange: ((state: GameState) => void) | null = null;
  private _onDamage: DamageCallback | null = null;
  private _handleResize: (() => void) | null = null;

  constructor() {
    this.renderer = new GameRenderer();
    this.camera = new Camera();
  }

  get running(): boolean {
    return this._running;
  }

  get gameState(): GameState {
    return this._gameState;
  }

  /** Current thought text if hero is resting (null otherwise). */
  get thoughtText(): string | null {
    return this._thoughtText;
  }

  /** performance.now() when the thought started, for fade animation. */
  get thoughtStartMs(): number {
    return this._thoughtStartMs;
  }

  // ---- Callback Registration ----

  /** Register a callback for game state changes (for React UI) */
  onStateChange(cb: (state: GameState) => void): void {
    this._onStateChange = cb;
  }

  /** Register a callback for damage events (floating numbers) */
  onDamage(cb: DamageCallback): void {
    this._onDamage = cb;
  }

  // ---- Data Setters (static data from REST init) ----

  /** Set the hero display name on the renderer label. */
  setHeroName(name: string): void {
    this.renderer.setHeroName(name);
  }

  /** Set towns for map rendering. */
  setTowns(towns: TownData[]): void {
    this._towns = towns;
    this._syncWorldTerrainContext();
  }

  /** Set NPC data for rendering. */
  setNPCs(npcs: NPCData[]): void {
    this._allNPCs = npcs;
  }

  /** Update the list of nearby heroes for shared-world rendering. */
  setNearbyHeroes(heroes: NearbyHeroData[]): void {
    this._nearbyHeroes = heroes;
  }

  // ---- Server State Application ----

  /**
   * Initialize the engine with hero data from the REST init endpoint.
   * This sets the initial state before WebSocket messages start arriving.
   */
  initFromServer(hero: HeroState, serverHeroState?: string): void {
    const serverDead = serverHeroState?.toLowerCase() === 'dead';
    const serverResting = serverHeroState?.toLowerCase() === 'resting';
    const serverInTown = serverHeroState?.toLowerCase() === 'in_town';

    let phase = GamePhase.Walking;
    if (hero.hp <= 0 || serverDead) phase = GamePhase.Dead;
    else if (serverResting) phase = GamePhase.Resting;
    else if (serverInTown) phase = GamePhase.InTown;

    this._gameState = {
      phase,
      hero,
      enemy: null,
      loot: null,
      lastVictoryLoot: null,
      tick: 0,
      serverTimeMs: 0,
      routeWaypoints: null,
    };

    // Initialize display position
    this._heroDisplayX = hero.position.x || 0;
    this._heroDisplayY = hero.position.y || 0;
    this._prevPositionX = this._heroDisplayX;
    this._prevPositionY = this._heroDisplayY;
    this._targetPositionX = this._heroDisplayX;
    this._targetPositionY = this._heroDisplayY;
    this._lastMoveUpdateTime = performance.now();
    this._lootTimerMs = 0;

    const heroScreen = worldToScreen(this._heroDisplayX, this._heroDisplayY);
    this.camera.setTarget(heroScreen.x, heroScreen.y);
    this.camera.snapToTarget();

    if (phase === GamePhase.Resting || phase === GamePhase.InTown) {
      this._showThought();
    }

    this._notifyStateChange();
  }

  /**
   * Called when server sends hero_move (2 Hz).
   * Smoothly interpolates between positions in the render loop.
   */
  applyHeroMove(
    x: number,
    y: number,
    targetX: number,
    targetY: number,
    speed: number,
  ): void {
    this._prevPositionX = this._heroDisplayX;
    this._prevPositionY = this._heroDisplayY;
    this._targetPositionX = x;
    this._targetPositionY = y;
    this._moveTargetX = targetX;
    this._moveTargetY = targetY;
    this._heroSpeed = speed;
    this._lastMoveUpdateTime = performance.now();

    // Update hero state position to server-known position
    if (this._gameState.hero) {
      this._gameState.hero.position.x = x;
      this._gameState.hero.position.y = y;
    }

    // Server sends hero_move while resting (road + display offset). Do not treat as "left rest".
    const serverResting =
      this._gameState.hero?.serverActivityState?.toLowerCase() === 'resting';
    if (
      !serverResting &&
      (this._gameState.phase === GamePhase.Resting ||
        this._gameState.phase === GamePhase.InTown)
    ) {
      this._gameState = { ...this._gameState, phase: GamePhase.Walking };
      this._thoughtText = null;
    } else if (this._gameState.phase !== GamePhase.Walking) {
      // Don't override fighting/dead phase from move messages
    }
  }

  /**
   * Called when server sends position_sync (every 10s).
   * Snaps to server position if drift exceeds threshold.
   */
  applyPositionSync(x: number, y: number, state: string): void {
    const dx = this._heroDisplayX - x;
    const dy = this._heroDisplayY - y;
    const drift = Math.sqrt(dx * dx + dy * dy);

    if (drift > POSITION_DRIFT_SNAP_THRESHOLD) {
      this._heroDisplayX = x;
      this._heroDisplayY = y;
      this._prevPositionX = x;
      this._prevPositionY = y;
      this._targetPositionX = x;
      this._targetPositionY = y;
    }

    if (this._gameState.hero) {
      this._gameState.hero.position.x = x;
      this._gameState.hero.position.y = y;
    }

    // Sync phase from server state string
    if (state === 'walking' && this._gameState.phase !== GamePhase.Fighting) {
      this._gameState = { ...this._gameState, phase: GamePhase.Walking };
      this._thoughtText = null;
    }
  }

  /**
   * Called when server sends route_assigned.
   * Stores waypoints for optional path rendering.
   */
  applyRouteAssigned(
    waypoints: Array<{ x: number; y: number }>,
    speed: number,
  ): void {
    this._routeWaypoints = waypoints;
    this._heroSpeed = speed;
    this._syncWorldTerrainContext();
    this._gameState = {
      ...this._gameState,
      phase: GamePhase.Walking,
      routeWaypoints: waypoints.length >= 2 ? waypoints.map((p) => ({ x: p.x, y: p.y })) : null,
    };
    this._thoughtText = null;
    this._notifyStateChange();
  }

  /** Rebuild procedural terrain context from towns + current route (ring + active polyline). */
  private _syncWorldTerrainContext(): void {
    if (this._towns.length === 0) {
      this.renderer.setWorldTerrainContext(null);
      return;
    }
    const influences = this._towns.map((t) => ({
      id: t.id,
      cx: t.centerX,
      cy: t.centerY,
      radius: t.radius,
      biome: t.biome,
      levelMin: t.levelMin,
    }));
    const route =
      this._routeWaypoints.length >= 2 ? this._routeWaypoints : null;
    this.renderer.setWorldTerrainContext(
      buildWorldTerrainContext(influences, route),
    );
  }

  /**
   * Apply a full hero state snapshot from the server.
   * Sent on WS connect, after level-up, revive, equipment change.
   */
  applyHeroState(hero: HeroState): void {
    const prevPos = this._gameState.hero?.position;
    // Preserve display position if hero hasn't moved significantly
    if (prevPos) {
      const dx = (hero.position.x || 0) - prevPos.x;
      const dy = (hero.position.y || 0) - prevPos.y;
      if (dx * dx + dy * dy < 0.01) {
        hero.position = prevPos;
      }
    }

    const newX = hero.position.x || 0;
    const newY = hero.position.y || 0;

    this._gameState = {
      ...this._gameState,
      hero,
    };

    const activity = hero.serverActivityState?.toLowerCase();
    if (
      this._gameState.phase !== GamePhase.Fighting &&
      this._gameState.phase !== GamePhase.Dead
    ) {
      if (activity === 'resting') {
        this._gameState = { ...this._gameState, phase: GamePhase.Resting };
        if (!this._thoughtText) this._showThought();
      } else if (activity === 'in_town') {
        this._gameState = { ...this._gameState, phase: GamePhase.InTown };
        if (!this._thoughtText) this._showThought();
      } else if (activity === 'walking') {
        this._gameState = { ...this._gameState, phase: GamePhase.Walking };
        this._thoughtText = null;
      }
    }

    {
      const tdx = newX - this._targetPositionX;
      const tdy = newY - this._targetPositionY;
      if (
        tdx * tdx + tdy * tdy >
        POSITION_DRIFT_SNAP_THRESHOLD * POSITION_DRIFT_SNAP_THRESHOLD
      ) {
        this._snapHeroWorldPositionTo(newX, newY);
      }
    }

    this._notifyStateChange();
  }

  /**
   * Called when server sends combat_start.
   */
  applyCombatStart(enemy: EnemyState): void {
    // Position enemy near hero
    enemy.position = {
      x: this._heroDisplayX + 1.5,
      y: this._heroDisplayY - 0.5,
    };

    this._gameState = {
      ...this._gameState,
      phase: GamePhase.Fighting,
      enemy,
      loot: null,
    };
    this._lootTimerMs = 0;
    this._thoughtText = null;
    this._notifyStateChange();
  }

  /**
   * Called when server sends attack.
   * Updates HP values and emits floating damage numbers.
   */
  applyAttack(
    source: 'hero' | 'enemy' | 'potion',
    damage: number,
    isCrit: boolean,
    heroHp: number,
    enemyHp: number,
  ): void {
    if (this._gameState.hero) {
      this._gameState.hero.hp = heroHp;
    }
    if (this._gameState.enemy) {
      this._gameState.enemy.hp = enemyHp;
    }

    // Emit floating damage at appropriate screen position
    const viewport = getViewport();
    if (source === 'hero') {
      // Damage on enemy (right side of screen)
      this._emitDamage(
        damage,
        viewport.width / 2 + 60,
        viewport.height / 2 - 30,
        isCrit,
      );
    } else if (source === 'enemy') {
      // Damage on hero (left side of screen)
      this._emitDamage(
        damage,
        viewport.width / 2 - 60,
        viewport.height / 2 - 30,
        false,
      );
    }
    // potion source: no floating damage

    this._notifyStateChange();
  }

  /**
   * Called when server sends combat_end.
   * Transitions back to walking phase.
   */
  applyCombatEnd(): void {
    this._gameState = {
      ...this._gameState,
      phase: GamePhase.Walking,
      enemy: null,
    };
    this._notifyStateChange();
  }

  /**
   * Apply loot from combat_end for the popup UI.
   */
  applyLoot(loot: LootDrop | null): void {
    this._lootTimerMs = 0;
    this._gameState = {
      ...this._gameState,
      loot,
      lastVictoryLoot: loot != null ? loot : this._gameState.lastVictoryLoot,
    };
    this._notifyStateChange();
  }

  /**
   * Called when server sends hero_died.
   */
  applyHeroDied(): void {
    this._gameState = {
      ...this._gameState,
      phase: GamePhase.Dead,
    };
    this._notifyStateChange();
  }

  /**
   * Called when server sends hero_revived.
   */
  applyHeroRevived(hp: number): void {
    if (this._gameState.hero) {
      this._gameState.hero.hp = hp;
    }
    this._gameState = {
      ...this._gameState,
      phase: GamePhase.Walking,
      enemy: null,
    };
    this._notifyStateChange();
  }

  /**
   * Called when server sends town_enter.
   * If buildings are provided, merge them into the matching town for rendering.
   */
  applyTownEnter(townId?: number, buildings?: BuildingData[]): void {
    if (townId && buildings && buildings.length > 0) {
      const idx = this._towns.findIndex((t) => t.id === townId);
      if (idx >= 0) {
        this._towns[idx] = { ...this._towns[idx]!, buildings };
      }
    }
    this._gameState = {
      ...this._gameState,
      phase: GamePhase.InTown,
    };
    this._showThought();
    this._notifyStateChange();
  }

  /** Server simulated approach to a town NPC (quest / shop / healer). Narration uses adventure_log_line. */
  applyTownNPCVisit(_npcName: string, _npcType: string): void {
    void _npcName;
    void _npcType;
  }

  /** Same text as adventure log line — shown in the thought bubble above the hero (town NPC visits). */
  applyAdventureLogLine(message: string): void {
    if (!message) return;
    this._thoughtText = message;
    this._thoughtStartMs = performance.now();
    this._notifyStateChange();
  }

  /**
   * Called when server sends town_exit.
   */
  applyTownExit(): void {
    this._gameState = {
      ...this._gameState,
      phase: GamePhase.Walking,
    };
    this._thoughtText = null;
    this._notifyStateChange();
  }

  // ---- Patch helpers for UI-driven optimistic updates ----

  /** Merge server-side active buffs into the current hero. */
  patchHeroBuffs(activeBuffs: HeroState['activeBuffs']): void {
    const hero = this._gameState.hero;
    if (!hero) return;
    this._gameState = {
      ...this._gameState,
      hero: { ...hero, activeBuffs },
    };
    this._notifyStateChange();
  }

  /** Merge buff quota fields. */
  patchHeroBuffQuota(
    fields: Pick<
      HeroState,
      'buffFreeChargesRemaining' | 'buffQuotaPeriodEnd' | 'buffCharges'
    >,
  ): void {
    const hero = this._gameState.hero;
    if (!hero) return;
    this._gameState = {
      ...this._gameState,
      hero: { ...hero, ...fields },
    };
    this._notifyStateChange();
  }

  /** Patch server-derived combat stats after buff application. */
  patchHeroCombat(
    combat: Pick<
      HeroState,
      'damage' | 'defense' | 'attackSpeed' | 'activeBuffs'
    >,
  ): void {
    const hero = this._gameState.hero;
    if (!hero) return;
    this._gameState = {
      ...this._gameState,
      hero: {
        ...hero,
        damage: combat.damage,
        defense: combat.defense,
        attackSpeed: combat.attackSpeed,
        activeBuffs: combat.activeBuffs,
      },
    };
    this._notifyStateChange();
  }

  /** Patch hero HP (e.g. after potion use). */
  patchHeroHp(hp: number): void {
    const hero = this._gameState.hero;
    if (!hero) return;
    this._gameState = {
      ...this._gameState,
      hero: { ...hero, hp: Math.min(hp, hero.maxHp) },
    };
    this._notifyStateChange();
  }

  /** Replace debuffs from server snapshot. */
  patchHeroDebuffs(debuffs: HeroState['debuffs']): void {
    const hero = this._gameState.hero;
    if (!hero) return;
    this._gameState = {
      ...this._gameState,
      hero: { ...hero, debuffs },
    };
    this._notifyStateChange();
  }

  /** Apply a full server state override (used for backward compat). */
  applyServerState(state: GameState): void {
    this._gameState = {
      ...state,
      lastVictoryLoot:
        state.lastVictoryLoot ?? this._gameState.lastVictoryLoot,
    };
    this._notifyStateChange();
  }

  // ---- Lifecycle ----

  /** Initialize the engine and attach to the DOM */
  async init(canvasContainer: HTMLElement): Promise<void> {
    await this.renderer.init(canvasContainer);

    this._handleResize = (): void => {
      this.renderer.resize();
    };
    window.addEventListener('resize', this._handleResize);
    window.addEventListener('orientationchange', this._handleResize);
  }

  /** Start the game loop */
  start(): void {
    if (this._running) return;
    this._running = true;
    this._lastTime = performance.now();
    this._tick(performance.now());
  }

  /** Stop the game loop */
  stop(): void {
    this._running = false;
    if (this._rafId !== null) {
      cancelAnimationFrame(this._rafId);
      this._rafId = null;
    }
  }

  /** Clean up everything */
  destroy(): void {
    this.stop();
    if (this._handleResize) {
      window.removeEventListener('resize', this._handleResize);
      window.removeEventListener('orientationchange', this._handleResize);
      this._handleResize = null;
    }
    this.renderer.destroy();
  }

  // ---- Private: Loop ----

  /** Main loop tick - called by requestAnimationFrame */
  private _tick = (now: number): void => {
    if (!this._running) return;

    const frameTime = Math.min(now - this._lastTime, MAX_ACCUMULATED_MS);
    this._lastTime = now;

    // Interpolation + camera must run every frame. A 100ms fixed step (server tick rate)
    // only updated ~10×/s and made the view stutter on 60 Hz displays.
    if (frameTime > 0) {
      this._update(frameTime);
    }

    this._render();

    this._rafId = requestAnimationFrame(this._tick);
  };

  /** Per-frame update -- hero interpolation, camera follow, loot timer */
  private _update(dtMs: number): void {
    // Interpolate hero display position toward target
    this._interpolatePosition();

    // Camera follows interpolated hero position
    if (this._gameState.hero) {
      const heroScreen = worldToScreen(
        this._heroDisplayX,
        this._heroDisplayY,
      );
      this.camera.setTarget(heroScreen.x, heroScreen.y);
    }
    this.camera.update(dtMs);

    // Auto-clear loot popup
    if (this._gameState.loot) {
      this._lootTimerMs += dtMs;
      if (this._lootTimerMs >= 2000) {
        this._gameState = { ...this._gameState, loot: null };
        this._lootTimerMs = 0;
      }
    }
  }

  /**
   * Interpolate hero display position between server updates.
   *
   * Server sends hero_move at 2 Hz (every 500ms). We linearly
   * interpolate between the previous known position and the target
   * position over that interval.
   */
  private _interpolatePosition(): void {
    const elapsed =
      (performance.now() - this._lastMoveUpdateTime) / 1000;
    const t = Math.min(elapsed / MOVE_UPDATE_INTERVAL_S, 1.0);

    this._heroDisplayX =
      this._prevPositionX +
      (this._targetPositionX - this._prevPositionX) * t;
    this._heroDisplayY =
      this._prevPositionY +
      (this._targetPositionY - this._prevPositionY) * t;
  }

  /** Render frame */
  private _render(): void {
    if (!this.renderer.initialized) return;

    const viewport = getViewport();

    // Apply camera to world container
    this.camera.applyTo(
      this.renderer.worldContainer,
      viewport.width,
      viewport.height,
    );

    // Draw the ground tiles
    this.renderer.drawGround(this.camera, viewport.width, viewport.height);

    // Draw towns on the map
    if (this._towns.length > 0) {
      this.renderer.drawTowns(
        this._towns,
        this.camera,
        viewport.width,
        viewport.height,
      );
    }

    // Draw entities
    const state = this._gameState;
    const now = performance.now();

    if (state.hero) {
      const isWalking = state.phase === GamePhase.Walking;
      const isFighting = state.phase === GamePhase.Fighting;
      const animPhase = isWalking
        ? 'walk'
        : isFighting
          ? 'fight'
          : 'idle';

      this.renderer.drawHero(
        this._heroDisplayX,
        this._heroDisplayY,
        animPhase,
        now,
      );

      this.renderer.clearCampfire();

      // Thought bubble during rest/town pauses
      if (this._thoughtText) {
        this.renderer.drawThoughtBubble(
          this._heroDisplayX,
          this._heroDisplayY,
          this._thoughtText,
          now,
          this._thoughtStartMs,
        );
      } else {
        this.renderer.clearThoughtBubble();
      }
    } else {
      this.renderer.clearCampfire();
    }

    // Draw NPCs from towns
    if (this._allNPCs.length > 0) {
      this.renderer.drawNPCs(
        this._allNPCs,
        this.camera,
        viewport.width,
        viewport.height,
        now,
      );
    } else {
      this.renderer.clearNPCs();
    }

    // Draw nearby heroes from the shared world
    if (this._nearbyHeroes.length > 0) {
      this.renderer.drawNearbyHeroes(this._nearbyHeroes, now);
    } else {
      this.renderer.clearNearbyHeroes();
    }

    // Draw enemy during combat or death
    const showEnemy =
      state.enemy &&
      (state.phase === GamePhase.Fighting ||
        (state.phase === GamePhase.Dead && state.enemy));
    if (showEnemy && state.enemy) {
      this.renderer.drawEnemy(
        state.enemy.position.x,
        state.enemy.position.y,
        state.enemy.hp,
        state.enemy.maxHp,
        state.enemy.enemyType,
        now,
      );
    }

    // Sort entities for isometric depth
    this.renderer.sortEntities();
  }

  // ---- Private: Helpers ----

  /**
   * Snap render/interpolation state and camera to a world position (teleport, town arrival, etc.).
   */
  private _snapHeroWorldPositionTo(x: number, y: number): void {
    this._heroDisplayX = x;
    this._heroDisplayY = y;
    this._prevPositionX = x;
    this._prevPositionY = y;
    this._targetPositionX = x;
    this._targetPositionY = y;
    this._moveTargetX = x;
    this._moveTargetY = y;
    this._lastMoveUpdateTime = performance.now();
    const heroScreen = worldToScreen(x, y);
    this.camera.setTarget(heroScreen.x, heroScreen.y);
    this.camera.snapToTarget();
  }

  private _notifyStateChange(): void {
    this._onStateChange?.(this._gameState);
  }

  private _emitDamage(
    value: number,
    x: number,
    y: number,
    isCrit: boolean,
  ): void {
    if (!this._onDamage) return;
    this._onDamage({
      id: Date.now() + Math.random(),
      value,
      x,
      y,
      isCrit,
      createdAt: performance.now(),
    });
  }

  private _showThought(): void {
    this._thoughtText =
      HERO_THOUGHTS[Math.floor(Math.random() * HERO_THOUGHTS.length)] ??
      null;
    this._thoughtStartMs = performance.now();
  }
}
