// Приближённая клиентская подсказка «конфликтует с текущим выбором». Слот гаснет, если он того же
// КА, что и какой-то выбранный под бронь слот, и не выдержан зазор на манёвр между ними:
//   • крен НЕ меняется  → между концом одного и началом другого нужно ≥ 10 с;
//   • крен меняется      → нужно ≥ 30 с (на перенацеливание дольше).
// Меньший зазор (вплоть до пересечения) → конфликт. «Крен меняется» = расхождение крена > ROLL_EPSILON
// (тот же допуск, по которому слоты кластеризуются в полосы).
//
// Это ПРИБЛИЖЁННАЯ клиентская оценка, не полная проверка выполнимости: реальная физика (профиль slew,
// maxChainLength, maxSurveyDuration) живёт в slots-service по-КА и проверяется бэкенд-эндпоинтом
// (итерация 2). Слоты ОДНОЙ полосы (одна непрерывная съёмка) между собой не конфликтуют — см. groupOf.
//
// Конфликтные слоты не только гаснут, но и НЕ ДОБАВЛЯЮТСЯ в выбор под бронь (жёсткая блокировка на
// стороне UI). Уже выбранные слоты не трогаем (конфликт считается только для НЕвыбранных кандидатов),
// поэтому снять выбор можно всегда. Окончательный арбитр выполнимости — бэкенд.

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

type Conflictable = Pick<SlotDTO, "satelliteId" | "tn" | "tk" | "roll">

/** Требуемый зазор между концом одного и началом следующего слота того же КА (мс). */
export const SAME_ROLL_GAP_MS = 10_000
export const DIFF_ROLL_GAP_MS = 30_000

/** Выбранный интервал слота: окно [start,end) (мс), крен и id полосы — для выбора нужного зазора. */
type SelInterval = { start: number; end: number; roll: number; group?: string }

/** Занятые интервалы выбранных слотов одного КА: отсортированы по началу + макс. длительность. */
type SatSelection = { intervals: SelInterval[]; maxDur: number }

/**
 * Конфликтует ли кандидат [cStart,cEnd) с креном cRoll с выбранным интервалом s того же КА:
 * упорядочиваем по началу и проверяем зазор (начало позднего − конец раннего) против требуемого
 * (10 с при том же крене, 30 с при смене крена). Пересечение даёт отрицательный зазор → конфликт.
 */
function conflictsPair(cStart: number, cEnd: number, cRoll: number, s: SelInterval): boolean {
  const required = Math.abs(cRoll - s.roll) <= ROLL_EPSILON ? SAME_ROLL_GAP_MS : DIFF_ROLL_GAP_MS
  const [earlierEnd, laterStart] = cStart <= s.start ? [cEnd, s.start] : [s.end, cStart]
  return laterStart - earlierEnd < required
}

/** Первый индекс, у которого intervals[i].start >= target (бинарный поиск по отсортированному). */
function lowerBound(intervals: SelInterval[], target: number): number {
  let lo = 0
  let hi = intervals.length
  while (lo < hi) {
    const mid = (lo + hi) >> 1
    if (intervals[mid].start < target) lo = mid + 1
    else hi = mid
  }
  return lo
}

/**
 * Возвращает множество ключей кандидатов, конфликтующих с текущим выбором (тот же КА + недостаточный
 * зазор на манёвр). Выбранные и «заблокированные» (бронь/BUSY) слоты в результат не попадают.
 *
 * Производительность: занятые интервалы строим из source (полный набор; выбор обычно мал), сортируем
 * по началу и для каждого кандидата проверяем не всех выбранных, а лишь окно по времени (≤ зазора +
 * макс. длительности) через бинарный поиск → ~O(кандидаты·log(выбор)). За кандидатов берём видимый
 * набор, а не весь — невидимое всё равно не рисуется.
 *
 * @param source Полный набор слотов — источник занятых интервалов выбранных.
 * @param candidates Слоты, которые помечаем (обычно видимые: таблица/карта).
 * @param keyOf Аксессор ключа слота.
 * @param isSelected Входит ли слот в текущий набор под бронь.
 * @param isBlocked Бронь/BUSY — такой слот не помечаем как конфликт (у него своя индикация).
 * @param groupOf Id полосы слота (одна непрерывная съёмка): слоты ОДНОЙ полосы между собой не
 *   конфликтуют (это один манёвр, без перенацеливания), поэтому такую пару пропускаем. Без аксессора
 *   полосы не учитываются.
 */
export function computeConflicts<T extends Conflictable>(
  source: readonly T[],
  candidates: readonly T[],
  keyOf: (slot: T) => string,
  isSelected: (slot: T) => boolean,
  isBlocked: (slot: T) => boolean,
  groupOf?: (slot: T) => string | undefined,
): Set<string> {
  const bySat = new Map<number, SatSelection>()
  for (const slot of source) {
    if (!isSelected(slot)) continue
    const start = parseLocal(slot.tn)
    const end = parseLocal(slot.tk)
    if (!Number.isFinite(start) || !Number.isFinite(end)) continue
    let bucket = bySat.get(slot.satelliteId)
    if (!bucket) {
      bucket = { intervals: [], maxDur: 0 }
      bySat.set(slot.satelliteId, bucket)
    }
    bucket.intervals.push({ start, end, roll: slot.roll, group: groupOf?.(slot) })
    bucket.maxDur = Math.max(bucket.maxDur, end - start)
  }

  const conflicts = new Set<string>()
  if (bySat.size === 0) return conflicts
  for (const bucket of bySat.values()) bucket.intervals.sort((a, b) => a.start - b.start)

  for (const slot of candidates) {
    if (isSelected(slot) || isBlocked(slot)) continue
    const bucket = bySat.get(slot.satelliteId)
    if (!bucket) continue
    const start = parseLocal(slot.tn)
    const end = parseLocal(slot.tk)
    if (!Number.isFinite(start) || !Number.isFinite(end)) continue
    const group = groupOf?.(slot)
    // Конфликтовать может только выбранный интервал в окне по времени: его start в диапазоне
    // [start − maxGap − maxDur, end + maxGap]. Вне окна зазор заведомо больше требуемого.
    const ints = bucket.intervals
    const highStart = end + DIFF_ROLL_GAP_MS
    for (let i = lowerBound(ints, start - DIFF_ROLL_GAP_MS - bucket.maxDur); i < ints.length; i++) {
      if (ints[i].start > highStart) break
      // Одна полоса — одна непрерывная съёмка: её слоты между собой не конфликтуют.
      if (group !== undefined && ints[i].group === group) continue
      if (conflictsPair(start, end, slot.roll, ints[i])) {
        conflicts.add(keyOf(slot))
        break
      }
    }
  }
  return conflicts
}
