export type ApiError = {
  title: string
  detail: string
  status: number
}

export type CurrentUser = {
  id: string
  userName: string
  role: string
}

export type AuthResponse = {
  accessToken: string
  expiresAt: string
  user: CurrentUser
}

export type RegistrationStatus = { enabled: boolean }

export type SiteSettings = {
  registrationEnabled: boolean
  preferredAudioLanguages: string
  /** Переключение каналов по номерам у зрителя; сетка каналов остаётся всегда. */
  channelNumbersEnabled: boolean
}

/** Угол экрана для логотипа-оверлея. */
export type LogoCorner = 'TopLeft' | 'TopRight' | 'BottomLeft' | 'BottomRight'

/** Как канал выглядит у зрителя — всё опционально и по умолчанию выключено (см. 6.8). */
export type ViewerSettings = {
  logoImageId: string | null
  logoCorner: LogoCorner
  logoOpacity: number
  showClock: boolean
  analogFilterStrength: number
}

export type RoleDto = {
  id: string
  name: string
  isSystem: boolean
}

export type UserSummaryDto = {
  id: string
  userName: string
  role: string
  isBlocked: boolean
  createdAt: string
}

export type PagedList<T> = {
  items: T[]
  total: number
  page: number
  pageSize: number
}

export type CreatedIdResponse = { id: string }

// ── Медиа ────────────────────────────────────────────────────────────────
export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed'
type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated'

/** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */
export type ManualInboxFileDto = {
  relativePath: string
  /** Каталог внутри manual/ («» — корень): по нему список группируется. */
  folder: string
  name: string
  sizeBytes: number
  alreadyImported: boolean
}

/** Файл к разбору: номера серии уходят такими, какими их показал предпросмотр. */
export type ManualImportItem = {
  relativePath: string
  season: number | null
  episode: number | null
}

/** Кандидат из источника метаданных — то же, что показывает поиск на карточке шоу. */
export type MetadataCandidateDto = {
  externalId: string
  title: string
  year: number | null
  overview: string | null
  posterUrl: string | null
}

export type MovieMatchStatus = 'Confident' | 'Uncertain' | 'NotFound' | 'AlreadyInLibrary'

/** Строка разбора фильмов: что распозналось в имени и что по нему нашлось. */
export type MovieMatchDto = {
  name: string
  parsedTitle: string
  parsedYear: number | null
  status: MovieMatchStatus
  candidates: MetadataCandidateDto[]
  pickedExternalId: string | null
  existingShowId: string | null
  existingShowName: string | null
}

/** Фильм к заведению: файл берётся из manual/ (relativePath) либо уже загружен (assetId). */
export type MovieImportItem = {
  relativePath: string | null
  assetId: string | null
  title: string
  year: number | null
  externalId: string | null
}

export type ImportMoviesResultDto = {
  created: number
  enriched: number
  failed: { title: string; reason: string }[]
}

export type ManualInboxListDto = {
  files: ManualInboxFileDto[]
  /** Выдача обрезана лимитом — в каталоге есть ещё. */
  truncated: boolean
}

export type ImportManualInboxResultDto = {
  imported: number
  failed: { relativePath: string; reason: string }[]
}

export type MediaAssetDto = {
  id: string
  originalFileName: string
  source: MediaSource
  status: MediaAssetStatus
  durationSeconds: number | null
  width: number | null
  height: number | null
  errorMessage: string | null
  processingSeconds: number | null
  createdAt: string
}

export type MediaStatsDto = {
  queued: number
  processing: number
  averageProcessingSeconds: number | null
  /** Оценка времени до конца очереди, сек; null — очередь пуста либо среднее ещё не набралось. */
  estimatedRemainingSeconds: number | null
}

// ── Библиотека (жанры) ─────────────────────────────────────────────────────
export type GenreDto = {
  id: string
  name: string
  slug: string
  sortOrder: number
  isSystem: boolean
  /** Варианты написания для сопоставления с метаданными провайдеров. */
  aliases: string[]
  /** Сколько шоу используют жанр — при ненулевом удаление заблокировано. */
  showCount: number
}

// ── Библиотека (шоу) ───────────────────────────────────────────────────────
/** `Interstitial` — ролик-врезка: то же шоу, но со своим экраном и вне общей библиотеки. */
export type ShowKind = 'Series' | 'Single' | 'Interstitial'

/**
 * Возрастной рейтинг (MPAA), по возрастанию строгости — порядок значим для правил планировщика.
 * `null` у шоу означает «не проставлен», а не «подходит всем»: такой контент планировщик не отсекает.
 */
export type ShowAudience = 'G' | 'PG' | 'PG-13' | 'R' | 'NC-17'

/** Тот же порядок для селектов и списков. */
export const SHOW_AUDIENCES: ShowAudience[] = ['G', 'PG', 'PG-13', 'R', 'NC-17']

/**
 * Значение пункта «рейтинг не проставлен» в селектах. Пустая строка не годится: Radix резервирует её
 * под сброс и на пункте с value="" падает.
 */
export const AUDIENCE_UNSET = 'unset'

export type ShowSummaryDto = {
  id: string
  name: string
  originalName: string | null
  kind: ShowKind
  audience: ShowAudience | null
  episodeCount: number
  seasonCount: number
  year: number | null
  hasPoster: boolean
  createdAt: string
  /** Основной жанр — его показывает колонка списка. */
  primaryGenre: string | null
  /** Прочие жанры — в списке прячутся под «…». */
  otherGenres: string[]
}

/** Где шоу задействовано — показывается на его карточке. */
export type ShowUsageDto = {
  groups: ShowUsageRefDto[]
  collections: ShowUsageRefDto[]
  channels: ShowUsageRefDto[]
}

type ShowUsageRefDto = {
  id: string
  name: string
  /** Шоу попало в группу правилом, а не явной позицией. */
  viaRule: boolean
}

type ShowGenreDto = {
  id: string
  name: string
  isPrimary: boolean
}

// ── Ролики-врезки ──────────────────────────────────────────────────────────
/** Ролик: важна длительность, поэтому она приходит сразу, а не вторым запросом за ассетом. */
export type InterstitialDto = {
  id: string
  name: string
  mediaAssetId: string | null
  assetStatus: MediaAssetStatus | null
  durationSeconds: number | null
  createdAt: string
}

/** Рекламный блок — коллекция, целиком собранная из роликов. */
export type InterstitialBlockDto = {
  id: string
  name: string
  itemCount: number
  durationSeconds: number
}

// ── Библиотека (коллекции) ─────────────────────────────────────────────────
export type CollectionSummaryDto = {
  id: string
  name: string
  description: string | null
  posterImageId: string | null
  /** Сколько частей в коллекции. */
  itemCount: number
  /** Сколько единиц воспроизведения суммарно — у сериала внутри коллекции их больше одной. */
  unitCount: number
  createdAt: string
}

type CollectionItemDto = {
  showId: string
  position: number
  showName: string
  showKind: ShowKind
  showAudience: ShowAudience | null
  episodeCount: number
  year: number | null
  posterImageId: string | null
}

export type CollectionDto = {
  id: string
  name: string
  description: string | null
  posterImageId: string | null
  createdAt: string
  items: CollectionItemDto[]
}

/** Откуда взялось предложение франшизы: данные источника либо догадка по названиям. */
export type CollectionSuggestionSource = 'Metadata' | 'NameGuess'

/**
 * Предложение собрать франшизу: имя и части в предлагаемом порядке. `collectionId` задан —
 * франшиза уже собрана, и предлагается дополнить её недостающими частями.
 */
export type CollectionSuggestionDto = {
  key: string
  source: CollectionSuggestionSource
  name: string
  parts: { showId: string; name: string; year: number | null }[]
  alreadyExists: boolean
  collectionId: string | null
}

/** Коллекция, в которую входит шоу — для блока на экране шоу. */
type ShowCollectionRefDto = {
  id: string
  name: string
  position: number
}

// ── Группы контента (планировщик) ──────────────────────────────────────────
export type GroupElementKind = 'Show' | 'Collection'

/** Правило быстрого набора состава. Пустые поля не ограничивают. */
export type GroupFilter = {
  elementKinds?: GroupElementKind[] | null
  showKinds?: ShowKind[] | null
  genreIds?: string[] | null
  maxAudience?: ShowAudience | null
  minAudience?: ShowAudience | null
  yearMin?: number | null
  yearMax?: number | null
  unitMinutesMin?: number | null
  unitMinutesMax?: number | null
}

export type GroupSummaryDto = {
  id: string
  name: string
  description: string | null
  itemCount: number
  unitCount: number
  totalDurationSeconds: number
  hasFilter: boolean
  mode: GroupMode
  statsComputedAt: string | null
  createdAt: string
}

/** Итог массового обогащения метаданными: что применилось и что пропущено с причиной. */
export type EnrichShowsResult = {
  applied: number
  skipped: { showId: string; showName: string; reason: string }[]
}

/** Откуда группа берёт состав: явный список либо вычисление правилом. */
export type GroupMode = 'Static' | 'Dynamic'

type GroupItemDto = {
  /** null — элемент найден правилом динамической группы: своей строки, веса и порядка у него нет. */
  id: string | null
  elementKind: GroupElementKind
  elementId: string
  elementName: string
  weight: number
  position: number
  unitCount: number
  showKind: ShowKind | null
  audience: ShowAudience | null
  year: number | null
  posterImageId: string | null
  /** Закреплён руками в динамической группе — правило его не выбрасывает. */
  pinned: boolean
}

/** Элемент, исключённый из динамической группы вручную. */
export type GroupExcludedDto = {
  elementKind: GroupElementKind
  elementId: string
  elementName: string
}

export type GroupDto = {
  id: string
  name: string
  description: string | null
  mode: GroupMode
  filter: GroupFilter | null
  itemCount: number
  unitCount: number
  totalDurationSeconds: number
  statsComputedAt: string | null
  items: GroupItemDto[]
  excluded: GroupExcludedDto[]
}

/** Кандидат, найденный правилом набора. */
export type GroupCandidateDto = {
  elementKind: GroupElementKind
  elementId: string
  elementName: string
  unitCount: number
  showKind: ShowKind | null
  audience: ShowAudience | null
  year: number | null
  posterImageId: string | null
  alreadyInGroup: boolean
}

/** Откуда взялось предложение группы — по нему подписывается происхождение. */
export type GroupSuggestionKind = 'Genre' | 'ShowKind' | 'Audience' | 'BigSeries'

/** Предложение завести группу: имя, объём и признак «уже создана». */
export type GroupSuggestionDto = {
  key: string
  kind: GroupSuggestionKind
  name: string
  showCount: number
  unitCount: number
  totalDurationSeconds: number
  alreadyExists: boolean
}

export type MetadataCandidate = {
  externalId: string
  title: string
  year: number | null
  overview: string | null
  posterUrl: string | null
  /** Сериал или полнометражка; null — источник ищет только по одному типу. */
  kind: ShowKind | null
}

type SeasonGapDto = {
  season: number
  expected: number | null
  loaded: number
  missing: number[]
}

export type MissingEpisodesReport = {
  seasons: SeasonGapDto[]
}

export type MissingSeasonsReport = {
  /** Сколько сезонов у сериала по источнику; null — источник не отдал данные. */
  expected: number | null
  /** Номера сезонов, где загружена хотя бы одна серия. */
  loaded: number[]
  /** Номера сезонов из источника, где не загружено ни одной серии. */
  missing: number[]
  /** Загруженные сезоны, которых источник не знает. */
  unknown: number[]
}

type EpisodeDto = {
  id: string
  mediaAssetId: string
  position: number
  assetName: string | null
  assetStatus: MediaAssetStatus | null
  durationSeconds: number | null
  season: number | null
  episode: number | null
  title: string | null
  overview: string | null
  stillImageId: string | null
}

export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'

export type ImageDto = {
  id: string
  category: ImageCategory
  originalFileName: string | null
  createdAt: string
}

export type ShowDto = {
  id: string
  name: string
  originalName: string | null
  kind: ShowKind
  audience: ShowAudience | null
  description: string | null
  metadataProvider: string | null
  metadataExternalId: string | null
  year: number | null
  posterImageId: string | null
  episodes: EpisodeDto[]
  genres: ShowGenreDto[]
  collections: ShowCollectionRefDto[]
}

// ── Хранилище ─────────────────────────────────────────────────────────────
/** Части хранилища, которые считаются отдельно (см. StorageArea на сервере). */
export type StorageArea =
  | 'Assets'
  | 'BumperAssets'
  | 'Originals'
  | 'Inbox'
  | 'ManualInbox'
  | 'Uploads'
  | 'BumperSources'
  | 'Images'
  | 'Other'

export type StorageAreaDto = {
  area: StorageArea
  bytes: number
  files: number
}

export type StorageStatsDto = {
  rootPath: string
  /** Размер тома целиком; 0 — файловая система не отдала метрику. */
  volumeTotalBytes: number
  volumeFreeBytes: number
  /** Сколько занимает само хранилище TeleWave. */
  storageBytes: number
  storageFiles: number
  /** Порог, ниже которого загрузка отклоняется. */
  minFreeSpaceBytes: number
  areas: StorageAreaDto[]
  /** Когда посчитано: обход дерева кэшируется. */
  computedAt: string
}

// ── Каналы ────────────────────────────────────────────────────────────────
type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
export type BumperFont = 'Sans' | 'Serif'
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
export type BumperLineStyle = 'Label' | 'Title' | 'Caption'
export type BumperLineColor = 'Accent' | 'Text'
/** Что за картинка под текстом: фон блока, постер следующего или предыдущего шоу. */
export type BumperBackground = 'Template' | 'NextPoster' | 'NowPoster' | 'Clip'

/** Строка заставки: роль, цвет из палитры блока и текст с плейсхолдерами. */
export type BumperLineDto = {
  style: BumperLineStyle
  color: BumperLineColor
  text: string
}

/** Подблок (текст-вариант): свои строки, фон и правило показа поверх оформления блока. */
export type BumperTextVariantDto = {
  id: string
  position: number
  name: string
  trigger: BumperTrigger
  background: BumperBackground
  /** Вес при выборе подблока на переходе (0 — не выбирается никогда). */
  weight: number
  lines: BumperLineDto[]
  /** Ролик-подложка для фона `Clip`: текст пишется поверх готового видео. */
  backgroundClipShowId: string | null
}

/** Блок заставки — общий для всех каналов, как группа. */
export type BumperTemplateDto = {
  id: string
  name: string
  font: BumperFont
  backgroundColor: string
  backgroundColor2: string
  accentColor: string
  textColor: string
  backgroundImageId: string | null
  hasAudio: boolean
  audioDurationSeconds: number | null
  /** Во скольких врезках стыков используется блок — блоки общие, и это надо видеть до правки. */
  usageCount: number
  variants: BumperTextVariantDto[]
}

export type ChannelSummaryDto = {
  id: string
  name: string
  slug: string
  isEnabled: boolean
}

export type ChannelDto = {
  id: string
  name: string
  slug: string
  isEnabled: boolean
  /** Номер канала для переключения по номерам (null — не задан). */
  number: number | null
  /** Смещение времени канала от UTC, минуты (180 — московское). */
  utcOffsetMinutes: number
  /** Начало вещательных суток в времени канала («06:00:00»). */
  dayStartTime: string
  templateId: string | null
  fillerAssetId: string | null
  viewer: ViewerSettings
}

// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
export type Daypart = 'Morning' | 'Day' | 'Prime' | 'Night'
export type SlotKind = 'Content' | 'Repeat' | 'SignOff'
export type SlotBlockMode = 'Count' | 'Duration' | 'FillSlot'
export type OverflowPolicy = 'ContinueNext' | 'ExtendSlot' | 'SkipIfNotFits'
export type SlotStrategyType = 'Sequential' | 'RandomWithCooldown' | 'Fixed'
type CooldownFallback = 'OldestFirst' | 'IgnoreCooldown'

type SlotStrategy = {
  type: SlotStrategyType
  restartOnEnd: boolean
  cooldownDays: number
  fallback: CooldownFallback
  fixedElementId?: string | null
}

/** Что повторяет слот-повтор: точка в уже записанной ленте того же канала. */
type RepeatSource = { daysAgo: number; time: string; durationMinutes: number }

export type SlotDto = {
  id: string
  layerId: string
  /** День недели вещательных суток (0=Вс..6=Сб) или null — каждый день. */
  weekday: number | null
  targetStart: string
  targetDurationMinutes: number
  title: string
  daypart: Daypart
  slotKind: SlotKind
  groupId: string | null
  groupName: string | null
  strategy: SlotStrategy | null
  repeatSource: RepeatSource | null
  blockMode: SlotBlockMode
  blockValue: number
  overflowPolicy: OverflowPolicy
  isAnchor: boolean
  maxDriftMinutes: number
  /** Округление старта до кратного N минут (5/10/15/30) или null. */
  snapToMinutes: number | null
  /** Стык между единицами внутри слота (null — шаблон канала по умолчанию). */
  junctionBetweenId: string | null
  /** Стык после слота (null — шаблон канала по умолчанию). */
  junctionAfterId: string | null
}

// ── Стыки (что играет между программами) ──────────────────────────────────
export type JunctionElementKind = 'Ad' | 'Promo' | 'Bumper' | 'Filler'
export type JunctionAmountMode = 'Count' | 'Duration'

/** Окно суток канала; допускает переход через полночь («с 23:00 до 06:00»). */
export type JunctionTimeWindow = { from: string; to: string }

/** Условия показа врезки — структурные поля, а не выражение-строка. */
export type JunctionConditions = {
  onlyOnElementChange: boolean
  minMinutesBetween: number
  dayparts?: Daypart[] | null
  timeWindow?: JunctionTimeWindow | null
  chance: number
  /** Ставить только в пределах ±N минут от круглого часа; 0 — без привязки. */
  nearHourMinutes: number
}

export type JunctionElementDto = {
  id: string
  position: number
  kind: JunctionElementKind
  groupId: string | null
  groupName: string | null
  bumperTemplateId: string | null
  bumperTemplateName: string | null
  /** Конкретный подблок заставки; null — выбирается по триггеру перехода и весам. */
  bumperVariantId: string | null
  bumperVariantName: string | null
  amountMode: JunctionAmountMode
  amountValue: number
  isRequired: boolean
  /** Метка развилки: из врезок с одной меткой играет одна, выбранная по весам. */
  choiceKey: string | null
  choiceWeight: number
  conditions: JunctionConditions | null
}

/** Стык — общий для всех каналов; канал только ссылается на него слотами. */
export type JunctionTemplateDto = {
  id: string
  name: string
  maxTotalSeconds: number | null
  /** Сколько каналов ссылается на стык — он общий, и это надо видеть до правки. */
  channelUsageCount: number
  elements: JunctionElementDto[]
}

export type DateRange = { from: string; to: string }
export type AnnualRange = { fromMonth: number; fromDay: number; toMonth: number; toDay: number }

/** Когда действует слой. Пустая применимость — слой действует всегда. */
export type LayerApplicability = {
  weekdays?: number[] | null
  dateRanges?: DateRange[] | null
  annualRanges?: AnnualRange[] | null
  specificDates?: string[] | null
}

export type GridLayerDto = {
  id: string
  name: string
  priority: number
  isEnabled: boolean
  isBackground: boolean
  applicability: LayerApplicability | null
  slots: SlotDto[]
}

/** Окно детского времени: до `to` в эфир идёт контент не строже `maxAudience`. */
export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudience }

/** Правила отбора кандидатов канала — жёсткие фильтры, применяются до жребия. */
export type PlanningRules = {
  maxAudienceByTime?: AudienceWindow[] | null
  maxRepeatsInWindow?: { windowDays: number; max: number } | null
  /** Пост-проверки: считаются по готовой ленте и только предупреждают. */
  maxBreakMinutesPerHour?: number | null
  maxGenreSharePercent?: number | null
  maxFallbackSharePercent?: number | null
}

export type ScheduleTemplateDto = {
  id: string
  channelId: string
  name: string
  fallbackGroupId: string | null
  /** Стык, который берётся, когда слот своего не задал. */
  defaultJunctionId: string | null
  /** Детское время и потолок повторов. */
  rules: PlanningRules | null
  revision: number
  appliedRevision: number
  /** Есть ли правки правил, не применённые к эфиру. */
  hasPendingChanges: boolean
  /** Есть ли к чему откатываться — снимок появляется при первом применении. */
  canRestore: boolean
  utcOffsetMinutes: number
  dayStartTime: string
  layers: GridLayerDto[]
}

type PlanningWarningKind =
  | 'SlotEmpty'
  | 'DriftExceeded'
  | 'CooldownExhausted'
  | 'RepeatSourceEmpty'
  | 'FallbackEmpty'
  | 'CandidatesFiltered'
  | 'BreakLimitExceeded'
  | 'GenreShareExceeded'
  | 'FallbackShareExceeded'

export type PlanningWarningDto = {
  kind: PlanningWarningKind
  slotId: string | null
  details: string
}

export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }

/** Что изменится в эфире, если применить правила сейчас (см. 6.6). */
type ScheduleChangeDto = {
  startsAtUtc: string
  before: string | null
  after: string | null
  /** Попадает в ближайшие сутки — подсвечивается отдельно. */
  soon: boolean
}

export type ScheduleDiffDto = {
  total: number
  changed: number
  changedSoon: number
  changes: ScheduleChangeDto[]
}

/** Цепочка происхождения записи — «почему это здесь» (см. 6.5). */
export type EntryTraceDto = {
  entryId: string
  startsAtUtc: string
  endsAtUtc: string
  showName: string | null
  episodeIndex: number | null
  layerName: string | null
  layerPriority: number | null
  slotTitle: string | null
  slotKind: SlotKind | null
  slotWeekday: number | null
  slotTargetStart: string | null
  slotDurationMinutes: number | null
  groupName: string | null
  groupItemCount: number | null
  /** Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу. */
  collectionName: string | null
  strategy: SlotStrategyType | null
  cooldownDays: number | null
  candidatesAfterCooldown: number | null
  driftMinutes: number
  snapped: boolean
  junctionName: string | null
}

export type CopyTemplateResultDto = {
  layers: number
  slots: number
  junctions: number
  /** Врезки-заставки, для которых на канале-приёмнике не нашлось блока с таким же именем. */
  droppedBumperRefs: number
}

// ── Автосборка сетки ──────────────────────────────────────────────────────

/** Архетип сетки: с какого типа канала списан ритм суток. */
export type GridProfileKind = 'Mixed' | 'Animation' | 'Sitcom' | 'Music' | 'Movies' | 'Kids'

/** Что делать с тем, что в сетке уже есть. */
export type GridGenerationMode = 'FillGaps' | 'Rebuild'

export type GridProfileDto = {
  kind: GridProfileKind
  name: string
  /** На что похоже — «как у 2×2». */
  reference: string
  description: string
}

/** Строка предпросмотра: будущий слот до того, как он создан. */
export type GridPlanSlotDto = {
  layer: 'Main' | 'Weekend' | 'Season'
  weekday: number | null
  start: string
  durationMinutes: number
  title: string
  daypart: Daypart
  slotKind: SlotKind
  groupName: string | null
  /** Человеческое описание блока: «4 подряд», «до конца слота». */
  block: string
}

/** Замечание к плану: `isError` — то, что после сборки останется дырой в эфире. */
export type PlanNoteDto = { isError: boolean; text: string }

export type GridPlanDto = {
  profile: GridProfileKind
  profileName: string
  slots: GridPlanSlotDto[]
  notes: PlanNoteDto[]
  /** Больше нуля только при пересборке — столько слотов будет снесено. */
  slotsToRemove: number
  fallbackGroupName: string | null
  freeMinutes: number
  coveredMinutes: number
}

export type GenerateGridResultDto = { created: number; removed: number }

/**
 * Файл обмена конфигурацией сетки. Ссылки на группы и стыки — по именам: файл переносится между
 * каналами и установками, а сетку для импорта пишет в том числе ИИ, который GUID может только
 * выдумать. Схема повторяет серверную GridConfig.
 */
export type GridConfigSlot = {
  title: string
  start: string
  durationMinutes: number
  daypart?: Daypart
  kind?: SlotKind
  weekday?: number | null
  group?: string | null
  strategy?: SlotStrategy | null
  repeat?: RepeatSource | null
  blockMode?: SlotBlockMode
  blockValue?: number
  overflow?: OverflowPolicy
  isAnchor?: boolean
  maxDriftMinutes?: number
  snapToMinutes?: number | null
  junctionBetween?: string | null
  junctionAfter?: string | null
}

export type GridConfigLayer = {
  name: string
  priority: number
  slots: GridConfigSlot[]
  isEnabled?: boolean
  isBackground?: boolean
  applicability?: LayerApplicability | null
}

/** Коллекция в формате обмена: цикл или франшиза из шоу по названиям. */
export type GridConfigCollection = {
  name: string
  shows: string[]
  description?: string | null
}

/** Группа в формате обмена: шоу и коллекции по названиям — импорт заводит недостающие. */
export type GridConfigGroup = {
  name: string
  shows?: string[] | null
  collections?: string[] | null
  description?: string | null
}

export type GridConfig = {
  format: string
  version: number
  channel?: { name: string; dayStart: string; utcOffsetMinutes: number } | null
  rules?: PlanningRules | null
  fallbackGroup?: string | null
  defaultJunction?: string | null
  collections?: GridConfigCollection[] | null
  groups?: GridConfigGroup[] | null
  layers: GridConfigLayer[]
}

/** Итог импорта: пропущенные слоты — не ошибка, а список того, чего не нашлось в библиотеке. */
export type GridImportResultDto = {
  layers: number
  slots: number
  skipped: number
  removed: number
  groups: number
  collections: number
  warnings: string[]
}

/** Готовый запрос к ИИ и то, из чего он собран. */
export type GridPromptDto = {
  prompt: string
  groups: number
  shows: number
  characters: number
}

/** Итог отката сетки: сколько слотов осталось и сколько ссылок восстановить не удалось. */
export type RestoreTemplateResultDto = { slots: number; droppedRefs: number }

/** Итог сброса: сколько снято слотов и слоёв и сколько будущих записей эфира удалено. */
export type ResetTemplateResultDto = { slots: number; layers: number; entries: number }

/** Проверки сетки по правилам, до генерации (см. 5.1). */
type TemplateIssueKind =
  | 'GroupEmpty'
  | 'GroupTooSmall'
  | 'GridGap'
  | 'SlotOverlap'
  | 'CooldownUnreachable'
  | 'AudienceConflict'
  | 'GroupMissing'

export type TemplateIssueDto = {
  kind: TemplateIssueKind
  severity: 'Warning' | 'Error'
  layerId: string | null
  slotId: string | null
  details: string
}

/** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'

export type PreviewItemDto = {
  kind: PlannedItemKind
  startsAtUtc: string
  endsAtUtc: string
  showId: string | null
  title: string | null
  slotId: string | null
  slotTitle: string | null
}

export type SchedulePreviewDto = {
  fromUtc: string
  toUtc: string
  utcOffsetMinutes: number
  items: PreviewItemDto[]
  warnings: PlanningWarningDto[]
}

export type ScheduleEntryDto = {
  id: string
  kind: ScheduleEntryKind
  mediaAssetId: string
  startsAtUtc: string
  endsAtUtc: string
  showId: string | null
  showName: string | null
  episodeIndex: number | null
  seasonEpisode: string | null
  /** Для заставок: имя подблока и его текст — для метки в расписании. */
  bumperName: string | null
  bumperText: string | null
  /** Какой частью франшизы шла запись и сколько их всего; null — шло само по себе. */
  collectionPart: number | null
  collectionParts: number | null
  collectionName: string | null
}

// ── Публичный эфир ─────────────────────────────────────────────────────────
export type PublicChannelDto = {
  id: string
  slug: string
  name: string
  /** Номер канала для переключения по номерам или null. */
  number: number | null
  currentShowId: string | null
  currentShowName: string | null
  currentShowPosterImageId: string | null
  logoImageId: string | null
  logoCorner: LogoCorner
  logoOpacity: number
  showClock: boolean
  analogFilterStrength: number
}

export type PublicEpgEntryDto = {
  kind: ScheduleEntryKind
  startsAtUtc: string
  endsAtUtc: string
  showId: string | null
  showName: string | null
  showPosterImageId: string | null
  episodeId: string | null
  episodeTitle: string | null
  episodeOverview: string | null
  episodeStillImageId: string | null
}
