// Группировка слотов в «полосы» (strips) для ручного выбора полосы мышью.
//
// Полоса = слоты ОДНОГО КА + ОДИН крен (с допуском ROLL_EPSILON) + ПОДРЯД по времени (без разрыва
// каденции). Виток/цикл в границу НЕ входят: у экватора номер витка меняется, а слоты остаются в
// одной полосе, поэтому группируем только по (КА, крен, непрерывность по времени).
//
// Это ТОЛЬКО identity-группировка (какой слот к какой полосе относится), НЕ проверка выполнимости
// брони. Проверка манёвренности КА (mmi/maxSurveyDuration/maxChainLength/паузы) живёт в slots-service
// по-КА из каталога и будет вынесена в бэкенд validate-эндпоинт (iteration 2), чтобы не дублировать
// физику на клиенте.

import type { SlotDTO } from "../../model/slotTypes";
import { parseLocal } from "../../utils/localTime";

/**
 * Допуск сравнения крена (градусы): слоты считаются «одним креном», если разрыв по крену между
 * соседями кластера ≤ ROLL_EPSILON. Подобран по реальным данным — «веер» нацеливаний идёт с шагом
 * ~3°, поэтому 0.5° надёжно разделяет соседние нацеливания и терпит дрейф крена внутри полосы.
 */
export const ROLL_EPSILON = 0.5;

/**
 * Во сколько раз разрыв по времени должен превысить типичную каденцию полосы, чтобы считаться
 * пропуском и разорвать полосу на две. Полоса — это слоты подряд (через равные шаги по времени);
 * заметно больший разрыв означает «другой проход», т.е. другую полосу.
 */
export const CADENCE_GAP_FACTOR = 1.5;

/** Идентификатор полосы. Стабилен и читаем: `polosa:${slotKey первого по времени члена}`. */
export type PolosaId = string;

/**
 * Результат разметки: id полосы по ключу слота и упорядоченные по времени члены каждой полосы
 * (членение нужно жесту «выбрать от первого к последнему» — взять срез между двумя кликами).
 */
export type StripGrouping = {
  idByKey: Map<string, PolosaId>;
  membersOf: Map<PolosaId, string[]>;
};

/** Минимальный набор полей слота, нужный для группировки. */
type Stripable = Pick<SlotDTO, "satelliteId" | "slotNumber" | "cycle" | "tn" | "tk" | "roll">;

type Item = { key: string; sat: number; roll: number; tnMs: number };

function cmpStr(a: string, b: string): number {
  return a < b ? -1 : a > b ? 1 : 0;
}

/** Медиана массива (для типичной каденции полосы). Пустой массив → 0. */
function median(values: readonly number[]): number {
  if (values.length === 0) return 0;
  const s = [...values].sort((a, b) => a - b);
  const mid = Math.floor(s.length / 2);
  return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}

/**
 * Делит кластер слотов одного крена (одного КА) на полосы по непрерывности времени: сортирует по
 * времени и режет там, где разрыв заметно больше типичной каденции (медиана шагов × CADENCE_GAP_FACTOR).
 * До двух членов каденцию определить нельзя — считаем их одной полосой.
 */
function splitByCadence(cluster: readonly Item[]): Item[][] {
  const sorted = [...cluster].sort((a, b) => a.tnMs - b.tnMs || cmpStr(a.key, b.key));
  if (sorted.length <= 2) return [sorted];

  const gaps: number[] = [];
  for (let i = 1; i < sorted.length; i++) gaps.push(sorted[i].tnMs - sorted[i - 1].tnMs);
  const cadence = median(gaps.filter((g) => g > 0));
  const threshold = cadence > 0 ? cadence * CADENCE_GAP_FACTOR : Infinity;

  const runs: Item[][] = [];
  let run: Item[] = [sorted[0]];
  for (let i = 1; i < sorted.length; i++) {
    if (sorted[i].tnMs - sorted[i - 1].tnMs > threshold) {
      runs.push(run);
      run = [];
    }
    run.push(sorted[i]);
  }
  runs.push(run);
  return runs;
}

/**
 * Размечает слоты по полосам. Полоса — слоты одного КА с одним креном (кластеризация по крену с
 * допуском ROLL_EPSILON), идущие подряд по времени (разрыв каденции рвёт полосу). Виток/цикл не
 * учитываются — полоса может пересекать смену витка.
 *
 * Детерминизм: результат не зависит от порядка входного массива — внутри сортируем по крену и времени,
 * id полосы = ключ её первого по времени члена.
 *
 * @param slots Слоты для разметки (любой порядок).
 * @param keyOf Аксессор ключа слота (как `keyAt` в selectionAfterCheck) — чтобы не дублировать
 *   формулу `${satelliteId}:${slotNumber}:${cycle}`.
 */
export function groupStrips<T extends Stripable>(
  slots: readonly T[],
  keyOf: (slot: T) => string,
): StripGrouping {
  const idByKey = new Map<string, PolosaId>();
  const membersOf = new Map<PolosaId, string[]>();
  if (slots.length === 0) return { idByKey, membersOf };

  const assign = (members: readonly Item[]) => {
    const keys = members.map((m) => m.key);
    const id: PolosaId = `polosa:${keys[0]}`;
    membersOf.set(id, keys);
    for (const k of keys) idByKey.set(k, id);
  };

  const bySat = new Map<number, Item[]>();
  for (const slot of slots) {
    const item: Item = {
      key: keyOf(slot),
      sat: slot.satelliteId,
      roll: slot.roll,
      tnMs: parseLocal(slot.tn),
    };
    // Слот без валидного времени не встроить в полосу по непрерывности — отдельная полоса-синглтон.
    if (Number.isNaN(item.tnMs)) {
      assign([item]);
      continue;
    }
    const arr = bySat.get(item.sat) ?? [];
    arr.push(item);
    bySat.set(item.sat, arr);
  }

  for (const sat of Array.from(bySat.keys()).sort((a, b) => a - b)) {
    const items = bySat.get(sat)!;
    // Кластеризация по крену: сортируем по roll, начинаем новый кластер при разрыве > ROLL_EPSILON.
    items.sort((a, b) => a.roll - b.roll || a.tnMs - b.tnMs || cmpStr(a.key, b.key));
    let cluster: Item[] = [];
    let prevRoll = Number.NaN;
    const flush = () => {
      if (cluster.length > 0) for (const run of splitByCadence(cluster)) assign(run);
    };
    for (const item of items) {
      if (cluster.length > 0 && item.roll - prevRoll > ROLL_EPSILON) {
        flush();
        cluster = [];
      }
      cluster.push(item);
      prevRoll = item.roll;
    }
    flush();
  }

  return { idByKey, membersOf };
}

/**
 * Якорь полосы для жеста на карте: из ключей слотов под курсором берём самый ранний по времени —
 * это «первый слот полосы» (правило оператора: при наложении начало = первый по времени). Слоты без
 * валидного времени игнорируем; тай-брейк при равном времени (веер кренов в один момент) — по ключу
 * (детерминизм). Пустой/полностью невалидный вход → null.
 *
 * @param candidateKeys Ключи слотов под курсором (порядок не важен).
 * @param tnMsOf Время начала слота в мс (как parseLocal(tn)); NaN — слот пропускаем.
 */
export function pickStripAnchor(
  candidateKeys: readonly string[],
  tnMsOf: (key: string) => number,
): string | null {
  let best: string | null = null;
  let bestTn = Number.POSITIVE_INFINITY;
  for (const key of candidateKeys) {
    const tn = tnMsOf(key);
    if (Number.isNaN(tn)) continue;
    if (best === null || tn < bestTn || (tn === bestTn && key < best)) {
      best = key;
      bestTn = tn;
    }
  }
  return best;
}

/**
 * Конец полосы для жеста на карте: из ключей под курсором берём члена полосы anchorId, самого
 * ПОЗДНЕГО по времени (правило оператора: при наложении конец = последний по времени). Время = индекс
 * в membersOf (он уже отсортирован по времени), поэтому берём максимальный индекс. Ключи не из этой
 * полосы игнорируем — так выделение не уезжает за границу полосы. Никого из полосы под курсором → null
 * (вызывающий оставляет прежний конец — «клампим» к последнему валидному).
 *
 * @param grouping Разметка полос (из groupStrips).
 * @param anchorId Полоса якоря.
 * @param candidateKeys Ключи слотов под курсором.
 */
export function pickStripEnd(
  grouping: StripGrouping,
  anchorId: PolosaId,
  candidateKeys: readonly string[],
): string | null {
  const members = grouping.membersOf.get(anchorId);
  if (!members) return null;
  let best: string | null = null;
  let bestIdx = -1;
  for (const key of candidateKeys) {
    if (grouping.idByKey.get(key) !== anchorId) continue;
    const idx = members.indexOf(key);
    if (idx > bestIdx) {
      bestIdx = idx;
      best = key;
    }
  }
  return best;
}
