# Система оспаривания (Disputes)

Система оспаривания позволяет участникам марафона проверять доказательства (пруфы) выполненных заданий друг друга и голосовать за их валидность.

## Общий принцип работы

```
┌──────────────────────────────────────────────────────────────────────────┐
│                          ЖИЗНЕННЫЙ ЦИКЛ ДИСПУТА                          │
└──────────────────────────────────────────────────────────────────────────┘

  Участник A                        Участник B                    Все участники
  выполняет задание                 замечает проблему             голосуют
       │                                  │                            │
       ▼                                  ▼                            ▼
 ┌───────────┐    24 часа          ┌───────────┐    24 часа     ┌───────────┐
 │ Завершено │ ─────────────────▶  │ Оспорено  │ ─────────────▶ │ Решено    │
 │           │   окно оспаривания  │ (OPEN)    │   голосование  │           │
 └───────────┘                     └───────────┘                └───────────┘
       │                                  │                            │
       │                                  │                            ├──▶ VALID (пруф OK)
       │                                  │                            │    Задание остаётся
       │                                  │                            │
       │                                  │                            └──▶ INVALID (пруф не OK)
       │                                  │                                 Задание возвращается
       │                                  │
       └──────────────────────────────────┘
              Если не оспорено — задание засчитано
```

## Кто может оспаривать

| Условие | Можно оспорить? |
|---------|-----------------|
| Своё задание | ❌ Нельзя |
| Чужое задание (статус COMPLETED) | ✅ Можно (в течение 24 часов) |
| Чужое задание (статус ACTIVE/DROPPED) | ❌ Нельзя |
| Прошло более 24 часов с момента выполнения | ❌ Нельзя |
| Уже есть активный диспут на это задание | ❌ Нельзя |

## Типы оспариваемых заданий

### 1. Обычные челленджи

Можно оспорить выполнение любого челленджа. При признании пруфа невалидным:
- Задание переходит в статус `RETURNED`
- Очки снимаются с участника
- Участник должен переделать задание

### 2. Прохождения игр (Playthrough)

Основное задание прохождения можно оспорить. При признании невалидным:
- Основное задание переходит в статус `RETURNED`
- Очки снимаются
- **Все бонусные челленджи сбрасываются** в статус `PENDING`

### 3. Бонусные челленджи

Каждый бонусный челлендж можно оспорить **отдельно**. При признании невалидным:
- Только этот бонусный челлендж сбрасывается в `PENDING`
- Участник может переделать его
- Основное задание и другие бонусы не затрагиваются

**Важно:** Очки за бонусные челленджи начисляются только при завершении основного задания. Поэтому при оспаривании бонуса очки не снимаются — просто сбрасывается статус.

## Процесс голосования

### Создание диспута

1. Участник нажимает "Оспорить" на странице деталей задания
2. Вводит причину оспаривания (минимум 10 символов)
3. Создаётся диспут со статусом `OPEN`
4. Владельцу задания отправляется уведомление в Telegram

### Голосование

- **Любой участник марафона** может голосовать
- Два варианта: "Валидно" (пруф OK) или "Невалидно" (пруф не OK)
- Можно **изменить** свой голос до завершения голосования
- Голосование длится **24 часа** с момента создания диспута

### Комментарии

- Участники могут оставлять комментарии для обсуждения
- Комментарии помогают другим участникам принять решение
- Комментарии доступны только пока диспут открыт

## Разрешение диспута

### Автоматическое (по таймеру)

Через 24 часа диспут автоматически разрешается:
- Система подсчитывает голоса
- При равенстве голосов — **в пользу обвиняемого** (пруф валиден)
- Результат: `RESOLVED_VALID` или `RESOLVED_INVALID`

**Технически:** Фоновый планировщик (`DisputeScheduler`) проверяет истёкшие диспуты каждые 5 минут.

### Результаты

| Результат | Условие | Последствия |
|-----------|---------|-------------|
| `RESOLVED_VALID` | Голосов "валидно" ≥ голосов "невалидно" | Задание остаётся выполненным |
| `RESOLVED_INVALID` | Голосов "невалидно" > голосов "валидно" | Задание возвращается |

### Что происходит при INVALID

**Для обычного задания:**
1. Статус → `RETURNED`
2. Очки (`points_earned`) вычитаются из общего счёта участника
3. Пруфы сохраняются для истории

**Для прохождения:**
1. Основное задание → `RETURNED`
2. Очки вычитаются
3. Все бонусные челленджи сбрасываются:
   - Статус → `PENDING`
   - Пруфы удаляются
   - Очки обнуляются

**Для бонусного челленджа:**
1. Только этот бонус → `PENDING`
2. Пруфы удаляются
3. Можно переделать

## API эндпоинты

### Создание диспута

```
POST /api/v1/assignments/{assignment_id}/dispute
POST /api/v1/bonus-assignments/{bonus_id}/dispute

Body: { "reason": "Описание проблемы с пруфом..." }
```

### Голосование

```
POST /api/v1/disputes/{dispute_id}/vote

Body: { "vote": true }  // true = валидно, false = невалидно
```

### Комментарии

```
POST /api/v1/disputes/{dispute_id}/comments

Body: { "text": "Текст комментария" }
```

### Получение информации

```
GET /api/v1/assignments/{assignment_id}

// В ответе включено поле dispute с полной информацией:
{
  "dispute": {
    "id": 1,
    "status": "open",
    "reason": "...",
    "votes_valid": 3,
    "votes_invalid": 2,
    "my_vote": true,
    "expires_at": "2024-12-30T12:00:00Z",
    "comments": [...],
    "votes": [...]
  }
}
```

## Структура базы данных

### Таблица `disputes`

| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `assignment_id` | INT | FK → assignments (nullable для бонусов) |
| `bonus_assignment_id` | INT | FK → bonus_assignments (nullable для основных) |
| `raised_by_id` | INT | FK → users |
| `reason` | TEXT | Причина оспаривания |
| `status` | VARCHAR(20) | open / valid / invalid |
| `created_at` | DATETIME | Время создания |
| `resolved_at` | DATETIME | Время разрешения |

**Ограничение:** Либо `assignment_id`, либо `bonus_assignment_id` должен быть заполнен (не оба).

### Таблица `dispute_votes`

| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `dispute_id` | INT | FK → disputes |
| `user_id` | INT | FK → users |
| `vote` | BOOLEAN | true = валидно, false = невалидно |
| `created_at` | DATETIME | Время голоса |

**Ограничение:** Один голос на участника (`UNIQUE dispute_id + user_id`).

### Таблица `dispute_comments`

| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `dispute_id` | INT | FK → disputes |
| `user_id` | INT | FK → users |
| `text` | TEXT | Текст комментария |
| `created_at` | DATETIME | Время комментария |

## UI компоненты

### Кнопка "Оспорить"

Появляется на странице деталей задания (`/assignments/{id}`) если:
- Статус задания: `COMPLETED`
- Это не своё задание
- Прошло меньше 24 часов с момента выполнения
- Нет активного диспута

### Секция диспута

Показывается если есть активный или завершённый диспут:
- Статус (открыт / валиден / невалиден)
- Таймер до окончания (для открытых)
- Причина оспаривания
- Кнопки голосования с счётчиками
- Секция комментариев

### Для бонусных челленджей

На каждом бонусном челлендже:
- Маленькая кнопка "Оспорить" (если можно)
- Бейдж статуса диспута
- Компактное голосование прямо в карточке бонуса

## Уведомления

### Telegram уведомления

| Событие | Получатель | Сообщение |
|---------|------------|-----------|
| Создание диспута | Владелец задания | "Ваше задание X оспорено в марафоне Y" |
| Результат: валидно | Владелец задания | "Диспут по заданию X решён в вашу пользу" |
| Результат: невалидно | Владелец задания | "Диспут по заданию X решён не в вашу пользу, задание возвращено" |

## Конфигурация

```python
# backend/app/api/v1/assignments.py
DISPUTE_WINDOW_HOURS = 24  # Окно для создания диспута

# backend/app/services/dispute_scheduler.py
CHECK_INTERVAL_SECONDS = 300  # Проверка каждые 5 минут
DISPUTE_WINDOW_HOURS = 24     # Время голосования
```

## Пример сценария

### Сценарий 1: Успешное оспаривание

1. **Иван** выполняет челлендж "Пройти уровень без смертей"
2. **Иван** прикладывает скриншот финального экрана
3. **Петр** открывает детали задания и видит, что на скриншоте есть смерти
4. **Петр** нажимает "Оспорить" и пишет: "На скриншоте видно 3 смерти"
5. Участники марафона голосуют: 5 за "невалидно", 2 за "валидно"
6. Через 24 часа диспут закрывается как `RESOLVED_INVALID`
7. Задание Ивана возвращается, очки снимаются
8. Иван получает уведомление и должен переделать задание

### Сценарий 2: Оспаривание бонуса

1. **Анна** проходит игру и выполняет бонусный челлендж
2. **Сергей** замечает проблему с пруфом бонуса
3. **Сергей** оспаривает только бонусный челлендж
4. Голосование: 4 за "невалидно", 1 за "валидно"
5. Результат: бонус сбрасывается в `PENDING`
6. Основное задание Анны **не затронуто**
7. Анна может переделать бонус (пока основное задание активно)

## Ручное разрешение диспутов

Администраторы системы и организаторы марафонов могут вручную разрешать диспуты, не дожидаясь окончания 24-часового окна голосования.

### Кто может разрешать

| Роль | Доступ |
|------|--------|
| **Системный админ** | Все диспуты во всех марафонах (`/admin/disputes`) |
| **Организатор марафона** | Только диспуты в своём марафоне (секция "Оспаривания" на странице марафона) |

### Интерфейс для системных админов

**Путь:** `/admin/disputes`

- Отдельная страница в админ-панели
- Фильтры: "Открытые" / "Все"
- Показывает диспуты из всех марафонов
- Информация: марафон, задание, участник, кто оспорил, причина
- Счётчик голосов и время до истечения
- Кнопки "Валидно" / "Невалидно" для мгновенного решения

### Интерфейс для организаторов

**Путь:** На странице марафона (`/marathons/{id}`) → секция "Оспаривания"

- Доступна только организаторам активного марафона
- Показывает только диспуты текущего марафона
- Компактный вид с возможностью раскрытия
- Ссылка на страницу задания для детального просмотра

### API для ручного разрешения

**Системные админы:**
```
GET  /api/v1/admin/disputes?status_filter=open|all
POST /api/v1/admin/disputes/{dispute_id}/resolve

Body: { "is_valid": true|false }
```

**Организаторы марафона:**
```
GET  /api/v1/marathons/{marathon_id}/disputes?status_filter=open|all
POST /api/v1/marathons/{marathon_id}/disputes/{dispute_id}/resolve

Body: { "is_valid": true|false }
```

### Что происходит при ручном разрешении

Логика идентична автоматическому разрешению:

**При `is_valid: true`:**
- Диспут закрывается как `RESOLVED_VALID`
- Задание остаётся выполненным
- Участник получает уведомление

**При `is_valid: false`:**
- Диспут закрывается как `RESOLVED_INVALID`
- Задание возвращается, очки снимаются
- Участник получает уведомление

### Важно: логика снятия очков за бонусы

При отклонении бонусного диспута система проверяет статус основного прохождения:

```
┌─────────────────────────────────────────────────────────────────┐
│                  БОНУС ПРИЗНАН НЕВАЛИДНЫМ                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   Основное прохождение      Основное прохождение               │
│   НЕ завершено?             УЖЕ завершено?                      │
│         │                          │                            │
│         ▼                          ▼                            │
│   ┌───────────┐              ┌───────────┐                      │
│   │ Просто    │              │ Вычитаем  │                      │
│   │ сбросить  │              │ очки из   │                      │
│   │ бонус     │              │ участника │                      │
│   └───────────┘              └───────────┘                      │
│   (очки ещё не               (очки уже были                     │
│   были начислены)            начислены при                      │
│                              завершении прохождения)            │
└─────────────────────────────────────────────────────────────────┘
```

**Почему так?** Очки за бонусные челленджи начисляются только в момент завершения основного прохождения (чтобы нельзя было получить очки за бонусы и потом дропнуть основное задание).

## Логирование действий

Ручное разрешение диспутов логируется в системе:

| Действие | Тип лога |
|----------|----------|
| Админ подтвердил пруф | `DISPUTE_RESOLVE_VALID` |
| Админ отклонил пруф | `DISPUTE_RESOLVE_INVALID` |

Логи доступны в `/admin/logs` для аудита действий администраторов.
