/**
 * Strict mirrors of the engine's view types (packages/engine/src/types.ts).
 *
 * Every object is `z.strictObject`, so an unexpected key (e.g. `correctOptionId` slipped into
 * `PublicState.question`) fails validation instead of reaching a phone. `alignment.ts` makes
 * typecheck fail if any schema here drifts from its engine type.
 */
import { z } from 'zod';
import { BOARD_SIZE, CATEGORY_COUNT } from '@pintrush/engine';
import {
  DifficultySchema,
  DurationMsSchema,
  EventModeSchema,
  IdSchema,
  LineIndexSchema,
  OptionIdSchema,
  PlayerStatusSchema,
  PositionSchema,
  PowerUpTypeSchema,
  QuestionTypeSchema,
  RoundKindSchema,
  RoundPhaseSchema,
  SessionStatusSchema,
  TileSourceSchema,
  TimestampSchema,
} from './common';

/** Public leaderboards are capped (types.ts: "top 10") so a broadcast never carries the full roster. */
export const PUBLIC_LEADERBOARD_SIZE = 10;
/** `PublicState.recentJoins` holds the last 8 nicknames. */
export const RECENT_JOINS_SIZE = 8;
/** `QuestionResult.fastest` holds up to 3 entries. */
export const FASTEST_SIZE = 3;
/** Round podium = top 3. */
export const PODIUM_SIZE = 3;
/** The 8 symmetries of the square (dihedral group D4) used to deal boards. */
export const SYMMETRY_COUNT = 8;
/** Power-up tokens + uses never exceed 3 per event (ARCHITECTURE §2.6). */
export const MAX_POWER_UPS_PER_EVENT = 3;

const Count = z.int().min(0);
const Rank = z.int().min(1);
const Points = z.int();
/**
 * Server-produced display name. Length rules are enforced when names are accepted (engine validators),
 * not re-checked here, so an odd bot name can never blank a TV.
 */
const Nickname = z.string().min(1);

// ───────────────────────────── Building blocks ─────────────────────────────

export const CategoryDefSchema = z.strictObject({
  key: z.string().min(1),
  label: z.string(),
});

export const AnswerOptionSchema = z.strictObject({
  id: OptionIdSchema,
  text: z.string(),
});

export const PlayerTotalsSchema = z.strictObject({
  points: Points,
  correct: Count,
  answered: Count,
  correctTimeMs: DurationMsSchema,
  lastCorrectAt: TimestampSchema.nullable(),
  lines: Count,
  fullBoards: Count,
});

export const TileStateSchema = z.strictObject({
  position: PositionSchema,
  category: z.string().min(1),
  activated: z.boolean(),
  source: TileSourceSchema.nullable(),
  questionId: IdSchema.nullable(),
});

const boardShape = {
  symmetry: z.int().min(0).max(SYMMETRY_COUNT - 1),
  baseLayoutRow: z.int().min(0).max(CATEGORY_COUNT - 1),
  tiles: z.array(TileStateSchema).length(BOARD_SIZE),
  lines: z.array(LineIndexSchema).max(8),
  fullBoard: z.boolean(),
};

/** The engine guarantees `tiles[i].position === i`; clients index tiles by position. */
function checkTileOrder<T extends { tiles: { position: number }[] }>(board: T, ctx: z.RefinementCtx<T>): void {
  board.tiles.forEach((tile, i) => {
    if (tile.position !== i) {
      ctx.addIssue({ code: 'custom', message: `tiles[${i}].position must be ${i}`, path: ['tiles', i, 'position'] });
    }
  });
}

export const BoardStateSchema = z.strictObject(boardShape).superRefine(checkTileOrder);

export const QuestionResultSchema = z.strictObject({
  questionId: IdSchema,
  correctOptionId: OptionIdSchema,
  distribution: z.record(OptionIdSchema, Count),
  answeredCount: Count,
  correctCount: Count,
  fastest: z
    .array(z.strictObject({ playerId: IdSchema, nickname: Nickname, elapsedMs: DurationMsSchema }))
    .max(FASTEST_SIZE),
  challengeWinners: z.array(IdSchema).optional(),
});

export const CompletedRoundSummarySchema = z.strictObject({
  id: IdSchema,
  index: Count,
  kind: RoundKindSchema,
  label: z.string(),
  completedAt: TimestampSchema,
  podium: z.array(z.strictObject({ playerId: IdSchema, nickname: Nickname, points: Points })).max(PODIUM_SIZE),
});

export const StandingSchema = z.strictObject({
  rank: Rank,
  playerId: IdSchema,
  nickname: Nickname,
  teamId: IdSchema.nullable(),
  points: Points,
  correct: Count,
  correctTimeMs: DurationMsSchema,
  lastCorrectAt: TimestampSchema.nullable(),
  lines: Count,
});

export const TeamStandingSchema = z.strictObject({
  rank: Rank,
  teamId: IdSchema,
  name: z.string().min(1),
  /** Mean of member points (1 decimal), so not an integer. */
  score: z.number(),
  members: Count,
  activeMembers: Count,
  isSolo: z.boolean(),
});

// ───────────────────────────── PublicState ─────────────────────────────

export const PublicRoundSchema = z.strictObject({
  id: IdSchema,
  index: Count,
  kind: RoundKindSchema,
  label: z.string(),
  categories: z.array(CategoryDefSchema),
  hasBoard: z.boolean(),
  questionCount: Count,
  /** -1 during INTRO. */
  qIndex: z.int().min(-1),
  phase: RoundPhaseSchema,
  phaseEndsAt: TimestampSchema,
  finalists: z.array(z.strictObject({ playerId: IdSchema, nickname: Nickname })).nullable(),
  estimatedEndAt: TimestampSchema,
});

/** The public question. Deliberately has no answer field: strictness rejects one if it ever appears. */
export const PublicQuestionSchema = z.strictObject({
  id: IdSchema,
  index: Count,
  category: z.string().min(1),
  categoryLabel: z.string(),
  difficulty: DifficultySchema,
  type: QuestionTypeSchema,
  isWildcard: z.boolean(),
  prompt: z.string().nullable(),
  options: z.array(AnswerOptionSchema).nullable(),
  opensAt: TimestampSchema.nullable(),
  openedAt: TimestampSchema.nullable(),
  closesAt: TimestampSchema.nullable(),
});

export const PublicPlanSchema = z.strictObject({
  index: Count,
  total: Count,
  label: z.string().nullable(),
  nextLabel: z.string().nullable(),
});

const REVEAL_PHASES: ReadonlySet<string> = new Set(['REVEALED', 'ROUND_OVER']);

export const PublicStateSchema = z
  .strictObject({
    sessionId: IdSchema,
    eventId: IdSchema,
    mode: EventModeSchema,
    status: SessionStatusSchema,
    round: PublicRoundSchema.nullable(),
    question: PublicQuestionSchema.nullable(),
    reveal: QuestionResultSchema.nullable(),
    answeredCount: Count,
    participantCount: Count,
    playerCount: Count,
    leaderboard: z.array(StandingSchema).max(PUBLIC_LEADERBOARD_SIZE),
    teamLeaderboard: z.array(TeamStandingSchema).max(PUBLIC_LEADERBOARD_SIZE),
    soloLeaderboard: z.array(TeamStandingSchema).max(PUBLIC_LEADERBOARD_SIZE).nullable(),
    recentJoins: z.array(Nickname).max(RECENT_JOINS_SIZE),
    nextRoundAt: TimestampSchema.nullable(),
    plan: PublicPlanSchema.nullable(),
    lastRound: CompletedRoundSummarySchema.nullable(),
    pausedAt: TimestampSchema.nullable(),
    /** FINAL_RUSH: share of predictors per finalist. */
    predictions: z.record(IdSchema, z.number().min(0)).nullable(),
  })
  // Leak guards for the rule "never send a correct answer (or an unopened question) to a client early".
  .superRefine((s, ctx) => {
    if (s.question !== null && s.round === null) {
      ctx.addIssue({ code: 'custom', message: 'question requires an active round', path: ['question'] });
    }
    if (s.reveal !== null) {
      if (s.round === null || !REVEAL_PHASES.has(s.round.phase)) {
        ctx.addIssue({ code: 'custom', message: 'reveal is only allowed in REVEALED or ROUND_OVER', path: ['reveal'] });
      } else if (s.question !== null && s.question.id !== s.reveal.questionId) {
        ctx.addIssue({ code: 'custom', message: 'reveal must be for the current question', path: ['reveal', 'questionId'] });
      }
    }
    if (s.question !== null && s.round?.phase === 'PENDING' && (s.question.prompt !== null || s.question.options !== null)) {
      ctx.addIssue({ code: 'custom', message: 'prompt and options must be null while PENDING', path: ['question'] });
    }
  });

// ───────────────────────────── PrivateState ─────────────────────────────

export const PrivateBoardSchema = z
  .strictObject({ ...boardShape, categoryLabels: z.record(z.string(), z.string()) })
  .superRefine(checkTileOrder);

export const PrivateTotalsSchema = PlayerTotalsSchema.extend({ rank: Rank.nullable() });

export const CurrentAnswerSchema = z.strictObject({
  questionId: IdSchema,
  optionId: OptionIdSchema,
  wildcardPosition: PositionSchema.nullable(),
});

export const LastResultSchema = z.strictObject({
  questionId: IdSchema,
  correct: z.boolean(),
  answered: z.boolean(),
  correctOptionId: OptionIdSchema,
  points: Points,
  speedBonus: Count,
  doubleDown: z.enum(['WON', 'LOST']).nullable(),
  tileActivated: PositionSchema.nullable(),
  newLines: z.array(LineIndexSchema),
  fullBoard: z.boolean(),
  tokenGranted: z.boolean(),
});

export const PrivatePowerUpsSchema = z.strictObject({
  enabled: z.boolean(),
  tokens: z.int().min(0).max(MAX_POWER_UPS_PER_EVENT),
  used: z.array(PowerUpTypeSchema).max(MAX_POWER_UPS_PER_EVENT),
  available: z.array(PowerUpTypeSchema).max(MAX_POWER_UPS_PER_EVENT),
  doubleDownOn: IdSchema.nullable(),
  removedOptionId: OptionIdSchema.nullable(),
});

export const PrivateStateSchema = z.strictObject({
  playerId: IdSchema,
  nickname: Nickname,
  status: PlayerStatusSchema,
  teamId: IdSchema.nullable(),
  teamName: z.string().nullable(),
  totals: PrivateTotalsSchema,
  inRound: z.boolean(),
  waitingForNextRound: z.boolean(),
  board: PrivateBoardSchema.nullable(),
  currentAnswer: CurrentAnswerSchema.nullable(),
  lastResult: LastResultSchema.nullable(),
  roundPoints: Points,
  powerUps: PrivatePowerUpsSchema,
  isFinalist: z.boolean(),
  prediction: IdSchema.nullable(),
});

// ───────────────────────────── HostState ─────────────────────────────

export const HostPlayerSchema = z.strictObject({
  playerId: IdSchema,
  nickname: Nickname,
  teamName: z.string().nullable(),
  status: PlayerStatusSchema,
  points: Points,
  answered: Count,
  correct: Count,
  joinedAt: TimestampSchema,
  isBot: z.boolean(),
});

export const HostTeamSchema = z.strictObject({
  teamId: IdSchema,
  name: z.string().min(1),
  members: Count,
});

export const HostStateSchema = z.strictObject({
  public: PublicStateSchema,
  players: z.array(HostPlayerSchema),
  teams: z.array(HostTeamSchema),
  roundsCompleted: Count,
  planIndex: Count,
  planLength: Count.nullable(),
});
