## Context

`worker` ведёт FSM загрузки (см. [workflow.md](../../../docs/specs/workflow.md)).
Сейчас `Poll` сверяет с qBittorrent только задачи в `downloading`
(`ListDownloadsByState(StateDownloading)`); терминальные задачи (`done`) с
реальностью не сверяются вовсе. Если раздача исчезла из qBittorrent, для
активной задачи код лишь пишет `Warn("active download not found")`; для
`done` не делает ничего.

Удаление просмотренного контента — штатная эксплуатационная операция, и оно
ручное: из qBittorrent (источник + скачанные файлы) или из Jellyfin (наши
хардлинки). Два инварианта при этом ломаются молча:

- **«Источник неприкосновенен»** перестаёт держаться, когда источника уже
  нет: библиотечный хардлинк становится последней копией, а `layout.Undo`
  безусловно `unlink`-ает цель.
- **Состояние `done` правдиво** перестаёт держаться, когда файлы убрали из
  библиотеки: ссылок нет, а БД числит `done`.

Данные, на которые опираемся: `download.infohash` (сопоставление с qBit),
`file_link.dst_path` + `status` (разложенные хардлинки), `file_link.src_path`
(исходный файл раздачи). qBittorrent уже листаем целиком в `Poll`.

## Goals / Non-Goals

**Goals:**

- Распознавать ручное удаление источника и/или цели фоновой сверкой и
  отражать его в состоянии задачи — **без автоматических действий**.
- Не допускать потери данных при `Undo`, когда источник уже удалён.
- Сделать рассинхрон видимым (состояние в UI + уведомление автору).
- Сохранить самовосстановление: вернулась реальность — вернулось состояние.

**Non-Goals:**

- Удаление средствами jellybit (path 2, «единое окно»).
- Автоперезапуск распознавания/раскладки при рассинхроне (relink — вручную).
- Реакция на удаление файлов **внутри** живой раздачи (это `missingFiles`/
  `error` qBittorrent — уже ведёт в `failed`).
- Ретеншн терминальных задач.

## Decisions

### D1. Двумерная матрица «источник × цель» → новые состояния FSM

Рассинхрон параметризуется двумя независимыми фактами. Для задачи, которая
дошла до раскладки, состояние выводится из них:

| источник (qBit) | цель (хардлинки) | состояние       |
|-----------------|------------------|-----------------|
| есть            | есть             | `done`          |
| есть            | нет              | `target_missing`|
| нет             | есть             | `orphaned`      |
| нет             | нет              | `deleted`       |

Выбрали **новые значения `state`**, а не флаги поверх состояния:
терминальность и набор доступных команд у этих ситуаций разные, а FSM —
единая точка правды (граф уже в `workflow.md`). Флаги размазали бы логику
«что можно делать» по двум осям.

- `target_missing` — источник на месте → доступен пользовательский relink
  (та же команда «Привязать заново», что из `reverted`/`cancelled`:
  `target_missing → recognizing`). Авто-переход в `recognizing` **не**
  делаем — это нарушило бы «никаких автодействий».
- `orphaned` — источник пропал, цель (последняя копия) на месте. Команд
  вперёд нет; `Undo` заблокирован (см. D4).
- `deleted` — пусто и там, и там; терминально, действий нет.

**Альтернатива (отклонено):** одно состояние `desynced` + поле-причина.
Хуже: разные исходы требуют разных команд и разной терминальности — проще
развести по состояниям.

### D2. Какие задачи и как сверяем

Desync-сверка применяется только к задачам, для которых ожидаем разложенные
файлы и осмысленную связь с источником: `done`, `target_missing`,
`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/
`deferred`/`linking`) и пользовательски-терминальные (`reverted`/
`cancelled`/`failed`/`stuck`) сверка **не трогает** (`reverted` = мы сами
сняли ссылки, это не рассинхрон).

- **Источник присутствует** ⟺ `download.infohash` найден среди торрентов
  qBittorrent (карту `byHash` `Poll` уже строит).
- **Цель присутствует** ⟺ все `file_link` со `status = linked` существуют на
  ФС (`os.Lstat`). Частичная пропажа (исчезла часть ссылок) трактуется как
  «цель отсутствует» → `target_missing` (библиотека сломана, лечится
  relink/слиянием).

Сверку делаем на том же тике `Poll` (5 с). При текущих объёмах (домашний
сервер) `Lstat` по ссылкам терминальных задач дёшев; если объём вырастет
(см. TODO «Ретеншн»), вынесем desync-сверку на отдельный, более редкий
интервал. Состояние выводим и переписываем только при изменении (без
лишних записей и логов на каждом тике).

### D3. Дебаунс пропажи источника

qBittorrent может временно пропасть из выдачи (рестарт демона, мигнул API),
тогда как локальный `Lstat` цели надёжен. Поэтому дебаунсим **только
отсутствие источника**:

- новый столбец `download.source_miss_count` (миграция goose);
- источник отсутствует на тике → `source_miss_count++`; найден →
  сбрасываем в `0`;
- источник считается удалённым (для вывода состояния) лишь когда
  `source_miss_count >= [worker].source_missing_threshold` (по умолчанию
  `3` → ~15 с при интервале 5 с). До порога источник трактуется как
  присутствующий — задача не дёргается.

Отсутствие цели в дебаунсе не нуждается (локальная ФС не «мигает»).

### D4. Безопасный `Undo`: не снимать последнюю копию

`layout.Undo` для каждой ссылки перед `unlink`:

1. `Lstat(dst)` — нет файла → нечего снимать, пропускаем (идемпотентно).
2. Иначе читаем `nlink` цели. `nlink <= 1` означает: это **единственная**
   ссылка на inode (источник уже удалён) — `unlink` сотрёт данные. Отказ:
   не трогаем файл, копим причину.
3. Доп. явный сигнал: `src_path` не существует → тоже отказ (источника нет).

Отказ возвращается типизированной ошибкой; задача в `reverted` **не**
переходит, причина показывается пользователю. На командном уровне `Undo`
для задачи в `orphaned` отклоняется сразу (по определению источник удалён →
все ссылки — последние копии); per-file `nlink`-проверка остаётся страховкой
на случай устаревшего состояния. Откат снимает **лишний** хардлинк, а не
последнюю копию.

### D5. Синхронный preflight перед действием (не доверяем `state` в БД)

Фоновая сверка (D2/D3) — eventual: она отстаёт на интервал поллинга плюс
дебаунс. Поэтому любая **команда**, которой нужен источник или цель, делает
собственную **синхронную пробу** прямо перед действием, а не полагается на
значение `state`:

- `relink`/«Распознать заново»/«Уточнить» и `Apply` (раскладка) — требуют
  источника (раздача в qBittorrent);
- `Undo` — требует источника (чтобы не снять последнюю копию).

Чтобы не дублировать логику, выделяем чистый помощник
`probe(download) → (sourcePresent, targetPresent)` и `deriveState(...)`,
которые используют **и** фоновая сверка, **и** preflight — единая точка
правды о том, что есть на диске и как это отображается в состояние.

Ключевое отличие preflight от фона: **без дебаунса** — это явное действие
пользователя «сейчас», единичная проба. Если qBittorrent в этот момент
недоступен, команда честно отказывает («источник недоступен») — пользователь
повторит. Дебаунс нужен только фону, чтобы не дёргать состояние на
транзиентных пропажах.

При неуспехе предусловия команда не выполняет действие, прогоняет
`deriveState` (приводя `state` к реальности — напр. `done → orphaned`) и
возвращает пользователю причину. Так команда сама «лечит» устаревшее
состояние, не дожидаясь `worker`.

### D6. Самовосстановление (healing)

Состояние всегда выводится из текущей матрицы D1 (с учётом дебаунса D3), а
не «залипает». Если источник вернулся (раздачу добавили заново) или цель
снова на месте — следующая сверка переведёт задачу обратно
(`orphaned/target_missing/deleted → done`). `deleted` не делаем абсорбирующим
ради единообразия; на практике одновременный возврат маловероятен.

### D7. Видимость

При переходе в `orphaned`/`target_missing` `worker` шлёт уведомление автору
через существующий `notifier` (новые события `EventOrphaned`/
`EventTargetMissing`), как для `review`/`done`. Web-UI и Telegram
отображают новые состояния; для `orphaned` кнопка `Undo` скрыта/заблокирована
с пояснением «источник удалён — это последняя копия».

## Risks / Trade-offs

- **Ложная пометка при долгом простое qBittorrent** (рестарт дольше
  `threshold × poll_interval`) → дебаунс D3 + самовосстановление D6: вернётся
  раздача — вернётся `done`. Порог настраивается.
- **Стоимость `Lstat` на каждом тике** при росте числа терминальных задач →
  при текущих объёмах пренебрежимо; путь отхода — отдельный редкий интервал
  desync-сверки (зафиксировано в D2, делаем при необходимости).
- **`nlink` зависит от ФС/синтаксиса `syscall.Stat_t`** (Linux-таргет,
  `CGO_ENABLED=0`, `linux/amd64`) → платформа фиксирована деплоем; copy-fallback
  раскладки (не хардлинк) даёт `nlink==1` у легитимной копии — такой `Undo`
  тоже корректно откажет (это и есть единственная копия). Приемлемо: лучше
  отказать, чем удалить данные.
- **Гонки команда/сверка** → всё под `w.mu` per-download, как и остальные
  переходы; новых блокировок не вводим.

## Migration Plan

- Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN
  source_miss_count INTEGER NOT NULL DEFAULT 0`. Новые значения `state` —
  данных не мигрируют (аддитивно).
- Конфиг: новый ключ `[worker].source_missing_threshold` с дефолтом; старые
  конфиги валидны без него.
- Откат: безопасен — состояния перестанут проставляться, существующие
  desync-задачи останутся со своим значением `state` (UI покажет как
  неизвестное/как есть). Столбец можно не удалять.

## Open Questions

- Нужны ли пользователю команды из `orphaned`, кроме как ждать
  восстановления (например явное «Забыть»/перевод в `deleted` руками)? На
  старте — нет, только пометка; добавим, если будет спрос.
- Порог дебаунса по умолчанию (`3`) — уточнить по реальному времени
  рестарта qBittorrent на umbar.
