/** Path animation for scene tokens / NPC tokens (project-persisted). */

export type TokenPathPoint = { nx: number; ny: number };

/** How the token faces while moving. */
export type TokenPathFacingMode = 'tangentSmooth' | 'fixed';

/**
 * once — play to end and stay.
 * pingpong — reverse at ends forever (until stopped).
 * loop — only when path is closed (explicit «Замкнуть»).
 */
export type TokenPathLoopMode = 'once' | 'pingpong' | 'loop';

/** When playback starts after the scene becomes current. */
export type TokenPathStartMode = 'onEnter' | 'delayed';

export type TokenPath = {
  points: TokenPathPoint[];
  /** Explicit close (RMB «Замкнуть» on a point). Enables loop mode. */
  closed: boolean;
  loopMode: TokenPathLoopMode;
  /** Seconds to traverse the full path once (or one direction for pingpong). */
  durationSec: number;
  startMode: TokenPathStartMode;
  /** Delay after scene enter when startMode === 'delayed'. */
  delaySec: number;
  facingMode: TokenPathFacingMode;
  /** Used when facingMode === 'fixed'. */
  fixedRotationDeg: number;
};

export const TOKEN_PATH_MIN_POINT_DIST = 0.02;
export const TOKEN_PATH_DURATION_MIN_SEC = 0.5;
export const TOKEN_PATH_DURATION_MAX_SEC = 600;
export const TOKEN_PATH_DELAY_MAX_SEC = 600;
export const DEFAULT_TOKEN_PATH_DURATION_SEC = 8;
export const DEFAULT_TOKEN_PATH_DELAY_SEC = 3;

export function clamp01(v: number): number {
  if (!Number.isFinite(v)) return 0;
  return Math.max(0, Math.min(1, v));
}

export function distNorm(a: TokenPathPoint, b: TokenPathPoint): number {
  const dx = a.nx - b.nx;
  const dy = a.ny - b.ny;
  return Math.hypot(dx, dy);
}

export function createEmptyTokenPath(): TokenPath {
  return {
    points: [],
    closed: false,
    loopMode: 'once',
    durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC,
    startMode: 'onEnter',
    delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC,
    facingMode: 'tangentSmooth',
    fixedRotationDeg: 0,
  };
}

export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode {
  return raw === 'fixed' ? 'fixed' : 'tangentSmooth';
}

export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode {
  if (raw === 'pingpong') return 'pingpong';
  if (raw === 'loop' && closed) return 'loop';
  return 'once';
}

export function normalizeTokenPathStartMode(raw: unknown): TokenPathStartMode {
  return raw === 'delayed' ? 'delayed' : 'onEnter';
}

export function clampTokenPathDurationSec(raw: unknown): number {
  const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_TOKEN_PATH_DURATION_SEC;
  return Math.max(TOKEN_PATH_DURATION_MIN_SEC, Math.min(TOKEN_PATH_DURATION_MAX_SEC, n));
}

export function clampTokenPathDelaySec(raw: unknown): number {
  const n = typeof raw === 'number' && Number.isFinite(raw) ? raw : DEFAULT_TOKEN_PATH_DELAY_SEC;
  return Math.max(0, Math.min(TOKEN_PATH_DELAY_MAX_SEC, n));
}

export function normalizeTokenPathPoint(raw: unknown): TokenPathPoint | null {
  if (!raw || typeof raw !== 'object') return null;
  const obj = raw as Partial<TokenPathPoint>;
  if (typeof obj.nx !== 'number' || !Number.isFinite(obj.nx)) return null;
  if (typeof obj.ny !== 'number' || !Number.isFinite(obj.ny)) return null;
  return { nx: clamp01(obj.nx), ny: clamp01(obj.ny) };
}

/** Drop points closer than TOKEN_PATH_MIN_POINT_DIST to the previous kept point. */
export function filterMinPointDistance(points: readonly TokenPathPoint[]): TokenPathPoint[] {
  if (points.length === 0) return [];
  const out: TokenPathPoint[] = [{ ...points[0]! }];
  for (let i = 1; i < points.length; i += 1) {
    const p = points[i]!;
    const prev = out[out.length - 1]!;
    if (distNorm(prev, p) + 1e-9 >= TOKEN_PATH_MIN_POINT_DIST) {
      out.push({ ...p });
    }
  }
  return out;
}

export function normalizeTokenPath(raw: unknown): TokenPath | null {
  if (raw == null) return null;
  if (!raw || typeof raw !== 'object') return null;
  const obj = raw as Partial<TokenPath>;
  const points0 = Array.isArray(obj.points)
    ? obj.points.map(normalizeTokenPathPoint).filter((p): p is TokenPathPoint => Boolean(p))
    : [];
  const points = filterMinPointDistance(points0);
  if (points.length < 2) return null;
  const closed = Boolean(obj.closed);
  const loopMode = normalizeTokenPathLoopMode(obj.loopMode, closed);
  return {
    points,
    closed,
    loopMode: closed ? loopMode : loopMode === 'loop' ? 'once' : loopMode,
    durationSec: clampTokenPathDurationSec(obj.durationSec),
    startMode: normalizeTokenPathStartMode(obj.startMode),
    delaySec: clampTokenPathDelaySec(obj.delaySec),
    facingMode: normalizeTokenPathFacingMode(obj.facingMode),
    fixedRotationDeg:
      typeof obj.fixedRotationDeg === 'number' && Number.isFinite(obj.fixedRotationDeg)
        ? obj.fixedRotationDeg
        : 0,
  };
}

/** Points used for drawing/length: if closed, append first at end when not already equal. */
export function tokenPathPolyline(path: TokenPath): TokenPathPoint[] {
  if (path.points.length === 0) return [];
  if (!path.closed) return path.points.map((p) => ({ ...p }));
  const first = path.points[0]!;
  const last = path.points[path.points.length - 1]!;
  if (distNorm(first, last) < 1e-6) return path.points.map((p) => ({ ...p }));
  return [...path.points.map((p) => ({ ...p })), { ...first }];
}

export function tokenPathTotalLength(path: TokenPath): number {
  const pts = tokenPathPolyline(path);
  let len = 0;
  for (let i = 1; i < pts.length; i += 1) {
    len += distNorm(pts[i - 1]!, pts[i]!);
  }
  return len;
}

export type TokenPathSample = {
  nx: number;
  ny: number;
  /** Degrees, CSS-style (0 = right? we use same as token rotationDeg — image up + rotate). */
  rotationDeg: number;
  /** Distance along polyline [0, length]. */
  dist: number;
};

function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

function segmentAngleDeg(a: TokenPathPoint, b: TokenPathPoint): number {
  // Screen y-down: atan2(dy, dx) with dy positive downward.
  const deg = (Math.atan2(b.ny - a.ny, b.nx - a.nx) * 180) / Math.PI;
  // Token art faces "up" (−Y); rotate so forward matches travel.
  return deg + 90;
}

function shortestAngleLerp(from: number, to: number, t: number): number {
  let delta = ((to - from + 540) % 360) - 180;
  return from + delta * t;
}

/**
 * Sample position/facing at distance `dist` along the polyline (clamped).
 */
export function sampleTokenPathAtDistance(path: TokenPath, dist: number): TokenPathSample | null {
  const pts = tokenPathPolyline(path);
  if (pts.length < 2) return null;
  const total = tokenPathTotalLength(path);
  if (total <= 1e-9) {
    const p = pts[0]!;
    return {
      nx: p.nx,
      ny: p.ny,
      rotationDeg: path.facingMode === 'fixed' ? path.fixedRotationDeg : 0,
      dist: 0,
    };
  }
  const d = Math.max(0, Math.min(total, dist));
  let walked = 0;
  for (let i = 1; i < pts.length; i += 1) {
    const a = pts[i - 1]!;
    const b = pts[i]!;
    const seg = distNorm(a, b);
    if (seg <= 1e-9) continue;
    if (walked + seg >= d - 1e-9) {
      const t = (d - walked) / seg;
      const nx = lerp(a.nx, b.nx, t);
      const ny = lerp(a.ny, b.ny, t);
      let rotationDeg = path.fixedRotationDeg;
      if (path.facingMode === 'tangentSmooth') {
        const ang = segmentAngleDeg(a, b);
        // Blend with previous segment for smoother corners.
        if (i >= 2) {
          const prevA = pts[i - 2]!;
          const prevAng = segmentAngleDeg(prevA, a);
          rotationDeg = shortestAngleLerp(prevAng, ang, Math.min(1, t + 0.35));
        } else {
          rotationDeg = ang;
        }
      }
      return { nx, ny, rotationDeg, dist: d };
    }
    walked += seg;
  }
  const last = pts[pts.length - 1]!;
  const prev = pts[pts.length - 2]!;
  return {
    nx: last.nx,
    ny: last.ny,
    rotationDeg:
      path.facingMode === 'fixed' ? path.fixedRotationDeg : segmentAngleDeg(prev, last),
    dist: total,
  };
}

/**
 * Progress u in [0,1] over one durationSec pass (pingpong handled by caller via triangle wave).
 */
export function sampleTokenPathAtProgress(path: TokenPath, u: number): TokenPathSample | null {
  const total = tokenPathTotalLength(path);
  const t = Math.max(0, Math.min(1, u));
  return sampleTokenPathAtDistance(path, t * total);
}

/**
 * Nearest point on the polyline that lies at or ahead of `fromDist` along the route direction.
 * Returns distance along path to that sample (for resume-after-drag).
 */
export function findNearestAheadDistance(
  path: TokenPath,
  nx: number,
  ny: number,
  fromDist: number,
): number {
  const pts = tokenPathPolyline(path);
  const total = tokenPathTotalLength(path);
  if (pts.length < 2 || total <= 1e-9) return 0;
  const start = Math.max(0, Math.min(total, fromDist));
  const samples = 64;
  let bestDist = start;
  let bestErr = Number.POSITIVE_INFINITY;
  for (let i = 0; i <= samples; i += 1) {
    const d = start + ((total - start) * i) / samples;
    const s = sampleTokenPathAtDistance(path, d);
    if (!s) continue;
    const err = Math.hypot(s.nx - nx, s.ny - ny);
    if (err < bestErr) {
      bestErr = err;
      bestDist = d;
    }
  }
  // Also check remaining if pingpong will reverse — for once/loop, ahead is enough.
  return bestDist;
}

export function reverseTokenPathPoints(points: readonly TokenPathPoint[]): TokenPathPoint[] {
  return [...points].reverse().map((p) => ({ ...p }));
}

export function rotateTokenPathByCwSteps(path: TokenPath, steps: number): TokenPath {
  const n = ((steps % 4) + 4) % 4;
  if (n === 0) {
    return {
      ...path,
      points: path.points.map((p) => ({ ...p })),
    };
  }
  // Import inline to avoid circular deps — callers use scenePreviewRotation.
  // We duplicate the CW formula here for a self-contained module OR accept a rotator.
  let points = path.points.map((p) => ({ ...p }));
  for (let s = 0; s < n; s += 1) {
    points = points.map((p) => ({ nx: clamp01(1 - p.ny), ny: clamp01(p.nx) }));
  }
  const fixedRotationDeg = path.fixedRotationDeg + n * 90;
  return { ...path, points, fixedRotationDeg };
}

/** Try add a point; returns null if too close to the last point. */
export function tryAppendPathPoint(
  points: readonly TokenPathPoint[],
  next: TokenPathPoint,
): TokenPathPoint[] | null {
  const p = { nx: clamp01(next.nx), ny: clamp01(next.ny) };
  if (points.length === 0) return [p];
  const last = points[points.length - 1]!;
  if (distNorm(last, p) < TOKEN_PATH_MIN_POINT_DIST) return null;
  return [...points, p];
}
