import { SdkRequestError } from './sdk-error';
import {
  collect, decodeScenario, loadConfig, placeBet, reportAnimationEnd,
  isFeatureChoice,
  type IGameResult, type IGameRoundInfo, type IGameSettings,
  type ILoadConfigConfig, type ILoadConfigReply, type IPlaceBetOptions,
  type IPlaceBetReply, type ICollectReply, type ISessionOptions,
  type IBalanceReply, type IFreePlayInfo, type TPlayerChoiceAward, type ITokenData,
} from '@hizi.io/engine-sdk';

export type Phase = 'ready' | 'choice' | 'collect-or-continue' |
  'continuation' | 'recovery' | 'busy' | 'blocked';

export interface Snapshot {
  result?: IGameResult;
  round?: IGameRoundInfo;
  balance?: IBalanceReply;
  tokenData?: ITokenData;
  freePlays?: IFreePlayInfo[];
  amountToCollect?: number;
}

export interface Policy {
  // Validated for this player, currency, vertical, and jurisdiction.
  stakes: number[];
  minSpinTimeMs: number;
  buyEnabled: boolean;
  gambleEnabled: boolean;
  partialCollectEnabled: boolean;
  maxPurchaseAmount?: number;
}

export interface View {
  // Resolve only after all boards, wins, and feature transitions finish.
  render(snapshot: Snapshot, resumed: boolean): Promise<void>;
}

// These dependencies also let tests model delayed or lost replies.
const defaultApi = { collect, loadConfig, placeBet, reportAnimationEnd };
const defaultClock = {
  now: () => performance.now(),
  sleep: (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)),
};

export function slotPhase(snapshot: Snapshot): Exclude<Phase, 'busy' | 'blocked'> {
  const { result, round, amountToCollect } = snapshot;
  if (round?.status && !['open', 'closed'].includes(round.status)) return 'recovery';
  if (round?.status === 'closed') {
    return result?.engineData.inProgress || result?.engineData.canCollect || result?.engineData.playerChoice?.length ? 'recovery' : 'ready';
  }
  if (!result) return round?.status === 'open' || (amountToCollect ?? 0) > 0 ? 'recovery' : 'ready';
  const e = result.engineData;
  if (e.canCollect) return 'collect-or-continue';
  if (e.playerChoice?.length) return 'choice';
  if (e.inProgress) return 'continuation';
  if (round?.status === 'open' || (amountToCollect ?? 0) > 0) return 'recovery';
  return 'ready';
}

export function roundBaseStake(snapshot: Snapshot): number {
  // The RGS can record the full purchase price in BOTH round stake fields.
  const stored = snapshot.result?.engineData.baseStake;
  if (typeof stored === 'number' && stored > 0) return stored;
  if (snapshot.result?.engineData.buyFeatureId) {
    throw new Error('Bought round has no recorded base stake. Resolve its history.');
  }
  const stake = snapshot.round?.baseStake;
  if (typeof stake === 'number' && stake > 0) return stake;
  throw new Error('Round has no base stake. Recover its state.');
}

export function permittedStakes(
  config: ILoadConfigConfig, settings: Partial<IGameSettings>,
  limits: { min: number; max: number },
): number[] {
  // The caller supplies finite, approved bounds. No invented fallback ladder.
  if (!Number.isFinite(limits.min) || !Number.isFinite(limits.max) ||
      limits.min <= 0 || limits.max < limits.min) throw new Error('Invalid stake limits');
  const source = settings.customStakes?.length ? settings.customStakes : config.stakes;
  if (!source?.length) throw new Error('This game needs a validated stake input or an explicit ladder');
  return [...new Set(source)].filter(n => Number.isSafeInteger(n) &&
    n >= limits.min && n <= limits.max).sort((a, b) => a - b);
}

export class SlotSession {
  snapshot: Snapshot = {};
  private busy = false;
  private uncertain = true;
  private blocks = new Set<string>();

  constructor(
    private session: ISessionOptions,
    public config: ILoadConfigConfig,
    public settings: Partial<IGameSettings>,
    public policy: Policy,
    private view: View,
    private api = defaultApi,
    private clock = defaultClock,
  ) {
    if (!Number.isFinite(policy.minSpinTimeMs) || policy.minSpinTimeMs < 0) {
      throw new Error('Invalid minimum spin time');
    }
  }

  get phase(): Phase {
    if (this.busy) return 'busy';
    if (this.uncertain) return 'recovery';
    if (this.blocks.size) return 'blocked';
    return slotPhase(this.snapshot);
  }

  block(reason: string): void { this.blocks.add(reason); }
  unblock(reason: string): void { this.blocks.delete(reason); }

  // Replace credentials after a successful refresh. Recover before any new bet.
  replaceSession(session: ISessionOptions): void {
    if (this.busy) throw new Error('Wait for the current request');
    this.session = session;
    this.uncertain = true;
  }

  private assertPhase(...allowed: Phase[]): void {
    if (!allowed.includes(this.phase)) throw new Error(`Action unavailable in ${this.phase}`);
  }

  private isWager(option: TPlayerChoiceAward): boolean {
    return isFeatureChoice(option) && [
      ...this.config.wagerFeatures ?? [], ...this.config.wagerStakeFeatures ?? [],
    ].includes(option.feature);
  }

  private accept(reply: IPlaceBetReply | ICollectReply, collecting = false): void {
    // A collect reply can carry a compressed scenario. collect() has no config option.
    if (collecting && reply.result) decodeScenario(reply.result, this.config);
    this.snapshot = {
      result: reply.result,
      round: reply.gameRoundInfo,
      balance: reply.balance ?? this.snapshot.balance,
      tokenData: reply.tokenData,
      freePlays: this.snapshot.freePlays,
      // A collect reply describes the requested amount, not guaranteed remaining funds.
      amountToCollect: collecting ? (reply.gameRoundInfo?.status === 'closed' ? 0 : undefined) : reply.amountToCollect,
    };
    // IPlaceBetReply does not declare updated freeplays. Reload at the campaign boundary.
  }

  async restore(loaded?: ILoadConfigReply): Promise<void> {
    if (this.busy) throw new Error('Wait for the current request');
    this.busy = true;
    this.uncertain = true;
    try {
      if (!loaded) {
        const reply = await this.api.loadConfig(this.session);
        if (!reply.success) throw new SdkRequestError(reply.error);
        loaded = reply.result;
      }
      this.config = loaded.config;
      this.snapshot = {
        result: loaded.gameResult,
        round: loaded.gameRoundInfo,
        balance: loaded.balance,
        tokenData: loaded.tokenData,
        freePlays: loaded.freePlaysAvailable,
        amountToCollect: loaded.amountToCollect,
      };
      await this.view.render(this.snapshot, true);
      this.uncertain = false;
    } finally { this.busy = false; }
  }

  private async bet(options: Omit<IPlaceBetOptions, 'backendURL' | 'token' | 'config'>): Promise<void> {
    // Set the lock before the first await. A double click cannot start two requests.
    this.busy = true;
    const started = this.clock.now();
    try {
      const reply = await this.api.placeBet({ ...options, ...this.session, config: this.config });
      if (!reply.success) throw new SdkRequestError(reply.error);
      this.accept(reply.result);
      await this.view.render(this.snapshot, false);
      // Check elapsed time again after each wait. A timer is not a clock.
      while (this.clock.now() - started < this.policy.minSpinTimeMs) {
        await this.clock.sleep(this.policy.minSpinTimeMs - (this.clock.now() - started));
      }
      if (this.settings.reportAnimationEnd === true) {
        const report = await this.api.reportAnimationEnd(this.session);
        if (!report.success) throw new SdkRequestError(report.error);
      }
    } catch (error) {
      // Never repeat a money request just because its response was lost.
      this.uncertain = true;
      throw error;
    } finally { this.busy = false; }
  }

  async start(stake: number, featureId?: string): Promise<void> {
    this.assertPhase('ready');
    if (!this.policy.stakes.includes(stake)) throw new Error('Stake is unavailable');
    const feature = featureId === undefined ? undefined : this.config.buyFeatures?.find(f => f.id === featureId);
    if (featureId !== undefined && (!feature || !this.policy.buyEnabled)) throw new Error('Purchase is unavailable');
    // The engine debits Math.ceil(targetPrice * stake), so ceil here too.
    const cost = Math.ceil(stake * (feature?.price ?? 1));
    if (!Number.isSafeInteger(cost) || cost <= 0) throw new Error('Invalid debit');
    if (feature && this.policy.maxPurchaseAmount !== undefined && cost > this.policy.maxPurchaseAmount) {
      throw new Error('Purchase exceeds its limit');
    }
    if (!this.snapshot.balance) throw new Error('Refresh the balance before a paid bet');
    if (cost > this.snapshot.balance.totalBalance) throw new Error('Insufficient balance');
    await this.bet({ stake, ...(featureId === undefined ? {} : { featureToBuy: featureId }) });
  }

  async ticket(ticket: IFreePlayInfo): Promise<void> {
    this.assertPhase('ready');
    const current = this.snapshot.freePlays?.find(t => t.currency === ticket.currency &&
      t.stake === ticket.stake && t.feature === ticket.feature && t.count > 0);
    if (!current || !this.policy.stakes.includes(current.stake)) throw new Error('Ticket is unavailable');
    if (this.snapshot.balance && current.currency !== this.snapshot.balance.currency) throw new Error('Ticket currency differs');
    await this.bet({ stake: current.stake, useTicket: true,
      ...(current.feature ? { useTicketFeatureType: current.feature } : {}) });
    // Ticket counts are not guaranteed on placeBet replies. Refresh before another ticket.
    this.snapshot.freePlays = undefined;
  }

  async choose(index: number): Promise<void> {
    this.assertPhase('choice', 'collect-or-continue');
    const choices = this.snapshot.result?.engineData.playerChoice ?? [];
    const option = choices[index];
    if (!Number.isInteger(index) || !option) throw new Error('Choice is unavailable');
    if (this.isWager(option) && !this.policy.gambleEnabled) throw new Error('Gamble is disabled');
    await this.bet({ playerChoiceIndex: index });
  }

  async continue(): Promise<void> {
    this.assertPhase('continuation', 'collect-or-continue');
    const e = this.snapshot.result!.engineData;
    if (e.playerChoice?.length) throw new Error('A player choice is required');
    if (!e.inProgress) throw new Error('No continuation exists');
    // In slots, a collectable continuation risks the existing win.
    if (e.canCollect && !this.policy.gambleEnabled) throw new Error('Gamble is disabled');
    await this.bet({});
  }

  async cashOut(amount?: number): Promise<void> {
    this.assertPhase('collect-or-continue');
    if (amount !== undefined) {
      const outstanding = this.snapshot.amountToCollect;
      if (!this.policy.partialCollectEnabled || this.snapshot.result?.engineData.inProgress !== true || !Number.isSafeInteger(amount) || amount <= 0 ||
          outstanding === undefined || amount >= outstanding) throw new Error('Invalid bank amount');
      const base = roundBaseStake(this.snapshot);
      const result = this.snapshot.result!;
      const banked = result.engineData.bankedWin ?? 0;
      const requested = Math.floor(amount / base * 100) / 100;
      const nextBanked = Math.floor((banked + requested) * 100) / 100;
      // These requests take a different engine path. Use explicit full collect instead.
      if (nextBanked <= banked || nextBanked >= result.totalWin) throw new Error('Bank amount must protect only part of the at-risk win');
    }
    this.busy = true;
    try {
      const reply = await this.api.collect({ ...this.session, ...(amount === undefined ? {} : { amount }) });
      if (!reply.success) throw new SdkRequestError(reply.error);
      this.accept(reply.result, true);
      // Do not add bankedWin to balance or subtract it from the outstanding amount.
      await this.view.render(this.snapshot, false);
      // Older replies omit result/round state. Reload instead of assuming a close.
      if (!reply.result.result || reply.result.gameRoundInfo?.status !== 'closed') this.uncertain = true;
    } catch (error) {
      this.uncertain = true;
      throw error;
    } finally { this.busy = false; }
  }
}
