/** App-local библиотека живых игроков (userData, не в project zip). */

import type { NpcDisposition } from './npcDisposition';
import { normalizeNpcDisposition } from './npcDisposition';
import type { NpcId, PlayerId, PlayerTeamId, SceneNpcTokenId } from './ids';
import { asNpcId, asPlayerId, asPlayerTeamId, asSceneNpcTokenId } from './ids';
import { normalizeHexColor } from '../npcs/npcGroups';
import { normalizeTokenPath, type TokenPath } from './tokenPath';

export type { PlayerId, PlayerTeamId, SceneNpcTokenId };
export { asPlayerId, asPlayerTeamId, asSceneNpcTokenId };

export type PlayerImageOffset = { x: number; y: number };

/** Плоская команда игроков (без вложенности). */
export type AppPlayerTeam = {
  id: PlayerTeamId;
  name: string;
  /** Hex `#rrggbb`. */
  color: string;
};

export type AppPlayer = {
  id: PlayerId;
  name: string;
  /** Относительный путь файла в каталоге `userData/players/`. */
  imageRelPath: string;
  sha256: string;
  teamId: PlayerTeamId | null;
  ringColor: string;
  imageOffset: PlayerImageOffset;
  /** Масштаб аватара внутри круга (1 = по умолчанию). */
  imageScale: number;
};

/** Расстановка кампанийного НПС на карте как игрового токена (в проекте). */
export type SceneNpcToken = {
  id: SceneNpcTokenId;
  npcId: NpcId;
  nx: number;
  ny: number;
  sizeN: number;
  /** Состояние экземпляра на карте (копируется из НПС при постановке). */
  disposition: NpcDisposition;
  /** Optional movement path (editor → session playback). */
  path?: TokenPath | null;
};

export const DEFAULT_PLAYER_RING_COLOR = '#c9a227';
export const DEFAULT_PLAYER_IMAGE_OFFSET: PlayerImageOffset = { x: 0, y: 0 };
export const PLAYER_IMAGE_OFFSET_MIN = -0.45;
export const PLAYER_IMAGE_OFFSET_MAX = 0.45;
export const DEFAULT_PLAYER_IMAGE_SCALE = 1;
export const PLAYER_IMAGE_SCALE_MIN = 0.5;
export const PLAYER_IMAGE_SCALE_MAX = 3;
export const PLAYER_IMAGE_SCALE_STEP = 0.08;

export const DEFAULT_SCENE_NPC_TOKEN_SIZE_N = 0.1;
export const SCENE_NPC_TOKEN_SIZE_MIN = 0.04;
export const SCENE_NPC_TOKEN_SIZE_MAX = 0.45;

/** Множитель размера всех НПС-токенов на пульте/презентации (session-only). */
export const DEFAULT_NPC_TOKEN_SESSION_SCALE = 1;
export const NPC_TOKEN_SESSION_SCALE_MIN = 0.4;
export const NPC_TOKEN_SESSION_SCALE_MAX = 2.5;

export type SceneNpcTokensSessionPlacement = {
  /** Есть после move; без move позиция берётся из сцены. */
  nx?: number;
  ny?: number;
  /** Session-only override of map diameter. */
  sizeN?: number;
  /** Session-only override типа экземпляра. */
  disposition?: NpcDisposition;
  /** Session-only: серая рамка и grayscale. */
  inactive?: boolean;
};

/** Подкрепление: токен только на сессию, не пишется в scene.npcTokens. */
export type SceneNpcSessionSpawn = {
  id: SceneNpcTokenId;
  sceneId: string;
  npcId: NpcId;
  nx: number;
  ny: number;
  sizeN: number;
  disposition: NpcDisposition;
};

export type SceneNpcTokensSessionState = {
  revision: number;
  byPlacementId: Record<string, SceneNpcTokensSessionPlacement>;
  /** Общий масштаб отображения всех НПС-токенов (не пишется в проект). */
  scale: number;
  spawned: Record<string, SceneNpcSessionSpawn>;
};

export type SceneNpcTokensSessionEvent =
  | { kind: 'move'; placementId: string; nx: number; ny: number }
  | { kind: 'setSize'; placementId: string; sizeN: number }
  | { kind: 'setDisposition'; placementId: string; disposition: NpcDisposition }
  | { kind: 'setInactive'; placementId: string; inactive: boolean }
  | { kind: 'setScale'; scale: number }
  | {
      kind: 'spawn';
      npcId: string;
      sceneId: string;
      nx: number;
      ny: number;
      sizeN?: number;
      disposition?: NpcDisposition;
      placementId?: string;
    }
  | { kind: 'despawn'; placementId: string }
  | { kind: 'clear' };

export type NpcMapSpawnDragState = {
  dragging: boolean;
  npcId: string | null;
  hoverNx: number | null;
  hoverNy: number | null;
};

/** Session-only токены живых игроков на карте во время показа. */
export type ScenePlayerTokenPlacement = { nx: number; ny: number; sizeN: number };

export type ScenePlayerTokensSessionState = {
  revision: number;
  selectedPlayerIds: string[];
  visible: boolean;
  byPlayerId: Record<string, ScenePlayerTokenPlacement>;
};

export type ScenePlayerTokensSessionEvent =
  | { kind: 'setSelection'; playerIds: readonly string[] }
  | { kind: 'setVisible'; visible: boolean }
  | { kind: 'show'; sizeN: number }
  | { kind: 'seedBottom'; sizeN: number }
  | { kind: 'move'; playerId: string; nx: number; ny: number }
  | { kind: 'setSize'; playerId: string; sizeN: number }
  | { kind: 'clearPlacements' }
  | { kind: 'clear' };

export function clampNpcTokenSessionScale(raw: unknown): number {
  const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_NPC_TOKEN_SESSION_SCALE;
  return Math.max(NPC_TOKEN_SESSION_SCALE_MIN, Math.min(NPC_TOKEN_SESSION_SCALE_MAX, n));
}

/** Ряд токенов внизу карты (первый показ «Показать игроков»). */
export function layoutPlayerTokensBottom(
  playerIds: readonly string[],
  sizeN: number,
): Record<string, ScenePlayerTokenPlacement> {
  const clamped = clampSceneNpcTokenSizeN(sizeN);
  const n = playerIds.length;
  const out: Record<string, ScenePlayerTokenPlacement> = {};
  const ny = 0.88;
  for (let i = 0; i < n; i += 1) {
    const id = playerIds[i];
    if (!id) continue;
    out[id] = { nx: (i + 1) / (n + 1), ny, sizeN: clamped };
  }
  return out;
}

export type PlayersUpsertProgressEvent = {
  percent: number;
  stage: string;
  detail?: string;
};

export function clampPlayerImageOffset(raw: unknown): PlayerImageOffset {
  if (!raw || typeof raw !== 'object') return { ...DEFAULT_PLAYER_IMAGE_OFFSET };
  const obj = raw as { x?: unknown; y?: unknown };
  const x = typeof obj.x === 'number' && Number.isFinite(obj.x) ? obj.x : 0;
  const y = typeof obj.y === 'number' && Number.isFinite(obj.y) ? obj.y : 0;
  return {
    x: Math.max(PLAYER_IMAGE_OFFSET_MIN, Math.min(PLAYER_IMAGE_OFFSET_MAX, x)),
    y: Math.max(PLAYER_IMAGE_OFFSET_MIN, Math.min(PLAYER_IMAGE_OFFSET_MAX, y)),
  };
}

export function clampPlayerImageScale(raw: unknown): number {
  const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_PLAYER_IMAGE_SCALE;
  return Math.max(PLAYER_IMAGE_SCALE_MIN, Math.min(PLAYER_IMAGE_SCALE_MAX, n));
}

export function clampSceneNpcTokenSizeN(sizeN: number): number {
  if (!Number.isFinite(sizeN)) return DEFAULT_SCENE_NPC_TOKEN_SIZE_N;
  return Math.max(SCENE_NPC_TOKEN_SIZE_MIN, Math.min(SCENE_NPC_TOKEN_SIZE_MAX, sizeN));
}

export function emptySceneNpcTokensSessionState(revision = 1): SceneNpcTokensSessionState {
  return {
    revision,
    byPlacementId: {},
    scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
    spawned: {},
  };
}

export function newSceneNpcSessionSpawnId(): SceneNpcTokenId {
  return asSceneNpcTokenId(
    `snpc_sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
  );
}

export function nextSessionNpcSpawnPoint(
  occupied: readonly { nx: number; ny: number }[],
): { nx: number; ny: number } {
  const tryPoint = (nx: number, ny: number) => {
    const p = {
      nx: Math.max(0.05, Math.min(0.95, nx)),
      ny: Math.max(0.05, Math.min(0.95, ny)),
    };
    return occupied.some((o) => Math.hypot(o.nx - p.nx, o.ny - p.ny) < 0.045) ? null : p;
  };
  const first = tryPoint(0.5, 0.5);
  if (first) return first;
  for (let i = 1; i <= 16; i += 1) {
    const r = 0.05 * i;
    for (let k = 0; k < 8; k += 1) {
      const a = (k / 8) * Math.PI * 2 + i * 0.35;
      const hit = tryPoint(0.5 + Math.cos(a) * r, 0.5 + Math.sin(a) * r);
      if (hit) return hit;
    }
  }
  return { nx: 0.5, ny: 0.5 };
}

export function listVisibleSceneNpcTokens(
  sceneTokens: readonly SceneNpcToken[] | undefined,
  spawned: Record<string, SceneNpcSessionSpawn> | undefined,
  sceneId: string | null | undefined,
): SceneNpcToken[] {
  const out = [...(sceneTokens ?? [])];
  if (!sceneId || !spawned) return out;
  for (const s of Object.values(spawned)) {
    if (s.sceneId !== sceneId) continue;
    out.push({
      id: asSceneNpcTokenId(s.id),
      npcId: asNpcId(s.npcId),
      nx: s.nx,
      ny: s.ny,
      sizeN: s.sizeN,
      disposition: s.disposition,
    });
  }
  return out;
}

export function normalizeAppPlayerTeam(raw: unknown): AppPlayerTeam | null {
  if (!raw || typeof raw !== 'object') return null;
  const obj = raw as Partial<AppPlayerTeam>;
  if (typeof obj.id !== 'string' || !obj.id) return null;
  if (typeof obj.name !== 'string') return null;
  const name = obj.name.trim();
  if (!name) return null;
  return {
    id: asPlayerTeamId(obj.id),
    name,
    color: normalizeHexColor(obj.color, DEFAULT_PLAYER_RING_COLOR),
  };
}

export function normalizeAppPlayer(raw: unknown, teamIds?: Set<string>): AppPlayer | null {
  if (!raw || typeof raw !== 'object') return null;
  const obj = raw as Partial<AppPlayer> & { teamId?: string | null };
  if (typeof obj.id !== 'string' || !obj.id) return null;
  if (typeof obj.name !== 'string') return null;
  const name = obj.name.trim();
  if (!name) return null;
  if (typeof obj.imageRelPath !== 'string' || !obj.imageRelPath) return null;
  if (typeof obj.sha256 !== 'string' || !obj.sha256) return null;
  let teamId: PlayerTeamId | null = null;
  if (typeof obj.teamId === 'string' && obj.teamId) {
    if (!teamIds || teamIds.has(obj.teamId)) teamId = asPlayerTeamId(obj.teamId);
  }
  return {
    id: asPlayerId(obj.id),
    name,
    imageRelPath: obj.imageRelPath,
    sha256: obj.sha256,
    teamId,
    ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR),
    imageOffset: clampPlayerImageOffset(obj.imageOffset),
    imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
  };
}

export function normalizeSceneNpcToken(raw: unknown): SceneNpcToken | null {
  if (!raw || typeof raw !== 'object') return null;
  const obj = raw as Partial<SceneNpcToken>;
  if (typeof obj.id !== 'string' || !obj.id) return null;
  if (typeof obj.npcId !== 'string' || !obj.npcId) return null;
  const nx = typeof obj.nx === 'number' && Number.isFinite(obj.nx) ? obj.nx : null;
  const ny = typeof obj.ny === 'number' && Number.isFinite(obj.ny) ? obj.ny : null;
  if (nx === null || ny === null) return null;
  return {
    id: asSceneNpcTokenId(obj.id),
    npcId: asNpcId(obj.npcId),
    nx: Math.max(0, Math.min(1, nx)),
    ny: Math.max(0, Math.min(1, ny)),
    sizeN: clampSceneNpcTokenSizeN(typeof obj.sizeN === 'number' ? obj.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N),
    disposition: normalizeNpcDisposition((obj as { disposition?: unknown }).disposition),
    ...(() => {
      const path = normalizeTokenPath((obj as { path?: unknown }).path);
      return path ? { path } : {};
    })(),
  };
}
