# Liga FPL 4x4 — plan implementacji

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Aplikacja Symfony + MySQL, która pobiera wyniki z oficjalnego API FPL, przelicza je na punkty ligowe formatu 4x4 (miejsca 7…0, remisy dzielone) i pokazuje mecz kolejki wraz z różnicami w składach.

**Architecture:** Warstwa `App\Fpl\Api` izoluje surowy JSON w DTO. `App\Fpl\Sync` zapisuje snapshoty kolejek w MySQL z regułą TTL 5 min / zamrożenie po `data_checked`. `App\League` to czysta domena bez zależności od HTTP: punktacja, różnice w składach, budowa widoku meczu. Kontrolery Twig konsumują wyłącznie `App\League`.

**Tech Stack:** PHP 8.4, Symfony 7.3, Doctrine ORM 3 + migracje, MySQL, Twig, Symfony HttpClient, PHPUnit + MockHttpClient + MockClock, dama/doctrine-test-bundle.

**Spec:** `docs/superpowers/specs/2026-08-16-fpl-4x4-league-design.md`

## Global Constraints

- PHP 8.4, Symfony 7.3.
- `DATABASE_URL="mysql://root:HASLO@127.0.0.1:3306/fpl?serverVersion=5.7&charset=utf8mb4"` — dokładnie ten ciąg trafia do `.env.local`. Baza testowa `fpl_test` powstaje automatycznie z `dbname_suffix` w `when@test`.
- Bazowy adres API: `https://fantasy.premierleague.com/api/`, klient scoped o nazwie `fpl.client` (autowiring pod `HttpClientInterface $fplClient`), timeout 5 s.
- Punkty ligowe: miejsce `i` (1-based) daje `8 - i`; grupa remisujących na pozycjach `a..b` dostaje `((8 - a) + (8 - b)) / 2`. Suma w meczu **zawsze 28.0** — to asercja w testach, nie komentarz.
- Do rankingu wchodzą punkty **netto** (po odjęciu kosztu transferów), ustalane przez `NetPointsResolver`.
- Źródłem prawdy dla wyniku drużyny jest `entry_history.points`; dane z `event/{gw}/live/` służą wyłącznie do rozbicia na zawodników i do detekcji netto/brutto.
- Osiem ID w meczu musi być parami różne — cztery moje i cztery rywala.
- Interfejs po polsku. Zero buildu frontendowego: statyczny `public/css/app.css`, rozwijanie składów natywnym `<details>`.
- Sezon 2026/27 startuje 21.08.2026. Do tego czasu `entry/{id}/event/{gw}/picks/` zwraca 404, a `event/{gw}/live/` pustą listę — wszystkie testy stoją na fixture'ach JSON w `tests/fixtures/api/`.
- Każde zadanie kończy się zielonym `vendor/bin/phpunit` i commitem.

## Struktura plików

```
src/
  Entity/            FplEntry, Squad, SquadMember, Gameweek, PlTeam, Element,
                     EntryGameweek, EntryGameweekPick, LeagueFixture
  Repository/        po jednym na encję, którą wyszukujemy
  Fpl/Api/           FplApiClient, FplApiException, Dto/*
  Fpl/Sync/          DictionarySync, NetPointsResolver, EntryGameweekSync
  League/            MatchSide, MatchOutcome, ScoreInput, StandingRow, MatchResult,
                     LeagueScorer, PlayerDifferential, DifferentialReport,
                     DifferentialAnalyzer, MatchView, MatchViewBuilder,
                     SeasonRecord, SquadManager, UnknownEntryException
  Form/              SquadFormModel, SquadFormType, FixtureFormModel, FixtureFormType
  Controller/        DashboardController, SettingsController, FixtureController
  Command/           FplSyncCommand
templates/           base, dashboard/index, settings/edit, fixture/new, fixture/show,
                     fixture/_standings, fixture/_differentials, fixture/_picks
public/css/app.css
tests/
  fixtures/api/*.json
  Unit/League/       LeagueScorerTest, DifferentialAnalyzerTest
  Unit/Fpl/          FplApiClientTest, NetPointsResolverTest
  Integration/       DictionarySyncTest, EntryGameweekSyncTest, MatchViewBuilderTest
  Functional/        SettingsControllerTest, FixtureControllerTest, DashboardControllerTest
  Support/           ApiFixtures, EntityFactory
```

Podział jest po odpowiedzialności, nie po warstwie technicznej: cała wiedza o kształcie JSON-a FPL mieszka w `Fpl/Api`, cała wiedza o regułach ligi w `League`. Kontroler nie widzi ani jednego, ani drugiego bezpośrednio — dostaje `MatchView`.

---

### Task 1: Szkielet projektu, baza, zielony PHPUnit

**Files:**
- Create: cała struktura Symfony w `/Users/gladki/projects/fpl`
- Create: `.env.local`
- Create: `tests/SmokeTest.php`
- Modify: `phpunit.dist.xml`

**Interfaces:**
- Consumes: nic (pierwsze zadanie)
- Produces: działający kernel Symfony, połączenie z MySQL, komenda `vendor/bin/phpunit`

- [ ] **Step 1: Rozpakuj szkielet do niepustego katalogu**

Katalog zawiera już `.git` i `docs/`, więc `composer create-project` nie zadziała w miejscu.

```bash
cd /Users/gladki/projects/fpl
composer create-project symfony/skeleton:"7.3.*" .skeleton --no-interaction
rsync -a .skeleton/ ./
rm -rf .skeleton
```

- [ ] **Step 2: Doinstaluj zależności**

```bash
cd /Users/gladki/projects/fpl
composer require --no-interaction symfony/orm-pack symfony/twig-bundle symfony/form symfony/validator symfony/security-csrf symfony/http-client symfony/clock
composer require --no-interaction --dev symfony/maker-bundle symfony/test-pack phpunit/phpunit dama/doctrine-test-bundle
```

- [ ] **Step 3: Ustaw połączenie z bazą**

Zapisz do `.env.local` (plik jest w `.gitignore` — hasło nie trafia do repo):

```
DATABASE_URL="mysql://root:HASLO@127.0.0.1:3306/fpl?serverVersion=5.7&charset=utf8mb4"
```

- [ ] **Step 4: Utwórz obie bazy**

```bash
cd /Users/gladki/projects/fpl
php bin/console doctrine:database:create --if-not-exists
php bin/console doctrine:database:create --if-not-exists --env=test
```

Oczekiwane: dwa komunikaty o utworzeniu baz `fpl` i `fpl_test` (albo o tym, że już istnieją).

- [ ] **Step 5: Włącz rollback transakcji w testach**

Sprawdź wersję: `vendor/bin/phpunit --version`.

Dla PHPUnit 10+ dopisz w `phpunit.dist.xml` bezpośrednio pod elementem głównym `<phpunit>`:

```xml
    <extensions>
        <bootstrap class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
    </extensions>
```

Dla PHPUnit 9 zamiast tego:

```xml
    <extensions>
        <extension class="DAMA\DoctrineTestBundle\PHPUnit\PHPUnitExtension"/>
    </extensions>
```

Sprawdź też, że `config/bundles.php` zawiera wpis `DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true]` — recipe zwykle dodaje go sam; jeśli nie, dopisz ręcznie.

- [ ] **Step 6: Napisz test dymny**

`tests/SmokeTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class SmokeTest extends KernelTestCase
{
    public function testKernelBootsAndDatabaseAnswers(): void
    {
        self::bootKernel();

        $em = self::getContainer()->get(EntityManagerInterface::class);
        $one = $em->getConnection()->fetchOne('SELECT 1');

        self::assertSame(1, (int) $one);
    }
}
```

- [ ] **Step 7: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS, 1 test, 1 asercja.

- [ ] **Step 8: Commit**

```bash
cd /Users/gladki/projects/fpl
git add -A
git commit -m "feat: szkielet Symfony, polaczenie z MySQL, test dymny"
```

---

### Task 2: Punktacja ligowa — `LeagueScorer`

Czysta domena, zero zależności od Doctrine i HTTP. Powstaje pierwsza, bo to serce ligi i najłatwiej ją w całości pokryć testami.

**Files:**
- Create: `src/League/MatchSide.php`, `src/League/MatchOutcome.php`, `src/League/ScoreInput.php`, `src/League/StandingRow.php`, `src/League/MatchResult.php`, `src/League/LeagueScorer.php`
- Test: `tests/Unit/League/LeagueScorerTest.php`

**Interfaces:**
- Consumes: nic
- Produces:
  - `enum MatchSide: string { case Mine = 'mine'; case Opponent = 'opponent'; }`
  - `enum MatchOutcome: string { case Win = 'win'; case Draw = 'draw'; case Loss = 'loss'; }`
  - `new ScoreInput(int $fplEntryId, string $label, MatchSide $side, int $netPoints, bool $hasData = true)`
  - `StandingRow` z polami `ScoreInput $input`, `int $position`, `float $leaguePoints`
  - `MatchResult` z polami `list<StandingRow> $rows`, `float $myPoints`, `float $opponentPoints` oraz metodami `outcome(): MatchOutcome` i `margin(): float`
  - `LeagueScorer::score(list<ScoreInput> $inputs): MatchResult`

- [ ] **Step 1: Napisz testy**

`tests/Unit/League/LeagueScorerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\League;

use App\League\LeagueScorer;
use App\League\MatchOutcome;
use App\League\MatchSide;
use App\League\ScoreInput;
use PHPUnit\Framework\TestCase;

final class LeagueScorerTest extends TestCase
{
    /** @param list<int> $minePoints @param list<int> $opponentPoints @return list<ScoreInput> */
    private function inputs(array $minePoints, array $opponentPoints): array
    {
        $inputs = [];
        foreach ($minePoints as $i => $points) {
            $inputs[] = new ScoreInput(100 + $i, 'Moja '.($i + 1), MatchSide::Mine, $points);
        }
        foreach ($opponentPoints as $i => $points) {
            $inputs[] = new ScoreInput(200 + $i, 'Rywal '.($i + 1), MatchSide::Opponent, $points);
        }

        return $inputs;
    }

    public function testAssignsSevenDownToZeroWhenNoTies(): void
    {
        $result = (new LeagueScorer())->score($this->inputs([84, 71, 60, 55], [79, 70, 58, 40]));

        $positions = array_map(static fn ($row) => $row->position, $result->rows);
        $points = array_map(static fn ($row) => $row->leaguePoints, $result->rows);
        $names = array_map(static fn ($row) => $row->input->label, $result->rows);

        self::assertSame([1, 2, 3, 4, 5, 6, 7, 8], $positions);
        self::assertSame([7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0, 0.0], $points);
        self::assertSame('Moja 1', $names[0]);
        self::assertSame('Rywal 4', $names[7]);
    }

    public function testSplitsPointsEquallyBetweenTwoTiedTeams(): void
    {
        $result = (new LeagueScorer())->score($this->inputs([84, 72, 60, 55], [71, 72, 58, 40]));

        // 84 -> 7, potem remis 72/72 na pozycjach 2-3 -> po (6+5)/2 = 5.5
        self::assertSame(7.0, $result->rows[0]->leaguePoints);
        self::assertSame(2, $result->rows[1]->position);
        self::assertSame(2, $result->rows[2]->position);
        self::assertSame(5.5, $result->rows[1]->leaguePoints);
        self::assertSame(5.5, $result->rows[2]->leaguePoints);
        self::assertSame(4, $result->rows[3]->position);
        self::assertSame(4.0, $result->rows[3]->leaguePoints);
    }

    public function testSplitsPointsAcrossThreeTiedTeams(): void
    {
        $result = (new LeagueScorer())->score($this->inputs([90, 70, 70, 50], [80, 70, 60, 40]));

        // remis 70/70/70 na pozycjach 3-5 -> po (5+4+3)/3 = 4.0
        foreach ([2, 3, 4] as $index) {
            self::assertSame(3, $result->rows[$index]->position);
            self::assertSame(4.0, $result->rows[$index]->leaguePoints);
        }
        self::assertSame(6, $result->rows[5]->position);
    }

    public function testAllTiedGivesDrawWithHalfPointsEach(): void
    {
        $result = (new LeagueScorer())->score($this->inputs([60, 60, 60, 60], [60, 60, 60, 60]));

        foreach ($result->rows as $row) {
            self::assertSame(1, $row->position);
            self::assertSame(3.5, $row->leaguePoints);
        }
        self::assertSame(14.0, $result->myPoints);
        self::assertSame(14.0, $result->opponentPoints);
        self::assertSame(MatchOutcome::Draw, $result->outcome());
        self::assertSame(0.0, $result->margin());
    }

    /** @return iterable<string, array{list<int>, list<int>}> */
    public static function scenarios(): iterable
    {
        yield 'bez remisow' => [[84, 71, 60, 55], [79, 70, 58, 40]];
        yield 'jeden remis' => [[84, 72, 60, 55], [79, 72, 58, 40]];
        yield 'dwa remisy' => [[84, 72, 60, 55], [84, 72, 58, 40]];
        yield 'wszyscy rowni' => [[60, 60, 60, 60], [60, 60, 60, 60]];
        yield 'zera' => [[0, 0, 0, 0], [0, 0, 0, 1]];
    }

    /**
     * @dataProvider scenarios
     * @param list<int> $mine
     * @param list<int> $opponent
     */
    public function testTotalLeaguePointsAlwaysEqualTwentyEight(array $mine, array $opponent): void
    {
        $result = (new LeagueScorer())->score($this->inputs($mine, $opponent));

        self::assertSame(28.0, $result->myPoints + $result->opponentPoints);
        self::assertSame(28.0, array_sum(array_map(static fn ($row) => $row->leaguePoints, $result->rows)));
    }

    public function testDetectsWinAndLoss(): void
    {
        $scorer = new LeagueScorer();

        $win = $scorer->score($this->inputs([84, 71, 60, 55], [79, 70, 58, 40]));
        self::assertSame(MatchOutcome::Win, $win->outcome());
        self::assertSame(4.0, $win->margin());

        $loss = $scorer->score($this->inputs([79, 70, 58, 40], [84, 71, 60, 55]));
        self::assertSame(MatchOutcome::Loss, $loss->outcome());
        self::assertSame(-4.0, $loss->margin());
    }

    public function testRejectsWrongNumberOfEntries(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        (new LeagueScorer())->score($this->inputs([84, 71, 60], [79, 70, 58, 40]));
    }

    public function testRejectsDuplicatedEntryIds(): void
    {
        $inputs = $this->inputs([84, 71, 60, 55], [79, 70, 58, 40]);
        $inputs[7] = new ScoreInput(100, 'Duplikat', MatchSide::Opponent, 40);

        $this->expectException(\InvalidArgumentException::class);

        (new LeagueScorer())->score($inputs);
    }
}
```

- [ ] **Step 2: Uruchom testy — muszą się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Unit/League/LeagueScorerTest.php
```

Oczekiwane: FAIL, `Class "App\League\LeagueScorer" not found`.

- [ ] **Step 3: Napisz typy domenowe**

`src/League/MatchSide.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

enum MatchSide: string
{
    case Mine = 'mine';
    case Opponent = 'opponent';
}
```

`src/League/MatchOutcome.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

enum MatchOutcome: string
{
    case Win = 'win';
    case Draw = 'draw';
    case Loss = 'loss';

    public function label(): string
    {
        return match ($this) {
            self::Win => 'Zwyciestwo',
            self::Draw => 'Remis',
            self::Loss => 'Porazka',
        };
    }
}
```

`src/League/ScoreInput.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final readonly class ScoreInput
{
    public function __construct(
        public int $fplEntryId,
        public string $label,
        public MatchSide $side,
        public int $netPoints,
        public bool $hasData = true,
    ) {
    }
}
```

`src/League/StandingRow.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final readonly class StandingRow
{
    public function __construct(
        public ScoreInput $input,
        public int $position,
        public float $leaguePoints,
    ) {
    }
}
```

`src/League/MatchResult.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final readonly class MatchResult
{
    /** @param list<StandingRow> $rows */
    public function __construct(
        public array $rows,
        public float $myPoints,
        public float $opponentPoints,
    ) {
    }

    public function outcome(): MatchOutcome
    {
        return match (true) {
            $this->myPoints > $this->opponentPoints => MatchOutcome::Win,
            $this->myPoints < $this->opponentPoints => MatchOutcome::Loss,
            default => MatchOutcome::Draw,
        };
    }

    public function margin(): float
    {
        return $this->myPoints - $this->opponentPoints;
    }

    /** @return list<StandingRow> */
    public function rowsFor(MatchSide $side): array
    {
        return array_values(array_filter($this->rows, static fn (StandingRow $row) => $row->input->side === $side));
    }
}
```

- [ ] **Step 4: Napisz `LeagueScorer`**

`src/League/LeagueScorer.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final class LeagueScorer
{
    public const SQUAD_SIZE = 4;
    public const ENTRIES_PER_MATCH = 8;
    public const TOTAL_LEAGUE_POINTS = 28.0;

    /** @param list<ScoreInput> $inputs */
    public function score(array $inputs): MatchResult
    {
        $this->assertValid($inputs);

        $sorted = $inputs;
        usort($sorted, static fn (ScoreInput $a, ScoreInput $b) => $b->netPoints <=> $a->netPoints);

        $rows = [];
        $index = 0;
        $count = count($sorted);

        while ($index < $count) {
            $last = $index;
            while ($last + 1 < $count && $sorted[$last + 1]->netPoints === $sorted[$index]->netPoints) {
                ++$last;
            }

            $firstPosition = $index + 1;
            $lastPosition = $last + 1;
            $shared = ((self::ENTRIES_PER_MATCH - $firstPosition) + (self::ENTRIES_PER_MATCH - $lastPosition)) / 2;

            for ($i = $index; $i <= $last; ++$i) {
                $rows[] = new StandingRow($sorted[$i], $firstPosition, $shared);
            }

            $index = $last + 1;
        }

        $myPoints = 0.0;
        $opponentPoints = 0.0;
        foreach ($rows as $row) {
            if ($row->input->side === MatchSide::Mine) {
                $myPoints += $row->leaguePoints;
            } else {
                $opponentPoints += $row->leaguePoints;
            }
        }

        return new MatchResult($rows, $myPoints, $opponentPoints);
    }

    /** @param list<ScoreInput> $inputs */
    private function assertValid(array $inputs): void
    {
        if (count($inputs) !== self::ENTRIES_PER_MATCH) {
            throw new \InvalidArgumentException(sprintf('Mecz wymaga %d druzyn, otrzymano %d.', self::ENTRIES_PER_MATCH, count($inputs)));
        }

        $perSide = [MatchSide::Mine->value => 0, MatchSide::Opponent->value => 0];
        $ids = [];
        foreach ($inputs as $input) {
            ++$perSide[$input->side->value];
            $ids[] = $input->fplEntryId;
        }

        foreach ($perSide as $side => $number) {
            if ($number !== self::SQUAD_SIZE) {
                throw new \InvalidArgumentException(sprintf('Strona "%s" ma %d druzyn zamiast %d.', $side, $number, self::SQUAD_SIZE));
            }
        }

        if (count(array_unique($ids)) !== self::ENTRIES_PER_MATCH) {
            throw new \InvalidArgumentException('Identyfikatory druzyn w meczu musza byc parami rozne.');
        }
    }
}
```

- [ ] **Step 5: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Unit/League/LeagueScorerTest.php
```

Oczekiwane: PASS, 12 testów (5 z dataProvidera).

- [ ] **Step 6: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/League tests/Unit/League
git commit -m "feat: punktacja ligowa 7-0 z dzieleniem punktow przy remisach"
```

---

### Task 3: Encje i migracja

**Files:**
- Create: `src/Entity/FplEntry.php`, `Squad.php`, `SquadMember.php`, `Gameweek.php`, `PlTeam.php`, `Element.php`, `EntryGameweek.php`, `EntryGameweekPick.php`, `LeagueFixture.php`
- Create: `src/Repository/FplEntryRepository.php`, `SquadRepository.php`, `GameweekRepository.php`, `ElementRepository.php`, `EntryGameweekRepository.php`, `LeagueFixtureRepository.php`
- Create: `tests/Support/EntityFactory.php`, `tests/Integration/SchemaTest.php`
- Create: `migrations/VersionXXXXXXXXXXXXXX.php` (generowana)

**Interfaces:**
- Consumes: nic z poprzednich zadań
- Produces: encje z akcesorami wymienionymi niżej oraz repozytoria:
  - `FplEntryRepository::find(int $id): ?FplEntry`
  - `SquadRepository::findMine(): ?Squad`
  - `GameweekRepository::find(int $id): ?Gameweek`, `findCurrent(): ?Gameweek`, `lastSyncedAt(): ?\DateTimeImmutable`
  - `ElementRepository::find(int $id): ?Element`
  - `EntryGameweekRepository::findOneFor(FplEntry $entry, Gameweek $gameweek): ?EntryGameweek`, `findFor(list<FplEntry> $entries, Gameweek $gameweek): array<int, EntryGameweek>` (klucz = id wpisu FPL), `latestPointsIncludeHit(): ?bool`
  - `LeagueFixtureRepository::findOneByGameweek(int $gameweek): ?LeagueFixture`, `findAllOrdered(): list<LeagueFixture>`
  - `EntityFactory` — pomocnik testowy tworzący zestaw encji

- [ ] **Step 1: Napisz test schematu i round-tripu**

`tests/Support/EntityFactory.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Support;

use App\Entity\Element;
use App\Entity\FplEntry;
use App\Entity\Gameweek;
use App\Entity\LeagueFixture;
use App\Entity\PlTeam;
use App\Entity\Squad;
use Doctrine\ORM\EntityManagerInterface;

final class EntityFactory
{
    public function __construct(private readonly EntityManagerInterface $em)
    {
    }

    public function gameweek(int $id = 1, bool $finished = false, bool $dataChecked = false): Gameweek
    {
        $gameweek = new Gameweek($id, 'Gameweek '.$id, new \DateTimeImmutable('2026-08-21 17:30:00'));
        $gameweek->setFlags($finished, $dataChecked, false, false);
        $gameweek->touch(new \DateTimeImmutable('2026-08-20 10:00:00'));
        $this->em->persist($gameweek);

        return $gameweek;
    }

    public function entry(int $id, string $teamName = 'Druzyna'): FplEntry
    {
        $entry = new FplEntry($id, $teamName.' '.$id, 'Imie'.$id, 'Nazwisko'.$id);
        $this->em->persist($entry);

        return $entry;
    }

    public function plTeam(int $id = 1): PlTeam
    {
        $team = new PlTeam($id, 'Arsenal', 'ARS');
        $this->em->persist($team);

        return $team;
    }

    public function element(int $id, PlTeam $team, string $webName = 'Zawodnik', int $type = 3): Element
    {
        $element = new Element($id, $webName.$id, 'Imie', 'Nazwisko', $type, $team);
        $this->em->persist($element);

        return $element;
    }

    /** @param list<int> $entryIds */
    public function squad(string $name, array $entryIds, bool $mine): Squad
    {
        $squad = new Squad($name, $mine, new \DateTimeImmutable('2026-08-16 12:00:00'));
        foreach ($entryIds as $slot => $entryId) {
            $squad->setMember($slot + 1, $this->em->find(FplEntry::class, $entryId) ?? $this->entry($entryId));
        }
        $this->em->persist($squad);

        return $squad;
    }

    public function fixture(Gameweek $gameweek, Squad $mine, Squad $opponent): LeagueFixture
    {
        $fixture = new LeagueFixture($gameweek, $mine, $opponent, new \DateTimeImmutable('2026-08-16 12:00:00'));
        $this->em->persist($fixture);

        return $fixture;
    }
}
```

`tests/Integration/SchemaTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use App\Entity\EntryGameweek;
use App\Entity\EntryGameweekPick;
use App\Repository\EntryGameweekRepository;
use App\Repository\LeagueFixtureRepository;
use App\Repository\SquadRepository;
use App\Tests\Support\EntityFactory;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaValidator;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class SchemaTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private EntityFactory $factory;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
        $this->factory = new EntityFactory($this->em);
    }

    public function testMappingIsValid(): void
    {
        $errors = (new SchemaValidator($this->em))->validateMapping();

        self::assertSame([], $errors);
    }

    public function testDatabaseSchemaMatchesMapping(): void
    {
        self::assertTrue((new SchemaValidator($this->em))->schemaInSyncWithMetadata());
    }

    public function testStoresFullMatchGraph(): void
    {
        $gameweek = $this->factory->gameweek(1);
        $plTeam = $this->factory->plTeam(1);
        $element = $this->factory->element(500, $plTeam, 'Salah', 3);

        $mine = $this->factory->squad('Moja', [11, 12, 13, 14], true);
        $opponent = $this->factory->squad('Rywal', [21, 22, 23, 24], false);
        $this->factory->fixture($gameweek, $mine, $opponent);

        $entry = $mine->entries()[0];
        $snapshot = new EntryGameweek($entry, $gameweek);
        $snapshot->applyResult(
            apiPoints: 84,
            transfers: 1,
            transfersCost: 4,
            pointsOnBench: 6,
            activeChip: null,
            livePointsSum: 84,
            pointsIncludeHit: false,
            netPoints: 80,
            captainPoints: 24,
        );
        $snapshot->addPick(new EntryGameweekPick($snapshot, $element, 1, 2, true, false, 12, 24, 90));
        $snapshot->markSynced(new \DateTimeImmutable('2026-08-22 20:00:00'), true);
        $this->em->persist($snapshot);

        $this->em->flush();
        $this->em->clear();

        $squads = self::getContainer()->get(SquadRepository::class);
        $fixtures = self::getContainer()->get(LeagueFixtureRepository::class);
        $snapshots = self::getContainer()->get(EntryGameweekRepository::class);

        $reloadedMine = $squads->findMine();
        self::assertNotNull($reloadedMine);
        self::assertSame([11, 12, 13, 14], $reloadedMine->entryIds());

        $reloadedFixture = $fixtures->findOneByGameweek(1);
        self::assertNotNull($reloadedFixture);
        self::assertSame('Rywal', $reloadedFixture->getOpponentSquad()->getName());

        $reloadedSnapshot = $snapshots->findOneFor($reloadedMine->entries()[0], $reloadedFixture->getGameweek());
        self::assertNotNull($reloadedSnapshot);
        self::assertSame(80, $reloadedSnapshot->getNetPoints());
        self::assertTrue($reloadedSnapshot->isFinalised());
        self::assertCount(1, $reloadedSnapshot->getPicks());
        self::assertSame(24, $reloadedSnapshot->getPicks()[0]->getEffectivePoints());
        self::assertFalse($snapshots->latestPointsIncludeHit());
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Integration/SchemaTest.php
```

Oczekiwane: FAIL, `Class "App\Entity\EntryGameweek" not found`.

- [ ] **Step 3: Napisz encje słownikowe**

`src/Entity/FplEntry.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\FplEntryRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: FplEntryRepository::class)]
class FplEntry
{
    #[ORM\Id]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 255)]
    private string $teamName;

    #[ORM\Column(length: 100)]
    private string $firstName;

    #[ORM\Column(length: 100)]
    private string $lastName;

    #[ORM\Column(nullable: true)]
    private ?int $startedEvent = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $lastSyncedAt = null;

    public function __construct(int $id, string $teamName, string $firstName, string $lastName)
    {
        $this->id = $id;
        $this->teamName = $teamName;
        $this->firstName = $firstName;
        $this->lastName = $lastName;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getTeamName(): string
    {
        return $this->teamName;
    }

    public function getManagerName(): string
    {
        return trim($this->firstName.' '.$this->lastName);
    }

    public function getStartedEvent(): ?int
    {
        return $this->startedEvent;
    }

    public function updateProfile(string $teamName, string $firstName, string $lastName, ?int $startedEvent, \DateTimeImmutable $syncedAt): void
    {
        $this->teamName = $teamName;
        $this->firstName = $firstName;
        $this->lastName = $lastName;
        $this->startedEvent = $startedEvent;
        $this->lastSyncedAt = $syncedAt;
    }
}
```

`src/Entity/Gameweek.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\GameweekRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: GameweekRepository::class)]
class Gameweek
{
    #[ORM\Id]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 50)]
    private string $name;

    #[ORM\Column]
    private \DateTimeImmutable $deadlineTime;

    #[ORM\Column]
    private bool $finished = false;

    #[ORM\Column]
    private bool $dataChecked = false;

    #[ORM\Column]
    private bool $current = false;

    #[ORM\Column]
    private bool $next = false;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $syncedAt = null;

    public function __construct(int $id, string $name, \DateTimeImmutable $deadlineTime)
    {
        $this->id = $id;
        $this->name = $name;
        $this->deadlineTime = $deadlineTime;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getDeadlineTime(): \DateTimeImmutable
    {
        return $this->deadlineTime;
    }

    public function isFinished(): bool
    {
        return $this->finished;
    }

    public function isDataChecked(): bool
    {
        return $this->dataChecked;
    }

    public function isCurrent(): bool
    {
        return $this->current;
    }

    public function isNext(): bool
    {
        return $this->next;
    }

    public function getSyncedAt(): ?\DateTimeImmutable
    {
        return $this->syncedAt;
    }

    public function rename(string $name, \DateTimeImmutable $deadlineTime): void
    {
        $this->name = $name;
        $this->deadlineTime = $deadlineTime;
    }

    public function setFlags(bool $finished, bool $dataChecked, bool $current, bool $next): void
    {
        $this->finished = $finished;
        $this->dataChecked = $dataChecked;
        $this->current = $current;
        $this->next = $next;
    }

    public function touch(\DateTimeImmutable $syncedAt): void
    {
        $this->syncedAt = $syncedAt;
    }
}
```

`src/Entity/PlTeam.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class PlTeam
{
    #[ORM\Id]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 100)]
    private string $name;

    #[ORM\Column(length: 10)]
    private string $shortName;

    public function __construct(int $id, string $name, string $shortName)
    {
        $this->id = $id;
        $this->name = $name;
        $this->shortName = $shortName;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getShortName(): string
    {
        return $this->shortName;
    }

    public function rename(string $name, string $shortName): void
    {
        $this->name = $name;
        $this->shortName = $shortName;
    }
}
```

`src/Entity/Element.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\ElementRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: ElementRepository::class)]
class Element
{
    public const POSITIONS = [1 => 'BR', 2 => 'OB', 3 => 'PO', 4 => 'NA', 5 => 'TR'];

    #[ORM\Id]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 100)]
    private string $webName;

    #[ORM\Column(length: 100)]
    private string $firstName;

    #[ORM\Column(length: 100)]
    private string $secondName;

    #[ORM\Column(type: 'smallint')]
    private int $elementType;

    #[ORM\ManyToOne(targetEntity: PlTeam::class)]
    #[ORM\JoinColumn(nullable: false)]
    private PlTeam $plTeam;

    public function __construct(int $id, string $webName, string $firstName, string $secondName, int $elementType, PlTeam $plTeam)
    {
        $this->id = $id;
        $this->webName = $webName;
        $this->firstName = $firstName;
        $this->secondName = $secondName;
        $this->elementType = $elementType;
        $this->plTeam = $plTeam;
    }

    public function getId(): int
    {
        return $this->id;
    }

    public function getWebName(): string
    {
        return $this->webName;
    }

    public function getPositionLabel(): string
    {
        return self::POSITIONS[$this->elementType] ?? '?';
    }

    public function getElementType(): int
    {
        return $this->elementType;
    }

    public function getPlTeam(): PlTeam
    {
        return $this->plTeam;
    }

    public function update(string $webName, string $firstName, string $secondName, int $elementType, PlTeam $plTeam): void
    {
        $this->webName = $webName;
        $this->firstName = $firstName;
        $this->secondName = $secondName;
        $this->elementType = $elementType;
        $this->plTeam = $plTeam;
    }
}
```

- [ ] **Step 4: Napisz encje drużyn i meczu**

`src/Entity/Squad.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\SquadRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: SquadRepository::class)]
class Squad
{
    public const SIZE = 4;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 100)]
    private string $name;

    #[ORM\Column]
    private bool $mine;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    /** @var Collection<int, SquadMember> */
    #[ORM\OneToMany(mappedBy: 'squad', targetEntity: SquadMember::class, cascade: ['persist'], orphanRemoval: true)]
    #[ORM\OrderBy(['slot' => 'ASC'])]
    private Collection $members;

    public function __construct(string $name, bool $mine, \DateTimeImmutable $createdAt)
    {
        $this->name = $name;
        $this->mine = $mine;
        $this->createdAt = $createdAt;
        $this->members = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function rename(string $name): void
    {
        $this->name = $name;
    }

    public function isMine(): bool
    {
        return $this->mine;
    }

    public function setMember(int $slot, FplEntry $entry): void
    {
        if ($slot < 1 || $slot > self::SIZE) {
            throw new \InvalidArgumentException(sprintf('Slot musi byc z zakresu 1-%d, podano %d.', self::SIZE, $slot));
        }

        foreach ($this->members as $member) {
            if ($member->getSlot() === $slot) {
                $member->setFplEntry($entry);

                return;
            }
        }

        $this->members->add(new SquadMember($this, $entry, $slot));
    }

    /** @return list<FplEntry> */
    public function entries(): array
    {
        return array_map(static fn (SquadMember $member) => $member->getFplEntry(), $this->members->toArray());
    }

    /** @return list<int> */
    public function entryIds(): array
    {
        return array_map(static fn (FplEntry $entry) => $entry->getId(), $this->entries());
    }

    public function isComplete(): bool
    {
        return count($this->members) === self::SIZE;
    }
}
```

`src/Entity/SquadMember.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\UniqueConstraint(name: 'uniq_squad_slot', columns: ['squad_id', 'slot'])]
#[ORM\UniqueConstraint(name: 'uniq_squad_entry', columns: ['squad_id', 'fpl_entry_id'])]
class SquadMember
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: Squad::class, inversedBy: 'members')]
    #[ORM\JoinColumn(nullable: false)]
    private Squad $squad;

    #[ORM\ManyToOne(targetEntity: FplEntry::class)]
    #[ORM\JoinColumn(nullable: false)]
    private FplEntry $fplEntry;

    #[ORM\Column(type: 'smallint')]
    private int $slot;

    public function __construct(Squad $squad, FplEntry $fplEntry, int $slot)
    {
        $this->squad = $squad;
        $this->fplEntry = $fplEntry;
        $this->slot = $slot;
    }

    public function getFplEntry(): FplEntry
    {
        return $this->fplEntry;
    }

    public function setFplEntry(FplEntry $fplEntry): void
    {
        $this->fplEntry = $fplEntry;
    }

    public function getSlot(): int
    {
        return $this->slot;
    }
}
```

`src/Entity/LeagueFixture.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\LeagueFixtureRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: LeagueFixtureRepository::class)]
#[ORM\Table(name: 'league_fixture')]
#[ORM\UniqueConstraint(name: 'uniq_fixture_gameweek', columns: ['gameweek_id'])]
class LeagueFixture
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: Gameweek::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Gameweek $gameweek;

    #[ORM\ManyToOne(targetEntity: Squad::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Squad $mySquad;

    #[ORM\ManyToOne(targetEntity: Squad::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Squad $opponentSquad;

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    public function __construct(Gameweek $gameweek, Squad $mySquad, Squad $opponentSquad, \DateTimeImmutable $createdAt)
    {
        $this->gameweek = $gameweek;
        $this->mySquad = $mySquad;
        $this->opponentSquad = $opponentSquad;
        $this->createdAt = $createdAt;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getGameweek(): Gameweek
    {
        return $this->gameweek;
    }

    public function getMySquad(): Squad
    {
        return $this->mySquad;
    }

    public function getOpponentSquad(): Squad
    {
        return $this->opponentSquad;
    }

    /** @return list<FplEntry> */
    public function allEntries(): array
    {
        return array_merge($this->mySquad->entries(), $this->opponentSquad->entries());
    }
}
```

- [ ] **Step 5: Napisz encje snapshotu**

`src/Entity/EntryGameweek.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\EntryGameweekRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: EntryGameweekRepository::class)]
#[ORM\UniqueConstraint(name: 'uniq_entry_gameweek', columns: ['fpl_entry_id', 'gameweek_id'])]
class EntryGameweek
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: FplEntry::class)]
    #[ORM\JoinColumn(nullable: false)]
    private FplEntry $fplEntry;

    #[ORM\ManyToOne(targetEntity: Gameweek::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Gameweek $gameweek;

    #[ORM\Column]
    private bool $hasData = false;

    #[ORM\Column]
    private int $apiPoints = 0;

    #[ORM\Column]
    private int $transfers = 0;

    #[ORM\Column]
    private int $transfersCost = 0;

    #[ORM\Column]
    private int $pointsOnBench = 0;

    #[ORM\Column(nullable: true)]
    private ?int $livePointsSum = null;

    #[ORM\Column(nullable: true)]
    private ?bool $pointsIncludeHit = null;

    #[ORM\Column]
    private int $netPoints = 0;

    #[ORM\Column]
    private int $captainPoints = 0;

    #[ORM\Column(length: 30, nullable: true)]
    private ?string $activeChip = null;

    #[ORM\Column(name: 'is_final')]
    private bool $finalised = false;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $syncedAt = null;

    /** @var Collection<int, EntryGameweekPick> */
    #[ORM\OneToMany(mappedBy: 'entryGameweek', targetEntity: EntryGameweekPick::class, cascade: ['persist'], orphanRemoval: true)]
    #[ORM\OrderBy(['position' => 'ASC'])]
    private Collection $picks;

    public function __construct(FplEntry $fplEntry, Gameweek $gameweek)
    {
        $this->fplEntry = $fplEntry;
        $this->gameweek = $gameweek;
        $this->picks = new ArrayCollection();
    }

    public function getFplEntry(): FplEntry
    {
        return $this->fplEntry;
    }

    public function getGameweek(): Gameweek
    {
        return $this->gameweek;
    }

    public function hasData(): bool
    {
        return $this->hasData;
    }

    public function getApiPoints(): int
    {
        return $this->apiPoints;
    }

    public function getTransfers(): int
    {
        return $this->transfers;
    }

    public function getTransfersCost(): int
    {
        return $this->transfersCost;
    }

    public function getPointsOnBench(): int
    {
        return $this->pointsOnBench;
    }

    public function getLivePointsSum(): ?int
    {
        return $this->livePointsSum;
    }

    public function getPointsIncludeHit(): ?bool
    {
        return $this->pointsIncludeHit;
    }

    public function getNetPoints(): int
    {
        return $this->netPoints;
    }

    public function getCaptainPoints(): int
    {
        return $this->captainPoints;
    }

    public function getActiveChip(): ?string
    {
        return $this->activeChip;
    }

    public function isFinalised(): bool
    {
        return $this->finalised;
    }

    public function getSyncedAt(): ?\DateTimeImmutable
    {
        return $this->syncedAt;
    }

    /** @return list<EntryGameweekPick> */
    public function getPicks(): array
    {
        return array_values($this->picks->toArray());
    }

    /** @return list<EntryGameweekPick> */
    public function getStartingPicks(): array
    {
        return array_values(array_filter($this->getPicks(), static fn (EntryGameweekPick $pick) => $pick->getMultiplier() > 0));
    }

    /** @return list<EntryGameweekPick> */
    public function getBenchPicks(): array
    {
        return array_values(array_filter($this->getPicks(), static fn (EntryGameweekPick $pick) => 0 === $pick->getMultiplier()));
    }

    public function applyResult(
        int $apiPoints,
        int $transfers,
        int $transfersCost,
        int $pointsOnBench,
        ?string $activeChip,
        ?int $livePointsSum,
        ?bool $pointsIncludeHit,
        int $netPoints,
        int $captainPoints,
    ): void {
        $this->hasData = true;
        $this->apiPoints = $apiPoints;
        $this->transfers = $transfers;
        $this->transfersCost = $transfersCost;
        $this->pointsOnBench = $pointsOnBench;
        $this->activeChip = $activeChip;
        $this->livePointsSum = $livePointsSum;
        $this->pointsIncludeHit = $pointsIncludeHit;
        $this->netPoints = $netPoints;
        $this->captainPoints = $captainPoints;
    }

    public function markMissing(): void
    {
        $this->hasData = false;
        $this->apiPoints = 0;
        $this->transfers = 0;
        $this->transfersCost = 0;
        $this->pointsOnBench = 0;
        $this->activeChip = null;
        $this->livePointsSum = null;
        $this->pointsIncludeHit = null;
        $this->netPoints = 0;
        $this->captainPoints = 0;
        $this->picks->clear();
    }

    public function clearPicks(): void
    {
        $this->picks->clear();
    }

    public function addPick(EntryGameweekPick $pick): void
    {
        $this->picks->add($pick);
    }

    public function markSynced(\DateTimeImmutable $syncedAt, bool $finalised): void
    {
        $this->syncedAt = $syncedAt;
        $this->finalised = $finalised;
    }
}
```

`src/Entity/EntryGameweekPick.php`:

```php
<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\UniqueConstraint(name: 'uniq_entry_gameweek_position', columns: ['entry_gameweek_id', 'position'])]
class EntryGameweekPick
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\ManyToOne(targetEntity: EntryGameweek::class, inversedBy: 'picks')]
    #[ORM\JoinColumn(nullable: false)]
    private EntryGameweek $entryGameweek;

    #[ORM\ManyToOne(targetEntity: Element::class)]
    #[ORM\JoinColumn(nullable: false)]
    private Element $element;

    #[ORM\Column(type: 'smallint')]
    private int $position;

    #[ORM\Column(type: 'smallint')]
    private int $multiplier;

    #[ORM\Column]
    private bool $captain;

    #[ORM\Column]
    private bool $viceCaptain;

    #[ORM\Column]
    private int $rawPoints;

    #[ORM\Column]
    private int $effectivePoints;

    #[ORM\Column]
    private int $minutes;

    public function __construct(
        EntryGameweek $entryGameweek,
        Element $element,
        int $position,
        int $multiplier,
        bool $captain,
        bool $viceCaptain,
        int $rawPoints,
        int $effectivePoints,
        int $minutes,
    ) {
        $this->entryGameweek = $entryGameweek;
        $this->element = $element;
        $this->position = $position;
        $this->multiplier = $multiplier;
        $this->captain = $captain;
        $this->viceCaptain = $viceCaptain;
        $this->rawPoints = $rawPoints;
        $this->effectivePoints = $effectivePoints;
        $this->minutes = $minutes;
    }

    public function getEntryGameweek(): EntryGameweek
    {
        return $this->entryGameweek;
    }

    public function getElement(): Element
    {
        return $this->element;
    }

    public function getPosition(): int
    {
        return $this->position;
    }

    public function getMultiplier(): int
    {
        return $this->multiplier;
    }

    public function isCaptain(): bool
    {
        return $this->captain;
    }

    public function isViceCaptain(): bool
    {
        return $this->viceCaptain;
    }

    public function getRawPoints(): int
    {
        return $this->rawPoints;
    }

    public function getEffectivePoints(): int
    {
        return $this->effectivePoints;
    }

    public function getMinutes(): int
    {
        return $this->minutes;
    }
}
```

- [ ] **Step 6: Napisz repozytoria**

`src/Repository/FplEntryRepository.php`, `ElementRepository.php` — czyste `ServiceEntityRepository` bez dodatkowych metod:

```php
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\FplEntry;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/** @extends ServiceEntityRepository<FplEntry> */
final class FplEntryRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, FplEntry::class);
    }
}
```

`ElementRepository` identycznie, tylko z `App\Entity\Element` w obu miejscach i nazwą klasy `ElementRepository`.

`src/Repository/SquadRepository.php`:

```php
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Squad;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/** @extends ServiceEntityRepository<Squad> */
final class SquadRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Squad::class);
    }

    public function findMine(): ?Squad
    {
        return $this->findOneBy(['mine' => true]);
    }
}
```

`src/Repository/GameweekRepository.php`:

```php
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\Gameweek;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/** @extends ServiceEntityRepository<Gameweek> */
final class GameweekRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Gameweek::class);
    }

    public function findCurrent(): ?Gameweek
    {
        return $this->findOneBy(['current' => true]) ?? $this->findOneBy(['next' => true]);
    }

    public function lastSyncedAt(): ?\DateTimeImmutable
    {
        $value = $this->createQueryBuilder('g')
            ->select('MAX(g.syncedAt)')
            ->getQuery()
            ->getSingleScalarResult();

        return null === $value ? null : new \DateTimeImmutable((string) $value);
    }
}
```

`src/Repository/EntryGameweekRepository.php`:

```php
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\EntryGameweek;
use App\Entity\FplEntry;
use App\Entity\Gameweek;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/** @extends ServiceEntityRepository<EntryGameweek> */
final class EntryGameweekRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, EntryGameweek::class);
    }

    public function findOneFor(FplEntry $entry, Gameweek $gameweek): ?EntryGameweek
    {
        return $this->findOneBy(['fplEntry' => $entry, 'gameweek' => $gameweek]);
    }

    /**
     * @param list<FplEntry> $entries
     * @return array<int, EntryGameweek> klucz = id wpisu FPL
     */
    public function findFor(array $entries, Gameweek $gameweek): array
    {
        if ([] === $entries) {
            return [];
        }

        $found = $this->createQueryBuilder('s')
            ->andWhere('s.gameweek = :gameweek')
            ->andWhere('s.fplEntry IN (:entries)')
            ->setParameter('gameweek', $gameweek)
            ->setParameter('entries', $entries)
            ->getQuery()
            ->getResult();

        $byEntryId = [];
        foreach ($found as $snapshot) {
            $byEntryId[$snapshot->getFplEntry()->getId()] = $snapshot;
        }

        return $byEntryId;
    }

    public function latestPointsIncludeHit(): ?bool
    {
        $value = $this->createQueryBuilder('s')
            ->select('s.pointsIncludeHit')
            ->andWhere('s.pointsIncludeHit IS NOT NULL')
            ->orderBy('s.syncedAt', 'DESC')
            ->setMaxResults(1)
            ->getQuery()
            ->getOneOrNullResult();

        return null === $value ? null : (bool) $value['pointsIncludeHit'];
    }
}
```

`src/Repository/LeagueFixtureRepository.php`:

```php
<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\LeagueFixture;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/** @extends ServiceEntityRepository<LeagueFixture> */
final class LeagueFixtureRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, LeagueFixture::class);
    }

    public function findOneByGameweek(int $gameweek): ?LeagueFixture
    {
        return $this->createQueryBuilder('f')
            ->andWhere('f.gameweek = :gameweek')
            ->setParameter('gameweek', $gameweek)
            ->getQuery()
            ->getOneOrNullResult();
    }

    /** @return list<LeagueFixture> */
    public function findAllOrdered(): array
    {
        return $this->createQueryBuilder('f')
            ->orderBy('f.gameweek', 'ASC')
            ->getQuery()
            ->getResult();
    }
}
```

- [ ] **Step 7: Wygeneruj i uruchom migrację**

```bash
cd /Users/gladki/projects/fpl
php bin/console make:migration --no-interaction
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console doctrine:migrations:migrate --no-interaction --env=test
```

- [ ] **Step 8: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS. `SchemaTest::testMappingIsValid` i `testDatabaseSchemaMatchesMapping` pilnują, że migracja odpowiada mapowaniu.

- [ ] **Step 9: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/Entity src/Repository migrations tests
git commit -m "feat: model danych ligi i snapshotow kolejek"
```

---

### Task 4: Klient API FPL i DTO

**Files:**
- Create: `src/Fpl/Api/FplApiException.php`, `FplApiClient.php`
- Create: `src/Fpl/Api/Dto/GameweekDto.php`, `PlTeamDto.php`, `ElementDto.php`, `BootstrapDto.php`, `EntryDto.php`, `PickDto.php`, `EntryPicksDto.php`, `LiveDto.php`
- Create: `tests/fixtures/api/bootstrap.json`, `entry_101.json`, `picks_101_gw1.json`, `live_gw1.json`
- Create: `tests/Support/ApiFixtures.php`, `tests/Unit/Fpl/FplApiClientTest.php`
- Modify: `config/packages/framework.yaml`

**Interfaces:**
- Consumes: nic z poprzednich zadań
- Produces:
  - `FplApiClient::bootstrap(): BootstrapDto`
  - `FplApiClient::entry(int $entryId): ?EntryDto` — `null` przy 404
  - `FplApiClient::picks(int $entryId, int $gameweek): ?EntryPicksDto` — `null` przy 404
  - `FplApiClient::live(int $gameweek): LiveDto`
  - `BootstrapDto` z polami `list<GameweekDto> $gameweeks`, `list<PlTeamDto> $teams`, `list<ElementDto> $elements`
  - `GameweekDto(int $id, string $name, \DateTimeImmutable $deadlineTime, bool $finished, bool $dataChecked, bool $current, bool $next)`
  - `PlTeamDto(int $id, string $name, string $shortName)`
  - `ElementDto(int $id, string $webName, string $firstName, string $secondName, int $elementType, int $teamId)`
  - `EntryDto(int $id, string $teamName, string $firstName, string $lastName, ?int $startedEvent)`
  - `EntryPicksDto(int $points, int $transfers, int $transfersCost, int $pointsOnBench, ?string $activeChip, list<PickDto> $picks)`
  - `PickDto(int $elementId, int $position, int $multiplier, bool $captain, bool $viceCaptain)`
  - `LiveDto::pointsFor(int $elementId): int`, `LiveDto::minutesFor(int $elementId): int`
  - `ApiFixtures::load(string $name): string` i `ApiFixtures::response(string $name): MockResponse`
- Uwaga dla późniejszych zadań — polityka wobec 404 jest różna dla różnych metod, bo różne jest znaczenie braku zasobu:
  - `entry()` i `picks()` zwracają `null` (menedżer nie istnieje albo nie ma jeszcze składu w tej kolejce — normalny stan),
  - `bootstrap()` rzuca `FplApiException` (ten zasób istnieje zawsze; jego brak to awaria),
  - `live()` zwraca pusty `LiveDto` (przed startem sezonu API i tak zwraca pustą listę, więc brak danych jest nieodróżnialny od zera punktów).
  Każdy inny błąd HTTP i każdy błąd transportu to `FplApiException` we wszystkich metodach.

- [ ] **Step 1: Skonfiguruj klienta scoped**

Dopisz w `config/packages/framework.yaml` wewnątrz klucza `framework:`:

```yaml
    http_client:
        scoped_clients:
            fpl.client:
                base_uri: 'https://fantasy.premierleague.com/api/'
                timeout: 5
                max_duration: 10
                max_redirects: 2
                headers:
                    Accept: 'application/json'
                    User-Agent: 'fpl-4x4-league/1.0'
```

Symfony zarejestruje alias autowiringu `HttpClientInterface $fplClient`.

- [ ] **Step 2: Zapisz fixture'y JSON**

Fixture'y są przycięte do kształtu, którego faktycznie używamy — `bootstrap.json` ma 2 kolejki, 2 kluby i 4 zawodników, `picks_101_gw1.json` pełne 15 pozycji.

`tests/fixtures/api/bootstrap.json`:

```json
{
  "events": [
    {"id": 1, "name": "Gameweek 1", "deadline_time": "2026-08-21T17:30:00Z", "finished": true, "data_checked": true, "is_current": false, "is_next": false},
    {"id": 2, "name": "Gameweek 2", "deadline_time": "2026-08-28T17:30:00Z", "finished": false, "data_checked": false, "is_current": true, "is_next": false}
  ],
  "teams": [
    {"id": 1, "name": "Arsenal", "short_name": "ARS"},
    {"id": 2, "name": "Liverpool", "short_name": "LIV"}
  ],
  "elements": [
    {"id": 301, "web_name": "Raya", "first_name": "David", "second_name": "Raya", "element_type": 1, "team": 1},
    {"id": 302, "web_name": "Saliba", "first_name": "William", "second_name": "Saliba", "element_type": 2, "team": 1},
    {"id": 303, "web_name": "Salah", "first_name": "Mohamed", "second_name": "Salah", "element_type": 3, "team": 2},
    {"id": 304, "web_name": "Ekitike", "first_name": "Hugo", "second_name": "Ekitike", "element_type": 4, "team": 2}
  ]
}
```

`tests/fixtures/api/entry_101.json`:

```json
{
  "id": 101,
  "name": "Kuba FC",
  "player_first_name": "Jakub",
  "player_last_name": "Gladych",
  "started_event": 1
}
```

`tests/fixtures/api/picks_101_gw1.json`:

```json
{
  "active_chip": null,
  "automatic_subs": [],
  "entry_history": {
    "event": 1,
    "points": 82,
    "total_points": 82,
    "event_transfers": 2,
    "event_transfers_cost": 4,
    "points_on_bench": 6,
    "bank": 5,
    "value": 1002
  },
  "picks": [
    {"element": 301, "position": 1, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 302, "position": 2, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 303, "position": 3, "multiplier": 2, "is_captain": true, "is_vice_captain": false},
    {"element": 304, "position": 4, "multiplier": 1, "is_captain": false, "is_vice_captain": true},
    {"element": 301, "position": 5, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 302, "position": 6, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 303, "position": 7, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 304, "position": 8, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 301, "position": 9, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 302, "position": 10, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 303, "position": 11, "multiplier": 1, "is_captain": false, "is_vice_captain": false},
    {"element": 304, "position": 12, "multiplier": 0, "is_captain": false, "is_vice_captain": false},
    {"element": 301, "position": 13, "multiplier": 0, "is_captain": false, "is_vice_captain": false},
    {"element": 302, "position": 14, "multiplier": 0, "is_captain": false, "is_vice_captain": false},
    {"element": 303, "position": 15, "multiplier": 0, "is_captain": false, "is_vice_captain": false}
  ]
}
```

Uwaga: powtarzające się `element` w fixture są celowe — testy sprawdzają mapowanie pozycji, nie realizm składu. Encja `EntryGameweekPick` ma unikalność na `(entry_gameweek, position)`, nie na zawodniku, więc taki zestaw przechodzi.

`tests/fixtures/api/live_gw1.json`:

```json
{
  "elements": [
    {"id": 301, "stats": {"minutes": 90, "total_points": 6}},
    {"id": 302, "stats": {"minutes": 90, "total_points": 2}},
    {"id": 303, "stats": {"minutes": 88, "total_points": 12}},
    {"id": 304, "stats": {"minutes": 65, "total_points": 5}}
  ]
}
```

- [ ] **Step 3: Napisz pomocnik testowy do fixture'ów**

`tests/Support/ApiFixtures.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Support;

use Symfony\Component\HttpClient\Response\MockResponse;

final class ApiFixtures
{
    public static function load(string $name): string
    {
        $path = __DIR__.'/../fixtures/api/'.$name.'.json';

        if (!is_file($path)) {
            throw new \RuntimeException(sprintf('Brak fixture "%s".', $path));
        }

        return (string) file_get_contents($path);
    }

    public static function response(string $name, int $status = 200): MockResponse
    {
        return new MockResponse(self::load($name), [
            'http_code' => $status,
            'response_headers' => ['content-type' => 'application/json'],
        ]);
    }

    public static function notFound(): MockResponse
    {
        return new MockResponse('{"detail":"Not found."}', [
            'http_code' => 404,
            'response_headers' => ['content-type' => 'application/json'],
        ]);
    }
}
```

- [ ] **Step 4: Napisz test klienta**

`tests/Unit/Fpl/FplApiClientTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Fpl;

use App\Fpl\Api\FplApiClient;
use App\Fpl\Api\FplApiException;
use App\Tests\Support\ApiFixtures;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class FplApiClientTest extends TestCase
{
    /** @param list<MockResponse> $responses */
    private function client(array $responses, ?MockHttpClient &$http = null): FplApiClient
    {
        $http = new MockHttpClient($responses, 'https://fantasy.premierleague.com/api/');

        return new FplApiClient($http);
    }

    public function testMapsBootstrap(): void
    {
        $bootstrap = $this->client([ApiFixtures::response('bootstrap')])->bootstrap();

        self::assertCount(2, $bootstrap->gameweeks);
        self::assertSame(1, $bootstrap->gameweeks[0]->id);
        self::assertSame('Gameweek 1', $bootstrap->gameweeks[0]->name);
        self::assertTrue($bootstrap->gameweeks[0]->dataChecked);
        self::assertTrue($bootstrap->gameweeks[1]->current);
        self::assertSame('2026-08-21 17:30:00', $bootstrap->gameweeks[0]->deadlineTime->format('Y-m-d H:i:s'));

        self::assertCount(2, $bootstrap->teams);
        self::assertSame('ARS', $bootstrap->teams[0]->shortName);

        self::assertCount(4, $bootstrap->elements);
        self::assertSame('Salah', $bootstrap->elements[2]->webName);
        self::assertSame(3, $bootstrap->elements[2]->elementType);
        self::assertSame(2, $bootstrap->elements[2]->teamId);
    }

    public function testMapsEntry(): void
    {
        $entry = $this->client([ApiFixtures::response('entry_101')])->entry(101);

        self::assertNotNull($entry);
        self::assertSame(101, $entry->id);
        self::assertSame('Kuba FC', $entry->teamName);
        self::assertSame('Jakub', $entry->firstName);
        self::assertSame('Gladych', $entry->lastName);
        self::assertSame(1, $entry->startedEvent);
    }

    public function testReturnsNullForUnknownEntry(): void
    {
        self::assertNull($this->client([ApiFixtures::notFound()])->entry(999999));
    }

    public function testMapsPicks(): void
    {
        $picks = $this->client([ApiFixtures::response('picks_101_gw1')])->picks(101, 1);

        self::assertNotNull($picks);
        self::assertSame(82, $picks->points);
        self::assertSame(2, $picks->transfers);
        self::assertSame(4, $picks->transfersCost);
        self::assertSame(6, $picks->pointsOnBench);
        self::assertNull($picks->activeChip);
        self::assertCount(15, $picks->picks);
        self::assertSame(303, $picks->picks[2]->elementId);
        self::assertSame(2, $picks->picks[2]->multiplier);
        self::assertTrue($picks->picks[2]->captain);
        self::assertTrue($picks->picks[3]->viceCaptain);
        self::assertSame(0, $picks->picks[14]->multiplier);
    }

    public function testReturnsNullWhenPicksAreNotPublishedYet(): void
    {
        self::assertNull($this->client([ApiFixtures::notFound()])->picks(101, 1));
    }

    public function testMapsLivePoints(): void
    {
        $live = $this->client([ApiFixtures::response('live_gw1')])->live(1);

        self::assertSame(12, $live->pointsFor(303));
        self::assertSame(88, $live->minutesFor(303));
        self::assertSame(0, $live->pointsFor(999));
        self::assertSame(0, $live->minutesFor(999));
    }

    public function testUsesExpectedUrls(): void
    {
        $client = $this->client([
            ApiFixtures::response('picks_101_gw1'),
            ApiFixtures::response('live_gw1'),
        ], $http);

        $client->picks(101, 7);
        $client->live(7);

        self::assertSame(2, $http->getRequestsCount());
    }

    public function testThrowsOnServerError(): void
    {
        $client = $this->client([new MockResponse('boom', ['http_code' => 503])]);

        $this->expectException(FplApiException::class);

        $client->bootstrap();
    }
}
```

- [ ] **Step 5: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Unit/Fpl/FplApiClientTest.php
```

Oczekiwane: FAIL, `Class "App\Fpl\Api\FplApiClient" not found`.

- [ ] **Step 6: Napisz DTO**

`src/Fpl/Api/Dto/GameweekDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api\Dto;

final readonly class GameweekDto
{
    public function __construct(
        public int $id,
        public string $name,
        public \DateTimeImmutable $deadlineTime,
        public bool $finished,
        public bool $dataChecked,
        public bool $current,
        public bool $next,
    ) {
    }
}
```

`src/Fpl/Api/Dto/PlTeamDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api\Dto;

final readonly class PlTeamDto
{
    public function __construct(
        public int $id,
        public string $name,
        public string $shortName,
    ) {
    }
}
```

`src/Fpl/Api/Dto/ElementDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api\Dto;

final readonly class ElementDto
{
    public function __construct(
        public int $id,
        public string $webName,
        public string $firstName,
        public string $secondName,
        public int $elementType,
        public int $teamId,
    ) {
    }
}
```

`src/Fpl/Api/Dto/BootstrapDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api\Dto;

final readonly class BootstrapDto
{
    /**
     * @param list<GameweekDto> $gameweeks
     * @param list<PlTeamDto>   $teams
     * @param list<ElementDto>  $elements
     */
    public function __construct(
        public array $gameweeks,
        public array $teams,
        public array $elements,
    ) {
    }
}
```

`src/Fpl/Api/Dto/EntryDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api\Dto;

final readonly class EntryDto
{
    public function __construct(
        public int $id,
        public string $teamName,
        public string $firstName,
        public string $lastName,
        public ?int $startedEvent,
    ) {
    }
}
```

`src/Fpl/Api/Dto/PickDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api\Dto;

final readonly class PickDto
{
    public function __construct(
        public int $elementId,
        public int $position,
        public int $multiplier,
        public bool $captain,
        public bool $viceCaptain,
    ) {
    }
}
```

`src/Fpl/Api/Dto/EntryPicksDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api\Dto;

final readonly class EntryPicksDto
{
    /** @param list<PickDto> $picks */
    public function __construct(
        public int $points,
        public int $transfers,
        public int $transfersCost,
        public int $pointsOnBench,
        public ?string $activeChip,
        public array $picks,
    ) {
    }
}
```

`src/Fpl/Api/Dto/LiveDto.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api\Dto;

final readonly class LiveDto
{
    /**
     * @param array<int, int> $points  klucz = id zawodnika
     * @param array<int, int> $minutes klucz = id zawodnika
     */
    public function __construct(
        private array $points,
        private array $minutes,
    ) {
    }

    public function pointsFor(int $elementId): int
    {
        return $this->points[$elementId] ?? 0;
    }

    public function minutesFor(int $elementId): int
    {
        return $this->minutes[$elementId] ?? 0;
    }

    public function isEmpty(): bool
    {
        return [] === $this->points;
    }
}
```

- [ ] **Step 7: Napisz klienta**

`src/Fpl/Api/FplApiException.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api;

final class FplApiException extends \RuntimeException
{
}
```

`src/Fpl/Api/FplApiClient.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Api;

use App\Fpl\Api\Dto\BootstrapDto;
use App\Fpl\Api\Dto\ElementDto;
use App\Fpl\Api\Dto\EntryDto;
use App\Fpl\Api\Dto\EntryPicksDto;
use App\Fpl\Api\Dto\GameweekDto;
use App\Fpl\Api\Dto\LiveDto;
use App\Fpl\Api\Dto\PickDto;
use App\Fpl\Api\Dto\PlTeamDto;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface as HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class FplApiClient
{
    public function __construct(private HttpClientInterface $fplClient)
    {
    }

    public function bootstrap(): BootstrapDto
    {
        $data = $this->get('bootstrap-static/') ?? throw new FplApiException('bootstrap-static zwrocil 404.');

        $gameweeks = [];
        foreach ($data['events'] ?? [] as $event) {
            $gameweeks[] = new GameweekDto(
                (int) $event['id'],
                (string) $event['name'],
                new \DateTimeImmutable((string) $event['deadline_time']),
                (bool) ($event['finished'] ?? false),
                (bool) ($event['data_checked'] ?? false),
                (bool) ($event['is_current'] ?? false),
                (bool) ($event['is_next'] ?? false),
            );
        }

        $teams = [];
        foreach ($data['teams'] ?? [] as $team) {
            $teams[] = new PlTeamDto((int) $team['id'], (string) $team['name'], (string) $team['short_name']);
        }

        $elements = [];
        foreach ($data['elements'] ?? [] as $element) {
            $elements[] = new ElementDto(
                (int) $element['id'],
                (string) $element['web_name'],
                (string) $element['first_name'],
                (string) $element['second_name'],
                (int) $element['element_type'],
                (int) $element['team'],
            );
        }

        return new BootstrapDto($gameweeks, $teams, $elements);
    }

    public function entry(int $entryId): ?EntryDto
    {
        $data = $this->get(sprintf('entry/%d/', $entryId));

        if (null === $data) {
            return null;
        }

        return new EntryDto(
            (int) $data['id'],
            (string) ($data['name'] ?? ''),
            (string) ($data['player_first_name'] ?? ''),
            (string) ($data['player_last_name'] ?? ''),
            isset($data['started_event']) ? (int) $data['started_event'] : null,
        );
    }

    public function picks(int $entryId, int $gameweek): ?EntryPicksDto
    {
        $data = $this->get(sprintf('entry/%d/event/%d/picks/', $entryId, $gameweek));

        if (null === $data) {
            return null;
        }

        $history = $data['entry_history'] ?? [];

        $picks = [];
        foreach ($data['picks'] ?? [] as $pick) {
            $picks[] = new PickDto(
                (int) $pick['element'],
                (int) $pick['position'],
                (int) $pick['multiplier'],
                (bool) ($pick['is_captain'] ?? false),
                (bool) ($pick['is_vice_captain'] ?? false),
            );
        }

        return new EntryPicksDto(
            (int) ($history['points'] ?? 0),
            (int) ($history['event_transfers'] ?? 0),
            (int) ($history['event_transfers_cost'] ?? 0),
            (int) ($history['points_on_bench'] ?? 0),
            null !== ($data['active_chip'] ?? null) ? (string) $data['active_chip'] : null,
            $picks,
        );
    }

    public function live(int $gameweek): LiveDto
    {
        $data = $this->get(sprintf('event/%d/live/', $gameweek)) ?? ['elements' => []];

        $points = [];
        $minutes = [];
        foreach ($data['elements'] ?? [] as $element) {
            $id = (int) $element['id'];
            $points[$id] = (int) ($element['stats']['total_points'] ?? 0);
            $minutes[$id] = (int) ($element['stats']['minutes'] ?? 0);
        }

        return new LiveDto($points, $minutes);
    }

    /** @return array<string, mixed>|null */
    private function get(string $path): ?array
    {
        try {
            $response = $this->fplClient->request('GET', $path);
            $status = $response->getStatusCode();

            if (404 === $status) {
                return null;
            }

            if ($status < 200 || $status >= 300) {
                throw new FplApiException(sprintf('FPL API zwrocilo %d dla "%s".', $status, $path));
            }

            return $response->toArray(false);
        } catch (HttpExceptionInterface $exception) {
            throw new FplApiException(sprintf('Blad polaczenia z FPL API dla "%s".', $path), 0, $exception);
        }
    }
}
```

- [ ] **Step 8: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS, wszystkie dotychczasowe testy zielone.

- [ ] **Step 9: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/Fpl config/packages/framework.yaml tests
git commit -m "feat: klient FPL API z mapowaniem odpowiedzi na DTO"
```

---

### Task 5: Synchronizacja słowników — `DictionarySync`

Kolejki, kluby i zawodnicy z `bootstrap-static`, odświeżane raz na dobę.

**Files:**
- Create: `src/Fpl/Sync/DictionarySync.php`
- Create: `tests/Integration/DictionarySyncTest.php`

**Interfaces:**
- Consumes: `FplApiClient::bootstrap()`, encje `Gameweek`, `PlTeam`, `Element`, `GameweekRepository::lastSyncedAt()`
- Produces: `DictionarySync::sync(bool $force = false): bool` — zwraca `true`, jeśli faktycznie odpytano API; `DictionarySync::TTL_SECONDS = 86400`

- [ ] **Step 1: Napisz test**

`tests/Integration/DictionarySyncTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use App\Entity\Element;
use App\Entity\Gameweek;
use App\Entity\PlTeam;
use App\Fpl\Api\FplApiClient;
use App\Fpl\Sync\DictionarySync;
use App\Repository\GameweekRepository;
use App\Tests\Support\ApiFixtures;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpClient\MockHttpClient;

final class DictionarySyncTest extends KernelTestCase
{
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
    }

    /** @param list<\Symfony\Component\HttpClient\Response\MockResponse> $responses */
    private function sync(array $responses, MockClock $clock, ?MockHttpClient &$http = null): DictionarySync
    {
        $http = new MockHttpClient($responses, 'https://fantasy.premierleague.com/api/');

        return new DictionarySync(
            new FplApiClient($http),
            $this->em,
            self::getContainer()->get(GameweekRepository::class),
            $clock,
        );
    }

    public function testImportsGameweeksTeamsAndElements(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-20 09:00:00'));
        $performed = $this->sync([ApiFixtures::response('bootstrap')], $clock)->sync();

        self::assertTrue($performed);
        $this->em->clear();

        $gameweek = $this->em->find(Gameweek::class, 1);
        self::assertNotNull($gameweek);
        self::assertSame('Gameweek 1', $gameweek->getName());
        self::assertTrue($gameweek->isDataChecked());
        self::assertSame('2026-08-20 09:00:00', $gameweek->getSyncedAt()?->format('Y-m-d H:i:s'));

        $current = self::getContainer()->get(GameweekRepository::class)->findCurrent();
        self::assertSame(2, $current?->getId());

        self::assertSame('ARS', $this->em->find(PlTeam::class, 1)?->getShortName());

        $salah = $this->em->find(Element::class, 303);
        self::assertNotNull($salah);
        self::assertSame('Salah', $salah->getWebName());
        self::assertSame('PO', $salah->getPositionLabel());
        self::assertSame(2, $salah->getPlTeam()->getId());
    }

    public function testSkipsCallWhenDictionariesAreFresh(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-20 09:00:00'));
        $this->sync([ApiFixtures::response('bootstrap')], $clock)->sync();

        $clock->modify('+1 hour');
        $performed = $this->sync([], $clock, $http)->sync();

        self::assertFalse($performed);
        self::assertSame(0, $http->getRequestsCount());
    }

    public function testRefreshesAfterTtlAndUpdatesExistingRows(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-20 09:00:00'));
        $this->sync([ApiFixtures::response('bootstrap')], $clock)->sync();

        $clock->modify('+2 days');
        $performed = $this->sync([ApiFixtures::response('bootstrap')], $clock, $http)->sync();

        self::assertTrue($performed);
        self::assertSame(1, $http->getRequestsCount());

        $this->em->clear();
        self::assertSame(2, (int) $this->em->createQuery('SELECT COUNT(g.id) FROM '.Gameweek::class.' g')->getSingleScalarResult());
        self::assertSame(4, (int) $this->em->createQuery('SELECT COUNT(e.id) FROM '.Element::class.' e')->getSingleScalarResult());
    }

    public function testForceIgnoresTtl(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-20 09:00:00'));
        $this->sync([ApiFixtures::response('bootstrap')], $clock)->sync();

        $performed = $this->sync([ApiFixtures::response('bootstrap')], $clock, $http)->sync(true);

        self::assertTrue($performed);
        self::assertSame(1, $http->getRequestsCount());
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Integration/DictionarySyncTest.php
```

Oczekiwane: FAIL, `Class "App\Fpl\Sync\DictionarySync" not found`.

- [ ] **Step 3: Napisz `DictionarySync`**

`src/Fpl/Sync/DictionarySync.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Sync;

use App\Entity\Element;
use App\Entity\Gameweek;
use App\Entity\PlTeam;
use App\Fpl\Api\FplApiClient;
use App\Repository\GameweekRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;

final readonly class DictionarySync
{
    public const TTL_SECONDS = 86400;

    public function __construct(
        private FplApiClient $api,
        private EntityManagerInterface $em,
        private GameweekRepository $gameweeks,
        private ClockInterface $clock,
    ) {
    }

    /** @return bool czy faktycznie odpytano API */
    public function sync(bool $force = false): bool
    {
        $now = $this->clock->now();

        if (!$force && !$this->isStale($now)) {
            return false;
        }

        $bootstrap = $this->api->bootstrap();

        foreach ($bootstrap->gameweeks as $dto) {
            $gameweek = $this->em->find(Gameweek::class, $dto->id) ?? new Gameweek($dto->id, $dto->name, $dto->deadlineTime);
            $gameweek->rename($dto->name, $dto->deadlineTime);
            $gameweek->setFlags($dto->finished, $dto->dataChecked, $dto->current, $dto->next);
            $gameweek->touch($now);
            $this->em->persist($gameweek);
        }

        foreach ($bootstrap->teams as $dto) {
            $team = $this->em->find(PlTeam::class, $dto->id) ?? new PlTeam($dto->id, $dto->name, $dto->shortName);
            $team->rename($dto->name, $dto->shortName);
            $this->em->persist($team);
        }

        $this->em->flush();

        foreach ($bootstrap->elements as $dto) {
            $team = $this->em->find(PlTeam::class, $dto->teamId);
            if (null === $team) {
                continue;
            }

            $element = $this->em->find(Element::class, $dto->id)
                ?? new Element($dto->id, $dto->webName, $dto->firstName, $dto->secondName, $dto->elementType, $team);
            $element->update($dto->webName, $dto->firstName, $dto->secondName, $dto->elementType, $team);
            $this->em->persist($element);
        }

        $this->em->flush();

        return true;
    }

    private function isStale(\DateTimeImmutable $now): bool
    {
        $lastSyncedAt = $this->gameweeks->lastSyncedAt();

        if (null === $lastSyncedAt) {
            return true;
        }

        return ($now->getTimestamp() - $lastSyncedAt->getTimestamp()) >= self::TTL_SECONDS;
    }
}
```

- [ ] **Step 4: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS.

- [ ] **Step 5: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/Fpl/Sync tests/Integration/DictionarySyncTest.php
git commit -m "feat: synchronizacja slownikow kolejek, klubow i zawodnikow"
```

---

### Task 6: Detekcja netto/brutto — `NetPointsResolver`

Czysta funkcja rozstrzygająca, czy `entry_history.points` ma już odjęty koszt transferów. Reguła opisana w sekcji 7 specyfikacji.

**Files:**
- Create: `src/Fpl/Sync/NetPointsResult.php`, `src/Fpl/Sync/NetPointsResolver.php`
- Test: `tests/Unit/Fpl/NetPointsResolverTest.php`

**Interfaces:**
- Consumes: nic
- Produces: `NetPointsResolver::resolve(int $apiPoints, int $transfersCost, ?int $livePointsSum, ?bool $previousIncludeHit = null): NetPointsResult` oraz `NetPointsResult` z polami `int $netPoints` i `?bool $pointsIncludeHit`

- [ ] **Step 1: Napisz test**

`tests/Unit/Fpl/NetPointsResolverTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\Fpl;

use App\Fpl\Sync\NetPointsResolver;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;

final class NetPointsResolverTest extends TestCase
{
    private function resolver(): NetPointsResolver
    {
        return new NetPointsResolver(new NullLogger());
    }

    public function testDetectsGrossPointsAndSubtractsHit(): void
    {
        // suma z live = 84, API tez pokazuje 84 -> hit jeszcze nieodjety
        $result = $this->resolver()->resolve(apiPoints: 84, transfersCost: 4, livePointsSum: 84);

        self::assertSame(80, $result->netPoints);
        self::assertFalse($result->pointsIncludeHit);
    }

    public function testDetectsNetPointsAndLeavesThemAlone(): void
    {
        // suma z live = 84, API pokazuje 80 -> hit juz odjety
        $result = $this->resolver()->resolve(apiPoints: 80, transfersCost: 4, livePointsSum: 84);

        self::assertSame(80, $result->netPoints);
        self::assertTrue($result->pointsIncludeHit);
    }

    public function testWithoutHitBothInterpretationsAreIdentical(): void
    {
        $result = $this->resolver()->resolve(apiPoints: 76, transfersCost: 0, livePointsSum: 76, previousIncludeHit: true);

        self::assertSame(76, $result->netPoints);
        self::assertTrue($result->pointsIncludeHit, 'Brak hitu nie moze zmienic wczesniejszego ustalenia.');
    }

    public function testWithoutHitAndWithoutHistoryVerdictStaysUndetermined(): void
    {
        $result = $this->resolver()->resolve(apiPoints: 76, transfersCost: 0, livePointsSum: 76);

        self::assertSame(76, $result->netPoints);
        self::assertNull($result->pointsIncludeHit);
    }

    public function testMismatchFallsBackToPreviousVerdict(): void
    {
        // np. auto-podmiany w trakcie kolejki: live nie zgadza sie z zadna interpretacja
        $result = $this->resolver()->resolve(apiPoints: 80, transfersCost: 4, livePointsSum: 71, previousIncludeHit: true);

        self::assertSame(80, $result->netPoints);
        self::assertTrue($result->pointsIncludeHit);
    }

    public function testMismatchWithoutHistoryTreatsPointsAsGross(): void
    {
        $result = $this->resolver()->resolve(apiPoints: 80, transfersCost: 4, livePointsSum: 71);

        self::assertSame(76, $result->netPoints);
        self::assertNull($result->pointsIncludeHit);
    }

    public function testMissingLiveDataFallsBackToGross(): void
    {
        $result = $this->resolver()->resolve(apiPoints: 80, transfersCost: 8, livePointsSum: null);

        self::assertSame(72, $result->netPoints);
        self::assertNull($result->pointsIncludeHit);
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Unit/Fpl/NetPointsResolverTest.php
```

Oczekiwane: FAIL, `Class "App\Fpl\Sync\NetPointsResolver" not found`.

- [ ] **Step 3: Napisz implementację**

`src/Fpl/Sync/NetPointsResult.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Sync;

final readonly class NetPointsResult
{
    public function __construct(
        public int $netPoints,
        public ?bool $pointsIncludeHit,
    ) {
    }
}
```

`src/Fpl/Sync/NetPointsResolver.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Sync;

use Psr\Log\LoggerInterface;

final readonly class NetPointsResolver
{
    public function __construct(private LoggerInterface $logger)
    {
    }

    public function resolve(int $apiPoints, int $transfersCost, ?int $livePointsSum, ?bool $previousIncludeHit = null): NetPointsResult
    {
        if (0 === $transfersCost) {
            // Obie interpretacje daja ten sam wynik, wiec nie da sie nic ustalic.
            return new NetPointsResult($apiPoints, $previousIncludeHit);
        }

        if (null !== $livePointsSum) {
            if ($apiPoints === $livePointsSum) {
                return new NetPointsResult($apiPoints - $transfersCost, false);
            }

            if ($apiPoints === $livePointsSum - $transfersCost) {
                return new NetPointsResult($apiPoints, true);
            }

            $this->logger->warning('Punkty z FPL nie zgadzaja sie z suma z live.', [
                'api_points' => $apiPoints,
                'live_points_sum' => $livePointsSum,
                'transfers_cost' => $transfersCost,
                'previous_include_hit' => $previousIncludeHit,
            ]);
        }

        // Fallback: trzymamy sie wczesniejszego ustalenia, a bez niego zakladamy, ze API podaje brutto.
        $netPoints = true === $previousIncludeHit ? $apiPoints : $apiPoints - $transfersCost;

        return new NetPointsResult($netPoints, $previousIncludeHit);
    }
}
```

- [ ] **Step 4: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS.

- [ ] **Step 5: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/Fpl/Sync tests/Unit/Fpl/NetPointsResolverTest.php
git commit -m "feat: automatyczna detekcja punktow netto i brutto"
```

---

### Task 7: Snapshoty kolejek — `EntryGameweekSync`

**Files:**
- Create: `src/Fpl/Sync/EntryGameweekSync.php`
- Test: `tests/Integration/EntryGameweekSyncTest.php`

**Interfaces:**
- Consumes: `FplApiClient::picks()`, `FplApiClient::live()`, `NetPointsResolver::resolve()`, `EntryGameweekRepository`, `ElementRepository`, encje z Task 3
- Produces:
  - `EntryGameweekSync::TTL_SECONDS = 300`
  - `EntryGameweekSync::syncMany(list<FplEntry> $entries, Gameweek $gameweek, bool $force = false): array<int, EntryGameweek>` — klucz to id wpisu FPL; zwraca snapshoty wszystkich przekazanych drużyn, także te nieodświeżone
  - `EntryGameweekSync::lastSyncFailed(): bool` — czy ostatnie wywołanie napotkało błąd API

Reguły:
1. snapshot z `isFinalised() === true` nie jest odświeżany nigdy,
2. snapshot z `syncedAt` młodszym niż 300 s jest pomijany,
3. `live` pobierane jest raz, tylko gdy przynajmniej jedna drużyna wymaga odświeżenia,
4. `picks` = 404 → snapshot z `hasData = false` i zerami,
5. `FplApiException` → log, zachowujemy istniejący snapshot, nie przerywamy pętli,
6. zawodnik nieznany w słowniku `Element` → pozycja pomijana (słownik dosynchronizuje `DictionarySync`).

**Poprawka wykryta w trakcie implementacji — kod niżej jej jeszcze nie zawiera.** Podmiana składu przez `clearPicks()` plus dodanie nowych pozycji w jednym `flush()` narusza unikalność pary `(entry_gameweek, position)`: Doctrine w jednym commicie wykonuje `executeInserts()` przed `executeDeletions()` (`UnitOfWork::commit()`), więc nowe pozycje kolidują ze starymi, jeszcze nieusuniętymi. Rozwiązanie: całą pracę nad jedną drużyną opakować w `EntityManagerInterface::wrapInTransaction()`, a wewnątrz niej zapisać usunięcia osobnym `flush()` zaraz po `clearPicks()`, zanim powstaną nowe pozycje. Transakcja jest tu istotna nie tylko dla unikalności — bez niej między usunięciem starego składu a zapisem nowego lecą kolejne zapytania sieciowe do FPL, więc padnięcie procesu w tym oknie trwale gubi skład drużyny. `wrapInTransaction()` sam wywołuje `flush()` przed commitem, więc końcowy `flush()` w `syncMany()` staje się zbędny.

- [ ] **Step 1: Napisz test**

`tests/Integration/EntryGameweekSyncTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use App\Entity\FplEntry;
use App\Entity\Gameweek;
use App\Fpl\Api\FplApiClient;
use App\Fpl\Sync\EntryGameweekSync;
use App\Fpl\Sync\NetPointsResolver;
use App\Repository\ElementRepository;
use App\Repository\EntryGameweekRepository;
use App\Tests\Support\ApiFixtures;
use App\Tests\Support\EntityFactory;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class EntryGameweekSyncTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private EntityFactory $factory;
    private Gameweek $gameweek;
    private FplEntry $entry;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
        $this->factory = new EntityFactory($this->em);

        $this->gameweek = $this->factory->gameweek(1);
        $team = $this->factory->plTeam(1);
        foreach ([301, 302, 303, 304] as $elementId) {
            $this->factory->element($elementId, $team);
        }
        $this->entry = $this->factory->entry(101, 'Kuba FC');
        $this->em->flush();
    }

    /** @param list<MockResponse> $responses */
    private function sync(array $responses, MockClock $clock, ?MockHttpClient &$http = null): EntryGameweekSync
    {
        $http = new MockHttpClient($responses, 'https://fantasy.premierleague.com/api/');

        return new EntryGameweekSync(
            new FplApiClient($http),
            $this->em,
            self::getContainer()->get(EntryGameweekRepository::class),
            self::getContainer()->get(ElementRepository::class),
            new NetPointsResolver(new NullLogger()),
            $clock,
            new NullLogger(),
        );
    }

    public function testCreatesSnapshotWithPicks(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-22 20:00:00'));
        $snapshots = $this->sync([
            ApiFixtures::response('live_gw1'),
            ApiFixtures::response('picks_101_gw1'),
        ], $clock)->syncMany([$this->entry], $this->gameweek);

        $snapshot = $snapshots[101];

        self::assertTrue($snapshot->hasData());
        self::assertSame(82, $snapshot->getApiPoints());
        self::assertSame(4, $snapshot->getTransfersCost());
        self::assertSame(6, $snapshot->getPointsOnBench());
        // jedenastka: 6+2+(12*2)+5+6+2+12+5+6+2+12 = 82, czyli tyle samo co points z API
        self::assertSame(82, $snapshot->getLivePointsSum());
        self::assertFalse($snapshot->getPointsIncludeHit(), 'API podaje brutto, wiec hit trzeba odjac.');
        self::assertSame(78, $snapshot->getNetPoints());
        self::assertSame(24, $snapshot->getCaptainPoints());
        self::assertCount(15, $snapshot->getPicks());
        self::assertCount(11, $snapshot->getStartingPicks());
        self::assertCount(4, $snapshot->getBenchPicks());
        self::assertSame('2026-08-22 20:00:00', $snapshot->getSyncedAt()?->format('Y-m-d H:i:s'));

        $captain = $snapshot->getPicks()[2];
        self::assertTrue($captain->isCaptain());
        self::assertSame(12, $captain->getRawPoints());
        self::assertSame(24, $captain->getEffectivePoints());
        self::assertSame(88, $captain->getMinutes());
    }

    public function testSkipsRefreshInsideTtl(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-22 20:00:00'));
        $this->sync([
            ApiFixtures::response('live_gw1'),
            ApiFixtures::response('picks_101_gw1'),
        ], $clock)->syncMany([$this->entry], $this->gameweek);

        $clock->modify('+2 minutes');
        $this->sync([], $clock, $http)->syncMany([$this->entry], $this->gameweek);

        self::assertSame(0, $http->getRequestsCount());
    }

    public function testRefreshesAfterTtl(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-22 20:00:00'));
        $this->sync([
            ApiFixtures::response('live_gw1'),
            ApiFixtures::response('picks_101_gw1'),
        ], $clock)->syncMany([$this->entry], $this->gameweek);

        $clock->modify('+6 minutes');
        $this->sync([
            ApiFixtures::response('live_gw1'),
            ApiFixtures::response('picks_101_gw1'),
        ], $clock, $http)->syncMany([$this->entry], $this->gameweek);

        self::assertSame(2, $http->getRequestsCount());

        $this->em->clear();
        $count = (int) $this->em->createQuery('SELECT COUNT(p.id) FROM App\Entity\EntryGameweekPick p')->getSingleScalarResult();
        self::assertSame(15, $count, 'Odswiezenie podmienia sklad, a nie dokleja drugiego kompletu.');
    }

    public function testNeverRefreshesFinalisedSnapshot(): void
    {
        $this->gameweek->setFlags(finished: true, dataChecked: true, current: false, next: false);
        $this->em->flush();

        $clock = new MockClock(new \DateTimeImmutable('2026-08-22 20:00:00'));
        $snapshots = $this->sync([
            ApiFixtures::response('live_gw1'),
            ApiFixtures::response('picks_101_gw1'),
        ], $clock)->syncMany([$this->entry], $this->gameweek);

        self::assertTrue($snapshots[101]->isFinalised());

        $clock->modify('+1 week');
        $this->sync([], $clock, $http)->syncMany([$this->entry], $this->gameweek, true);

        self::assertSame(0, $http->getRequestsCount(), 'Force nie moze ruszyc zamrozonego snapshotu.');
    }

    public function testMarksEntryWithoutPicksAsMissing(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-22 20:00:00'));
        $snapshots = $this->sync([
            ApiFixtures::response('live_gw1'),
            ApiFixtures::notFound(),
        ], $clock)->syncMany([$this->entry], $this->gameweek);

        self::assertFalse($snapshots[101]->hasData());
        self::assertSame(0, $snapshots[101]->getNetPoints());
        self::assertCount(0, $snapshots[101]->getPicks());
    }

    public function testKeepsPreviousSnapshotWhenApiFails(): void
    {
        $clock = new MockClock(new \DateTimeImmutable('2026-08-22 20:00:00'));
        $this->sync([
            ApiFixtures::response('live_gw1'),
            ApiFixtures::response('picks_101_gw1'),
        ], $clock)->syncMany([$this->entry], $this->gameweek);

        $clock->modify('+10 minutes');
        $sync = $this->sync([new MockResponse('boom', ['http_code' => 503])], $clock);
        $snapshots = $sync->syncMany([$this->entry], $this->gameweek);

        self::assertTrue($sync->lastSyncFailed());
        self::assertSame(82, $snapshots[101]->getApiPoints());
        self::assertSame('2026-08-22 20:00:00', $snapshots[101]->getSyncedAt()?->format('Y-m-d H:i:s'));
    }

    public function testFetchesLiveOnlyOnceForManyEntries(): void
    {
        $second = $this->factory->entry(102, 'Kuba FC 2');
        $this->em->flush();

        $clock = new MockClock(new \DateTimeImmutable('2026-08-22 20:00:00'));
        $snapshots = $this->sync([
            ApiFixtures::response('live_gw1'),
            ApiFixtures::response('picks_101_gw1'),
            ApiFixtures::response('picks_101_gw1'),
        ], $clock, $http)->syncMany([$this->entry, $second], $this->gameweek);

        self::assertSame(3, $http->getRequestsCount(), '1x live + 2x picks');
        self::assertCount(2, $snapshots);
        self::assertArrayHasKey(102, $snapshots);
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Integration/EntryGameweekSyncTest.php
```

Oczekiwane: FAIL, `Class "App\Fpl\Sync\EntryGameweekSync" not found`.

- [ ] **Step 3: Napisz `EntryGameweekSync`**

`src/Fpl/Sync/EntryGameweekSync.php`:

```php
<?php

declare(strict_types=1);

namespace App\Fpl\Sync;

use App\Entity\EntryGameweek;
use App\Entity\EntryGameweekPick;
use App\Entity\FplEntry;
use App\Entity\Gameweek;
use App\Fpl\Api\Dto\EntryPicksDto;
use App\Fpl\Api\Dto\LiveDto;
use App\Fpl\Api\FplApiClient;
use App\Fpl\Api\FplApiException;
use App\Repository\ElementRepository;
use App\Repository\EntryGameweekRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Clock\ClockInterface;

final class EntryGameweekSync
{
    public const TTL_SECONDS = 300;

    private bool $lastSyncFailed = false;

    public function __construct(
        private readonly FplApiClient $api,
        private readonly EntityManagerInterface $em,
        private readonly EntryGameweekRepository $snapshots,
        private readonly ElementRepository $elements,
        private readonly NetPointsResolver $netPoints,
        private readonly ClockInterface $clock,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * @param list<FplEntry> $entries
     * @return array<int, EntryGameweek> klucz = id wpisu FPL
     */
    public function syncMany(array $entries, Gameweek $gameweek, bool $force = false): array
    {
        $this->lastSyncFailed = false;
        $now = $this->clock->now();

        $existing = $this->snapshots->findFor($entries, $gameweek);

        $toRefresh = [];
        foreach ($entries as $entry) {
            if ($this->needsRefresh($existing[$entry->getId()] ?? null, $now, $force)) {
                $toRefresh[] = $entry;
            }
        }

        if ([] === $toRefresh) {
            return $existing;
        }

        try {
            $live = $this->api->live($gameweek->getId());
        } catch (FplApiException $exception) {
            $this->lastSyncFailed = true;
            $this->logger->warning('Nie udalo sie pobrac punktow live.', ['gameweek' => $gameweek->getId(), 'exception' => $exception]);

            return $existing;
        }

        $previousVerdict = $this->snapshots->latestPointsIncludeHit();

        foreach ($toRefresh as $entry) {
            try {
                $picks = $this->api->picks($entry->getId(), $gameweek->getId());
            } catch (FplApiException $exception) {
                $this->lastSyncFailed = true;
                $this->logger->warning('Nie udalo sie pobrac skladu.', [
                    'entry' => $entry->getId(),
                    'gameweek' => $gameweek->getId(),
                    'exception' => $exception,
                ]);

                continue;
            }

            $snapshot = $existing[$entry->getId()] ?? new EntryGameweek($entry, $gameweek);
            $this->em->persist($snapshot);
            $existing[$entry->getId()] = $snapshot;

            if (null === $picks) {
                $snapshot->markMissing();
                $snapshot->markSynced($now, $gameweek->isDataChecked());

                continue;
            }

            $this->apply($snapshot, $picks, $live, $previousVerdict);
            $snapshot->markSynced($now, $gameweek->isDataChecked());
            $previousVerdict = $snapshot->getPointsIncludeHit() ?? $previousVerdict;
        }

        $this->em->flush();

        return $existing;
    }

    public function lastSyncFailed(): bool
    {
        return $this->lastSyncFailed;
    }

    private function needsRefresh(?EntryGameweek $snapshot, \DateTimeImmutable $now, bool $force): bool
    {
        if (null === $snapshot) {
            return true;
        }

        if ($snapshot->isFinalised()) {
            return false;
        }

        if ($force) {
            return true;
        }

        $syncedAt = $snapshot->getSyncedAt();

        return null === $syncedAt || ($now->getTimestamp() - $syncedAt->getTimestamp()) >= self::TTL_SECONDS;
    }

    private function apply(EntryGameweek $snapshot, EntryPicksDto $picks, LiveDto $live, ?bool $previousVerdict): void
    {
        $snapshot->clearPicks();

        $livePointsSum = 0;
        $captainPoints = 0;
        $rows = [];

        foreach ($picks->picks as $pick) {
            $element = $this->elements->find($pick->elementId);

            if (null === $element) {
                $this->logger->warning('Zawodnik spoza slownika, pozycja pominieta.', ['element' => $pick->elementId]);

                continue;
            }

            $rawPoints = $live->pointsFor($pick->elementId);
            $effectivePoints = $rawPoints * $pick->multiplier;

            if ($pick->multiplier > 0) {
                $livePointsSum += $effectivePoints;
            }

            if ($pick->multiplier >= 2) {
                $captainPoints += $effectivePoints;
            }

            $rows[] = new EntryGameweekPick(
                $snapshot,
                $element,
                $pick->position,
                $pick->multiplier,
                $pick->captain,
                $pick->viceCaptain,
                $rawPoints,
                $effectivePoints,
                $live->minutesFor($pick->elementId),
            );
        }

        $resolved = $this->netPoints->resolve(
            $picks->points,
            $picks->transfersCost,
            $live->isEmpty() ? null : $livePointsSum,
            $previousVerdict,
        );

        $snapshot->applyResult(
            apiPoints: $picks->points,
            transfers: $picks->transfers,
            transfersCost: $picks->transfersCost,
            pointsOnBench: $picks->pointsOnBench,
            activeChip: $picks->activeChip,
            livePointsSum: $live->isEmpty() ? null : $livePointsSum,
            pointsIncludeHit: $resolved->pointsIncludeHit,
            netPoints: $resolved->netPoints,
            captainPoints: $captainPoints,
        );

        foreach ($rows as $row) {
            $snapshot->addPick($row);
        }
    }
}
```

- [ ] **Step 4: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS. Jeśli `testRefreshesAfterTtl` zgłosi duplikat klucza `uniq_entry_gameweek_position`, znaczy że `clearPicks()` nie zadziałało — sprawdź, czy `EntryGameweek::$picks` ma `orphanRemoval: true`.

- [ ] **Step 5: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/Fpl/Sync tests/Integration/EntryGameweekSyncTest.php
git commit -m "feat: snapshoty kolejek z TTL i zamrazaniem po data_checked"
```

---

### Task 8: Różnice w składach — `DifferentialAnalyzer`

**Files:**
- Create: `src/League/PlayerDifferential.php`, `src/League/DifferentialReport.php`, `src/League/DifferentialAnalyzer.php`
- Test: `tests/Unit/League/DifferentialAnalyzerTest.php`

**Interfaces:**
- Consumes: encje `EntryGameweek`, `EntryGameweekPick`, `Element` z Task 3
- Produces:
  - `DifferentialAnalyzer::analyze(list<EntryGameweek> $mine, list<EntryGameweek> $opponent): DifferentialReport`
  - `PlayerDifferential` z polami `Element $element`, `int $mineStartedBy`, `int $mineBenchedBy`, `int $minePoints`, `int $opponentStartedBy`, `int $opponentBenchedBy`, `int $opponentPoints`
  - `DifferentialReport` z polami `list<PlayerDifferential> $onlyMine`, `$onlyOpponent`, `$shared` oraz metodami `minePoints(): int`, `opponentPoints(): int`, `netSwing(): int`

Reguły:
1. do koszyków kwalifikuje wystawienie w jedenastce (`multiplier > 0`), nie samo posiadanie,
2. zawodnik wystawiony po obu stronach → `shared`,
3. zawodnik siedzący na ławkach po obu stronach → pomijany zupełnie,
4. punkty w koszyku to suma `effectivePoints` z jedenastek danej strony, więc kapitan liczy się podwójnie,
5. `netSwing()` = suma „tylko moi" minus suma „tylko rywala".

- [ ] **Step 1: Napisz test**

`tests/Unit/League/DifferentialAnalyzerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Unit\League;

use App\Entity\Element;
use App\Entity\EntryGameweek;
use App\Entity\EntryGameweekPick;
use App\Entity\FplEntry;
use App\Entity\Gameweek;
use App\Entity\PlTeam;
use App\League\DifferentialAnalyzer;
use PHPUnit\Framework\TestCase;

final class DifferentialAnalyzerTest extends TestCase
{
    private Gameweek $gameweek;
    /** @var array<string, Element> */
    private array $elements = [];

    protected function setUp(): void
    {
        $this->gameweek = new Gameweek(1, 'Gameweek 1', new \DateTimeImmutable('2026-08-21 17:30:00'));
        $team = new PlTeam(1, 'Liverpool', 'LIV');

        foreach (['Salah' => 301, 'Haaland' => 302, 'Saliba' => 303, 'Raya' => 304] as $name => $id) {
            $this->elements[$name] = new Element($id, $name, 'Imie', $name, 3, $team);
        }
    }

    /**
     * @param array<string, array{int, int}> $picks nazwa zawodnika => [mnoznik, punkty surowe]
     */
    private function snapshot(int $entryId, array $picks): EntryGameweek
    {
        $snapshot = new EntryGameweek(new FplEntry($entryId, 'Druzyna '.$entryId, 'Imie', 'Nazwisko'), $this->gameweek);

        $position = 1;
        foreach ($picks as $name => [$multiplier, $rawPoints]) {
            $snapshot->addPick(new EntryGameweekPick(
                $snapshot,
                $this->elements[$name],
                $position++,
                $multiplier,
                2 === $multiplier,
                false,
                $rawPoints,
                $rawPoints * $multiplier,
                90,
            ));
        }

        return $snapshot;
    }

    public function testSplitsPlayersIntoThreeBuckets(): void
    {
        $mine = [
            $this->snapshot(101, ['Salah' => [2, 12], 'Saliba' => [1, 8]]),
            $this->snapshot(102, ['Salah' => [1, 12], 'Raya' => [1, 6]]),
        ];
        $opponent = [
            $this->snapshot(201, ['Haaland' => [2, 9], 'Raya' => [1, 6]]),
            $this->snapshot(202, ['Haaland' => [1, 9]]),
        ];

        $report = (new DifferentialAnalyzer())->analyze($mine, $opponent);

        $onlyMine = array_map(static fn ($row) => $row->element->getWebName(), $report->onlyMine);
        $onlyOpponent = array_map(static fn ($row) => $row->element->getWebName(), $report->onlyOpponent);
        $shared = array_map(static fn ($row) => $row->element->getWebName(), $report->shared);

        self::assertSame(['Salah', 'Saliba'], $onlyMine);
        self::assertSame(['Haaland'], $onlyOpponent);
        self::assertSame(['Raya'], $shared);
    }

    public function testCountsOwnershipAndPointsWithCaptaincy(): void
    {
        $mine = [
            $this->snapshot(101, ['Salah' => [2, 12]]),
            $this->snapshot(102, ['Salah' => [1, 12]]),
            $this->snapshot(103, ['Salah' => [0, 12]]),
        ];
        $opponent = [$this->snapshot(201, ['Haaland' => [1, 9]])];

        $report = (new DifferentialAnalyzer())->analyze($mine, $opponent);
        $salah = $report->onlyMine[0];

        self::assertSame('Salah', $salah->element->getWebName());
        self::assertSame(2, $salah->mineStartedBy);
        self::assertSame(1, $salah->mineBenchedBy);
        self::assertSame(36, $salah->minePoints, '24 od kapitana + 12 od zwyklego wystawienia');
        self::assertSame(0, $salah->opponentStartedBy);
        self::assertSame(0, $salah->opponentPoints);
    }

    public function testCalculatesNetSwing(): void
    {
        $mine = [$this->snapshot(101, ['Salah' => [2, 12], 'Raya' => [1, 6]])];
        $opponent = [$this->snapshot(201, ['Haaland' => [1, 9], 'Raya' => [1, 6]])];

        $report = (new DifferentialAnalyzer())->analyze($mine, $opponent);

        self::assertSame(24, $report->minePoints());
        self::assertSame(9, $report->opponentPoints());
        self::assertSame(15, $report->netSwing());
    }

    public function testPlayerBenchedOnBothSidesIsIgnored(): void
    {
        $mine = [$this->snapshot(101, ['Salah' => [1, 12], 'Haaland' => [0, 9]])];
        $opponent = [$this->snapshot(201, ['Saliba' => [1, 8], 'Haaland' => [0, 9]])];

        $report = (new DifferentialAnalyzer())->analyze($mine, $opponent);

        $names = array_map(
            static fn ($row) => $row->element->getWebName(),
            array_merge($report->onlyMine, $report->onlyOpponent, $report->shared),
        );

        self::assertNotContains('Haaland', $names);
    }

    public function testBenchedByOpponentButStartedByMeShowsAsDifferential(): void
    {
        $mine = [$this->snapshot(101, ['Haaland' => [1, 9]])];
        $opponent = [$this->snapshot(201, ['Haaland' => [0, 9]])];

        $report = (new DifferentialAnalyzer())->analyze($mine, $opponent);

        self::assertCount(1, $report->onlyMine);
        self::assertSame(1, $report->onlyMine[0]->opponentBenchedBy);
        self::assertSame(0, $report->onlyMine[0]->opponentStartedBy);
        self::assertSame(9, $report->netSwing());
    }

    public function testSortsBucketsByImpact(): void
    {
        $mine = [$this->snapshot(101, ['Saliba' => [1, 8], 'Salah' => [2, 12]])];
        $opponent = [$this->snapshot(201, ['Haaland' => [1, 9]])];

        $report = (new DifferentialAnalyzer())->analyze($mine, $opponent);

        self::assertSame('Salah', $report->onlyMine[0]->element->getWebName());
        self::assertSame('Saliba', $report->onlyMine[1]->element->getWebName());
    }

    public function testEmptySnapshotsProduceEmptyReport(): void
    {
        $report = (new DifferentialAnalyzer())->analyze([], []);

        self::assertSame([], $report->onlyMine);
        self::assertSame([], $report->onlyOpponent);
        self::assertSame([], $report->shared);
        self::assertSame(0, $report->netSwing());
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Unit/League/DifferentialAnalyzerTest.php
```

Oczekiwane: FAIL, `Class "App\League\DifferentialAnalyzer" not found`.

- [ ] **Step 3: Napisz typy raportu**

`src/League/PlayerDifferential.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

use App\Entity\Element;

final readonly class PlayerDifferential
{
    public function __construct(
        public Element $element,
        public int $mineStartedBy,
        public int $mineBenchedBy,
        public int $minePoints,
        public int $opponentStartedBy,
        public int $opponentBenchedBy,
        public int $opponentPoints,
    ) {
    }

    public function impact(): int
    {
        return $this->minePoints + $this->opponentPoints;
    }
}
```

`src/League/DifferentialReport.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final readonly class DifferentialReport
{
    /**
     * @param list<PlayerDifferential> $onlyMine
     * @param list<PlayerDifferential> $onlyOpponent
     * @param list<PlayerDifferential> $shared
     */
    public function __construct(
        public array $onlyMine,
        public array $onlyOpponent,
        public array $shared,
    ) {
    }

    public function minePoints(): int
    {
        return array_sum(array_map(static fn (PlayerDifferential $row) => $row->minePoints, $this->onlyMine));
    }

    public function opponentPoints(): int
    {
        return array_sum(array_map(static fn (PlayerDifferential $row) => $row->opponentPoints, $this->onlyOpponent));
    }

    public function netSwing(): int
    {
        return $this->minePoints() - $this->opponentPoints();
    }

    public function isEmpty(): bool
    {
        return [] === $this->onlyMine && [] === $this->onlyOpponent && [] === $this->shared;
    }
}
```

- [ ] **Step 4: Napisz analizator**

`src/League/DifferentialAnalyzer.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

use App\Entity\Element;
use App\Entity\EntryGameweek;

final class DifferentialAnalyzer
{
    /**
     * @param list<EntryGameweek> $mine
     * @param list<EntryGameweek> $opponent
     */
    public function analyze(array $mine, array $opponent): DifferentialReport
    {
        /** @var array<int, array{element: Element, mineStarted: int, mineBenched: int, minePoints: int, opponentStarted: int, opponentBenched: int, opponentPoints: int}> $totals */
        $totals = [];

        $this->accumulate($totals, $mine, 'mine');
        $this->accumulate($totals, $opponent, 'opponent');

        $onlyMine = [];
        $onlyOpponent = [];
        $shared = [];

        foreach ($totals as $row) {
            if (0 === $row['mineStarted'] && 0 === $row['opponentStarted']) {
                continue;
            }

            $differential = new PlayerDifferential(
                $row['element'],
                $row['mineStarted'],
                $row['mineBenched'],
                $row['minePoints'],
                $row['opponentStarted'],
                $row['opponentBenched'],
                $row['opponentPoints'],
            );

            match (true) {
                $row['mineStarted'] > 0 && 0 === $row['opponentStarted'] => $onlyMine[] = $differential,
                0 === $row['mineStarted'] && $row['opponentStarted'] > 0 => $onlyOpponent[] = $differential,
                default => $shared[] = $differential,
            };
        }

        usort($onlyMine, static fn (PlayerDifferential $a, PlayerDifferential $b) => $b->minePoints <=> $a->minePoints);
        usort($onlyOpponent, static fn (PlayerDifferential $a, PlayerDifferential $b) => $b->opponentPoints <=> $a->opponentPoints);
        usort($shared, static fn (PlayerDifferential $a, PlayerDifferential $b) => $b->impact() <=> $a->impact());

        return new DifferentialReport($onlyMine, $onlyOpponent, $shared);
    }

    /**
     * @param array<int, array{element: Element, mineStarted: int, mineBenched: int, minePoints: int, opponentStarted: int, opponentBenched: int, opponentPoints: int}> $totals
     * @param list<EntryGameweek> $snapshots
     */
    private function accumulate(array &$totals, array $snapshots, string $side): void
    {
        $startedKey = 'mine' === $side ? 'mineStarted' : 'opponentStarted';
        $benchedKey = 'mine' === $side ? 'mineBenched' : 'opponentBenched';
        $pointsKey = 'mine' === $side ? 'minePoints' : 'opponentPoints';

        foreach ($snapshots as $snapshot) {
            foreach ($snapshot->getPicks() as $pick) {
                $elementId = $pick->getElement()->getId();

                $totals[$elementId] ??= [
                    'element' => $pick->getElement(),
                    'mineStarted' => 0,
                    'mineBenched' => 0,
                    'minePoints' => 0,
                    'opponentStarted' => 0,
                    'opponentBenched' => 0,
                    'opponentPoints' => 0,
                ];

                if ($pick->getMultiplier() > 0) {
                    ++$totals[$elementId][$startedKey];
                    $totals[$elementId][$pointsKey] += $pick->getEffectivePoints();
                } else {
                    ++$totals[$elementId][$benchedKey];
                }
            }
        }
    }
}
```

- [ ] **Step 5: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS.

- [ ] **Step 6: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/League tests/Unit/League/DifferentialAnalyzerTest.php
git commit -m "feat: analiza roznic w skladach miedzy czworkami"
```

---

### Task 9: Zarządzanie czwórkami — `SquadManager`

**Files:**
- Create: `src/League/UnknownEntryException.php`, `src/League/SquadManager.php`
- Test: `tests/Integration/SquadManagerTest.php`

**Interfaces:**
- Consumes: `FplApiClient::entry()`, `SquadRepository::findMine()`, encje `Squad`, `SquadMember`, `FplEntry`
- Produces:
  - `SquadManager::resolveEntry(int $entryId): FplEntry` — rzuca `UnknownEntryException` przy 404
  - `SquadManager::saveMySquad(string $name, list<int> $entryIds): Squad` — tworzy albo aktualizuje jedyną drużynę z `mine = true`
  - `SquadManager::createOpponentSquad(string $name, list<int> $entryIds): Squad`
  - `UnknownEntryException::getEntryId(): int`

- [ ] **Step 1: Napisz test**

`tests/Integration/SquadManagerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use App\Fpl\Api\FplApiClient;
use App\League\SquadManager;
use App\League\UnknownEntryException;
use App\Repository\SquadRepository;
use App\Tests\Support\ApiFixtures;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class SquadManagerTest extends KernelTestCase
{
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
    }

    /** @param list<MockResponse> $responses */
    private function manager(array $responses, ?MockHttpClient &$http = null): SquadManager
    {
        $http = new MockHttpClient($responses, 'https://fantasy.premierleague.com/api/');

        return new SquadManager(
            new FplApiClient($http),
            $this->em,
            self::getContainer()->get(SquadRepository::class),
            new MockClock(new \DateTimeImmutable('2026-08-16 12:00:00')),
        );
    }

    /** @return list<MockResponse> */
    private function fourEntries(): array
    {
        return array_fill(0, 4, ApiFixtures::response('entry_101'));
    }

    public function testCreatesMySquadFromFplIds(): void
    {
        $squad = $this->manager($this->fourEntries())->saveMySquad('Moja czworka', [101, 102, 103, 104]);

        self::assertTrue($squad->isMine());
        self::assertSame('Moja czworka', $squad->getName());
        self::assertSame([101, 102, 103, 104], $squad->entryIds());
        self::assertSame('Kuba FC', $squad->entries()[0]->getTeamName());
        self::assertSame('Jakub Gladych', $squad->entries()[0]->getManagerName());
    }

    public function testUpdatesExistingSquadInsteadOfCreatingSecondOne(): void
    {
        $this->manager($this->fourEntries())->saveMySquad('Moja czworka', [101, 102, 103, 104]);
        $this->manager([ApiFixtures::response('entry_101')])->saveMySquad('Nowa nazwa', [101, 102, 103, 105]);

        $this->em->clear();
        $squads = self::getContainer()->get(SquadRepository::class);

        self::assertSame(1, (int) $this->em->createQuery('SELECT COUNT(s.id) FROM App\Entity\Squad s WHERE s.mine = true')->getSingleScalarResult());
        self::assertSame('Nowa nazwa', $squads->findMine()?->getName());
        self::assertSame([101, 102, 103, 105], $squads->findMine()?->entryIds());
    }

    public function testDoesNotCallApiForAlreadyKnownEntries(): void
    {
        $this->manager($this->fourEntries())->saveMySquad('Moja czworka', [101, 102, 103, 104]);

        $this->manager([], $http)->createOpponentSquad('Rywal', [101, 102, 103, 104]);

        self::assertSame(0, $http->getRequestsCount());
    }

    public function testCreatesOpponentSquadNotMarkedAsMine(): void
    {
        $squad = $this->manager($this->fourEntries())->createOpponentSquad('Rywal', [201, 202, 203, 204]);

        self::assertFalse($squad->isMine());
        self::assertSame([201, 202, 203, 204], $squad->entryIds());
    }

    public function testRejectsUnknownEntryId(): void
    {
        $manager = $this->manager([ApiFixtures::notFound()]);

        try {
            $manager->resolveEntry(999999);
            self::fail('Oczekiwano UnknownEntryException.');
        } catch (UnknownEntryException $exception) {
            self::assertSame(999999, $exception->getEntryId());
        }
    }

    public function testRejectsSquadWithDuplicatedIds(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->manager([])->saveMySquad('Moja', [101, 101, 103, 104]);
    }

    public function testRejectsSquadWithWrongSize(): void
    {
        $this->expectException(\InvalidArgumentException::class);

        $this->manager([])->saveMySquad('Moja', [101, 102, 103]);
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Integration/SquadManagerTest.php
```

Oczekiwane: FAIL, `Class "App\League\SquadManager" not found`.

- [ ] **Step 3: Napisz implementację**

`src/League/UnknownEntryException.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final class UnknownEntryException extends \RuntimeException
{
    public function __construct(private readonly int $entryId)
    {
        parent::__construct(sprintf('FPL nie zna druzyny o ID %d.', $entryId));
    }

    public function getEntryId(): int
    {
        return $this->entryId;
    }
}
```

`src/League/SquadManager.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

use App\Entity\FplEntry;
use App\Entity\Squad;
use App\Fpl\Api\FplApiClient;
use App\Repository\SquadRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Clock\ClockInterface;

final readonly class SquadManager
{
    public function __construct(
        private FplApiClient $api,
        private EntityManagerInterface $em,
        private SquadRepository $squads,
        private ClockInterface $clock,
    ) {
    }

    public function resolveEntry(int $entryId): FplEntry
    {
        $entry = $this->em->find(FplEntry::class, $entryId);

        if (null !== $entry) {
            return $entry;
        }

        $dto = $this->api->entry($entryId) ?? throw new UnknownEntryException($entryId);

        $entry = new FplEntry($entryId, $dto->teamName, $dto->firstName, $dto->lastName);
        $entry->updateProfile($dto->teamName, $dto->firstName, $dto->lastName, $dto->startedEvent, $this->clock->now());
        $this->em->persist($entry);

        return $entry;
    }

    /** @param list<int> $entryIds */
    public function saveMySquad(string $name, array $entryIds): Squad
    {
        $this->assertValidIds($entryIds);

        $squad = $this->squads->findMine() ?? new Squad($name, true, $this->clock->now());
        $squad->rename($name);
        $this->fill($squad, $entryIds);

        $this->em->persist($squad);
        $this->em->flush();

        return $squad;
    }

    /** @param list<int> $entryIds */
    public function createOpponentSquad(string $name, array $entryIds): Squad
    {
        $this->assertValidIds($entryIds);

        $squad = new Squad($name, false, $this->clock->now());
        $this->fill($squad, $entryIds);

        $this->em->persist($squad);
        $this->em->flush();

        return $squad;
    }

    /** @param list<int> $entryIds */
    private function fill(Squad $squad, array $entryIds): void
    {
        foreach ($entryIds as $index => $entryId) {
            $squad->setMember($index + 1, $this->resolveEntry($entryId));
        }
    }

    /** @param list<int> $entryIds */
    private function assertValidIds(array $entryIds): void
    {
        if (count($entryIds) !== Squad::SIZE) {
            throw new \InvalidArgumentException(sprintf('Druzyna musi miec dokladnie %d identyfikatorow.', Squad::SIZE));
        }

        if (count(array_unique($entryIds)) !== Squad::SIZE) {
            throw new \InvalidArgumentException('Identyfikatory w druzynie musza byc rozne.');
        }
    }
}
```

- [ ] **Step 4: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS.

- [ ] **Step 5: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/League tests/Integration/SquadManagerTest.php
git commit -m "feat: zarzadzanie czworkami i walidacja ID z FPL"
```

---

### Task 10: Widok meczu i bilans sezonu — `MatchViewBuilder`, `SeasonRecordCalculator`

Jedyne miejsce, w którym spinają się sync, punktacja i różnice. Kontrolery nie będą znać żadnego z tych trzech serwisów osobno.

**Files:**
- Create: `src/League/MatchView.php`, `src/League/MatchViewBuilder.php`, `src/League/SeasonRecord.php`, `src/League/SeasonRecordCalculator.php`
- Test: `tests/Integration/MatchViewBuilderTest.php`

**Interfaces:**
- Consumes: `EntryGameweekSync::syncMany()`, `EntryGameweekRepository::findFor()`, `LeagueScorer::score()`, `DifferentialAnalyzer::analyze()`
- Produces:
  - `MatchViewBuilder::build(LeagueFixture $fixture, bool $refresh = true): MatchView`
  - `MatchView` z polami `LeagueFixture $fixture`, `MatchResult $result`, `DifferentialReport $differentials`, `array<int, EntryGameweek> $snapshots`, `?\DateTimeImmutable $oldestSyncedAt`, `bool $syncFailed` i metodami `snapshotFor(int $entryId): ?EntryGameweek`, `hasAnyData(): bool`
  - `SeasonRecordCalculator::calculate(list<MatchView> $views): SeasonRecord`
  - `SeasonRecord` z polami `int $wins`, `int $draws`, `int $losses`, `float $pointsFor`, `float $pointsAgainst` i metodami `played(): int`, `difference(): float`

- [ ] **Step 1: Napisz test**

`tests/Integration/MatchViewBuilderTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use App\Entity\Gameweek;
use App\Entity\LeagueFixture;
use App\Fpl\Api\FplApiClient;
use App\Fpl\Sync\EntryGameweekSync;
use App\Fpl\Sync\NetPointsResolver;
use App\League\DifferentialAnalyzer;
use App\League\LeagueScorer;
use App\League\MatchOutcome;
use App\League\MatchViewBuilder;
use App\League\SeasonRecordCalculator;
use App\Repository\ElementRepository;
use App\Repository\EntryGameweekRepository;
use App\Tests\Support\ApiFixtures;
use App\Tests\Support\EntityFactory;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\NullLogger;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class MatchViewBuilderTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private EntityFactory $factory;
    private Gameweek $gameweek;
    private LeagueFixture $fixture;
    private MockClock $clock;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
        $this->factory = new EntityFactory($this->em);
        $this->clock = new MockClock(new \DateTimeImmutable('2026-08-22 20:00:00'));

        $this->gameweek = $this->factory->gameweek(1);
        $team = $this->factory->plTeam(1);
        foreach ([301, 302, 303, 304] as $elementId) {
            $this->factory->element($elementId, $team);
        }

        $mine = $this->factory->squad('Moja', [101, 102, 103, 104], true);
        $opponent = $this->factory->squad('Rywal', [201, 202, 203, 204], false);
        $this->fixture = $this->factory->fixture($this->gameweek, $mine, $opponent);
        $this->em->flush();
    }

    /** @param list<MockResponse> $responses */
    private function builder(array $responses, ?MockHttpClient &$http = null): MatchViewBuilder
    {
        $http = new MockHttpClient($responses, 'https://fantasy.premierleague.com/api/');

        $sync = new EntryGameweekSync(
            new FplApiClient($http),
            $this->em,
            self::getContainer()->get(EntryGameweekRepository::class),
            self::getContainer()->get(ElementRepository::class),
            new NetPointsResolver(new NullLogger()),
            $this->clock,
            new NullLogger(),
        );

        return new MatchViewBuilder(
            $sync,
            self::getContainer()->get(EntryGameweekRepository::class),
            new LeagueScorer(),
            new DifferentialAnalyzer(),
        );
    }

    /** @return list<MockResponse> 1x live + 8x picks */
    private function fullMatchResponses(): array
    {
        $responses = [ApiFixtures::response('live_gw1')];
        for ($i = 0; $i < 8; ++$i) {
            $responses[] = ApiFixtures::response('picks_101_gw1');
        }

        return $responses;
    }

    public function testBuildsCompleteViewForAllEightEntries(): void
    {
        $view = $this->builder($this->fullMatchResponses(), $http)->build($this->fixture);

        self::assertSame(9, $http->getRequestsCount(), '1x live + 8x picks');
        self::assertCount(8, $view->result->rows);
        self::assertCount(8, $view->snapshots);
        self::assertSame(28.0, $view->result->myPoints + $view->result->opponentPoints);
        // wszystkie osiem druzyn ma ten sam fixture skladu, wiec kazdy ma 3.5 pkt
        self::assertSame(MatchOutcome::Draw, $view->result->outcome());
        self::assertFalse($view->syncFailed);
        self::assertSame('2026-08-22 20:00:00', $view->oldestSyncedAt?->format('Y-m-d H:i:s'));
        self::assertTrue($view->hasAnyData());
        self::assertNotNull($view->snapshotFor(101));
    }

    public function testUsesTeamNamesAsLabels(): void
    {
        $view = $this->builder($this->fullMatchResponses())->build($this->fixture);

        $labels = array_map(static fn ($row) => $row->input->label, $view->result->rows);

        self::assertContains('Druzyna 101', $labels);
        self::assertContains('Druzyna 204', $labels);
    }

    public function testRefreshFalseDoesNotTouchApi(): void
    {
        $this->builder($this->fullMatchResponses())->build($this->fixture);

        $view = $this->builder([], $http)->build($this->fixture, false);

        self::assertSame(0, $http->getRequestsCount());
        self::assertCount(8, $view->snapshots);
    }

    public function testEmptyMatchStillScoresWithZeros(): void
    {
        $view = $this->builder([], $http)->build($this->fixture, false);

        self::assertSame(0, $http->getRequestsCount());
        self::assertSame(28.0, $view->result->myPoints + $view->result->opponentPoints);
        self::assertSame(14.0, $view->result->myPoints);
        self::assertFalse($view->hasAnyData());
        self::assertNull($view->oldestSyncedAt);
    }

    public function testSeasonRecordCountsOnlyMatchesWithData(): void
    {
        // kolejnosc ma znaczenie: pierwszy widok powstaje na pustej bazie snapshotow
        $withoutData = $this->builder([], $http)->build($this->fixture, false);
        $withData = $this->builder($this->fullMatchResponses())->build($this->fixture);

        $record = (new SeasonRecordCalculator())->calculate([$withData]);
        self::assertSame(1, $record->played());
        self::assertSame(0, $record->wins);
        self::assertSame(1, $record->draws);
        self::assertSame(14.0, $record->pointsFor);
        self::assertSame(0.0, $record->difference());

        $emptyRecord = (new SeasonRecordCalculator())->calculate([$withoutData]);
        self::assertSame(0, $emptyRecord->played());
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Integration/MatchViewBuilderTest.php
```

Oczekiwane: FAIL, `Class "App\League\MatchViewBuilder" not found`.

- [ ] **Step 3: Napisz `MatchView` i `MatchViewBuilder`**

`src/League/MatchView.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

use App\Entity\EntryGameweek;
use App\Entity\LeagueFixture;

final readonly class MatchView
{
    /** @param array<int, EntryGameweek> $snapshots klucz = id wpisu FPL */
    public function __construct(
        public LeagueFixture $fixture,
        public MatchResult $result,
        public DifferentialReport $differentials,
        public array $snapshots,
        public ?\DateTimeImmutable $oldestSyncedAt,
        public bool $syncFailed,
    ) {
    }

    public function snapshotFor(int $entryId): ?EntryGameweek
    {
        return $this->snapshots[$entryId] ?? null;
    }

    public function hasAnyData(): bool
    {
        foreach ($this->snapshots as $snapshot) {
            if ($snapshot->hasData()) {
                return true;
            }
        }

        return false;
    }
}
```

`src/League/MatchViewBuilder.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

use App\Entity\EntryGameweek;
use App\Entity\FplEntry;
use App\Entity\LeagueFixture;
use App\Fpl\Sync\EntryGameweekSync;
use App\Repository\EntryGameweekRepository;

final readonly class MatchViewBuilder
{
    public function __construct(
        private EntryGameweekSync $sync,
        private EntryGameweekRepository $snapshots,
        private LeagueScorer $scorer,
        private DifferentialAnalyzer $analyzer,
    ) {
    }

    public function build(LeagueFixture $fixture, bool $refresh = true): MatchView
    {
        $entries = $fixture->allEntries();
        $gameweek = $fixture->getGameweek();

        $snapshots = $refresh
            ? $this->sync->syncMany($entries, $gameweek)
            : $this->snapshots->findFor($entries, $gameweek);

        $inputs = [];
        foreach ($fixture->getMySquad()->entries() as $entry) {
            $inputs[] = $this->toScoreInput($entry, $snapshots[$entry->getId()] ?? null, MatchSide::Mine);
        }
        foreach ($fixture->getOpponentSquad()->entries() as $entry) {
            $inputs[] = $this->toScoreInput($entry, $snapshots[$entry->getId()] ?? null, MatchSide::Opponent);
        }

        $report = $this->analyzer->analyze(
            $this->snapshotsOf($fixture->getMySquad()->entries(), $snapshots),
            $this->snapshotsOf($fixture->getOpponentSquad()->entries(), $snapshots),
        );

        return new MatchView(
            $fixture,
            $this->scorer->score($inputs),
            $report,
            $snapshots,
            $this->oldestSyncedAt($snapshots),
            $refresh && $this->sync->lastSyncFailed(),
        );
    }

    private function toScoreInput(FplEntry $entry, ?EntryGameweek $snapshot, MatchSide $side): ScoreInput
    {
        return new ScoreInput(
            $entry->getId(),
            $entry->getTeamName(),
            $side,
            $snapshot?->getNetPoints() ?? 0,
            $snapshot?->hasData() ?? false,
        );
    }

    /**
     * @param list<FplEntry> $entries
     * @param array<int, EntryGameweek> $snapshots
     * @return list<EntryGameweek>
     */
    private function snapshotsOf(array $entries, array $snapshots): array
    {
        $result = [];
        foreach ($entries as $entry) {
            $snapshot = $snapshots[$entry->getId()] ?? null;
            if (null !== $snapshot && $snapshot->hasData()) {
                $result[] = $snapshot;
            }
        }

        return $result;
    }

    /** @param array<int, EntryGameweek> $snapshots */
    private function oldestSyncedAt(array $snapshots): ?\DateTimeImmutable
    {
        $oldest = null;
        foreach ($snapshots as $snapshot) {
            $syncedAt = $snapshot->getSyncedAt();
            if (null !== $syncedAt && (null === $oldest || $syncedAt < $oldest)) {
                $oldest = $syncedAt;
            }
        }

        return $oldest;
    }
}
```

- [ ] **Step 4: Napisz bilans sezonu**

`src/League/SeasonRecord.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final readonly class SeasonRecord
{
    public function __construct(
        public int $wins,
        public int $draws,
        public int $losses,
        public float $pointsFor,
        public float $pointsAgainst,
    ) {
    }

    public function played(): int
    {
        return $this->wins + $this->draws + $this->losses;
    }

    public function difference(): float
    {
        return $this->pointsFor - $this->pointsAgainst;
    }
}
```

`src/League/SeasonRecordCalculator.php`:

```php
<?php

declare(strict_types=1);

namespace App\League;

final class SeasonRecordCalculator
{
    /** @param list<MatchView> $views */
    public function calculate(array $views): SeasonRecord
    {
        $wins = $draws = $losses = 0;
        $pointsFor = $pointsAgainst = 0.0;

        foreach ($views as $view) {
            if (!$view->hasAnyData()) {
                continue;
            }

            $pointsFor += $view->result->myPoints;
            $pointsAgainst += $view->result->opponentPoints;

            match ($view->result->outcome()) {
                MatchOutcome::Win => ++$wins,
                MatchOutcome::Draw => ++$draws,
                MatchOutcome::Loss => ++$losses,
            };
        }

        return new SeasonRecord($wins, $draws, $losses, $pointsFor, $pointsAgainst);
    }
}
```

- [ ] **Step 5: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS.

- [ ] **Step 6: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src/League tests/Integration/MatchViewBuilderTest.php
git commit -m "feat: budowa widoku meczu i bilans sezonu"
```

---

### Task 11: Szkielet interfejsu i konfiguracja mojej czwórki

Pierwsze zadanie z widokami. Powstaje tu też atrapa klienta HTTP dla testów funkcjonalnych — używają jej wszystkie kolejne zadania.

**Files:**
- Create: `templates/base.html.twig`, `templates/settings/edit.html.twig`
- Create: `public/css/app.css`
- Create: `src/Form/SquadFormModel.php`, `src/Form/SquadFormType.php`
- Create: `src/Controller/SettingsController.php`
- Create: `tests/Support/FplMockHttpClient.php`
- Create: `config/services_test.yaml`
- Test: `tests/Functional/SettingsControllerTest.php`

**Interfaces:**
- Consumes: `SquadManager`, `SquadRepository`, `UnknownEntryException`
- Produces:
  - trasa `settings` pod `/settings` (GET + POST)
  - `FplMockHttpClient::create(): MockHttpClient` — atrapa serwująca fixture'y po URL-u; w środowisku `test` podmienia serwis `fpl.client`
  - `base.html.twig` z blokami `title` i `body` oraz nawigacją do tras `dashboard`, `fixture_new`, `settings`

- [ ] **Step 1: Napisz atrapę klienta HTTP dla testów funkcjonalnych**

`tests/Support/FplMockHttpClient.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Support;

use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class FplMockHttpClient
{
    public static function create(): MockHttpClient
    {
        return new MockHttpClient(
            static function (string $method, string $url): MockResponse {
                $path = parse_url($url, \PHP_URL_PATH) ?: '';

                return match (true) {
                    str_contains($path, '/bootstrap-static/') => ApiFixtures::response('bootstrap'),
                    (bool) preg_match('#/entry/\d+/event/\d+/picks/#', $path) => ApiFixtures::response('picks_101_gw1'),
                    (bool) preg_match('#/event/\d+/live/#', $path) => ApiFixtures::response('live_gw1'),
                    (bool) preg_match('#/entry/(\d+)/$#', $path, $matches) => self::entry((int) $matches[1]),
                    default => ApiFixtures::notFound(),
                };
            },
            'https://fantasy.premierleague.com/api/',
        );
    }

    private static function entry(int $entryId): MockResponse
    {
        // ID powyzej 900000 udaje nieistniejaca druzyne - testy walidacji formularzy tego uzywaja.
        if ($entryId >= 900000) {
            return ApiFixtures::notFound();
        }

        /** @var array<string, mixed> $data */
        $data = json_decode(ApiFixtures::load('entry_101'), true, 512, \JSON_THROW_ON_ERROR);
        $data['id'] = $entryId;
        $data['name'] = 'Druzyna '.$entryId;

        return new MockResponse(json_encode($data, \JSON_THROW_ON_ERROR), [
            'http_code' => 200,
            'response_headers' => ['content-type' => 'application/json'],
        ]);
    }
}
```

`config/services_test.yaml`:

```yaml
services:
    App\Tests\Support\FplMockHttpClient:
        class: Symfony\Component\HttpClient\MockHttpClient
        factory: ['App\Tests\Support\FplMockHttpClient', 'create']
        public: true

    fpl.client: '@App\Tests\Support\FplMockHttpClient'
```

- [ ] **Step 2: Napisz test funkcjonalny**

`tests/Functional/SettingsControllerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional;

use App\Repository\SquadRepository;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class SettingsControllerTest extends WebTestCase
{
    private KernelBrowser $client;

    protected function setUp(): void
    {
        $this->client = self::createClient();
    }

    public function testShowsEmptyFormWhenNoSquadConfigured(): void
    {
        $crawler = $this->client->request('GET', '/settings');

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h1', 'Moja czworka');
        self::assertCount(1, $crawler->filter('form input[name="squad[entry1]"]'));
    }

    public function testSavesSquadAndRedirects(): void
    {
        $crawler = $this->client->request('GET', '/settings');

        $this->client->submit($crawler->selectButton('Zapisz')->form([
            'squad[name]' => 'Kuba FC',
            'squad[entry1]' => '101',
            'squad[entry2]' => '102',
            'squad[entry3]' => '103',
            'squad[entry4]' => '104',
        ]));

        self::assertResponseRedirects('/settings');
        $this->client->followRedirect();
        self::assertSelectorTextContains('body', 'Zapisano');

        $squad = self::getContainer()->get(SquadRepository::class)->findMine();
        self::assertNotNull($squad);
        self::assertSame('Kuba FC', $squad->getName());
        self::assertSame([101, 102, 103, 104], $squad->entryIds());
    }

    public function testRejectsUnknownEntryId(): void
    {
        $crawler = $this->client->request('GET', '/settings');

        $this->client->submit($crawler->selectButton('Zapisz')->form([
            'squad[name]' => 'Kuba FC',
            'squad[entry1]' => '999999',
            'squad[entry2]' => '102',
            'squad[entry3]' => '103',
            'squad[entry4]' => '104',
        ]));

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'FPL nie zna druzyny o ID 999999');
        self::assertNull(self::getContainer()->get(SquadRepository::class)->findMine());
    }

    public function testRejectsDuplicatedIds(): void
    {
        $crawler = $this->client->request('GET', '/settings');

        $this->client->submit($crawler->selectButton('Zapisz')->form([
            'squad[name]' => 'Kuba FC',
            'squad[entry1]' => '101',
            'squad[entry2]' => '101',
            'squad[entry3]' => '103',
            'squad[entry4]' => '104',
        ]));

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'musza byc rozne');
    }

    public function testPrefillsExistingSquad(): void
    {
        $crawler = $this->client->request('GET', '/settings');
        $this->client->submit($crawler->selectButton('Zapisz')->form([
            'squad[name]' => 'Kuba FC',
            'squad[entry1]' => '101',
            'squad[entry2]' => '102',
            'squad[entry3]' => '103',
            'squad[entry4]' => '104',
        ]));

        $crawler = $this->client->request('GET', '/settings');

        self::assertSame('Kuba FC', $crawler->filter('input[name="squad[name]"]')->attr('value'));
        self::assertSame('103', $crawler->filter('input[name="squad[entry3]"]')->attr('value'));
    }
}
```

- [ ] **Step 3: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Functional/SettingsControllerTest.php
```

Oczekiwane: FAIL, 404 na `/settings`.

- [ ] **Step 4: Napisz model i typ formularza**

`src/Form/SquadFormModel.php`:

```php
<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

class SquadFormModel
{
    #[Assert\NotBlank(message: 'Podaj nazwe druzyny.')]
    #[Assert\Length(max: 100)]
    public ?string $name = null;

    #[Assert\NotNull(message: 'Podaj ID druzyny.')]
    #[Assert\Positive(message: 'ID musi byc liczba dodatnia.')]
    public ?int $entry1 = null;

    #[Assert\NotNull(message: 'Podaj ID druzyny.')]
    #[Assert\Positive(message: 'ID musi byc liczba dodatnia.')]
    public ?int $entry2 = null;

    #[Assert\NotNull(message: 'Podaj ID druzyny.')]
    #[Assert\Positive(message: 'ID musi byc liczba dodatnia.')]
    public ?int $entry3 = null;

    #[Assert\NotNull(message: 'Podaj ID druzyny.')]
    #[Assert\Positive(message: 'ID musi byc liczba dodatnia.')]
    public ?int $entry4 = null;

    /** @return list<int> */
    public function entryIds(): array
    {
        return [(int) $this->entry1, (int) $this->entry2, (int) $this->entry3, (int) $this->entry4];
    }

    #[Assert\Callback]
    public function validateIdsAreDistinct(ExecutionContextInterface $context): void
    {
        $ids = array_filter($this->entryIds());

        if (count($ids) === count(array_unique($ids))) {
            return;
        }

        $context->buildViolation('Identyfikatory druzyn musza byc rozne.')
            ->atPath('entry1')
            ->addViolation();
    }
}
```

`src/Form/SquadFormType.php`:

```php
<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class SquadFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class, ['label' => 'Nazwa druzyny', 'required' => false]);

        foreach ([1, 2, 3, 4] as $slot) {
            $builder->add('entry'.$slot, IntegerType::class, [
                'label' => 'ID druzyny '.$slot,
                'required' => false,
            ]);
        }
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => SquadFormModel::class]);
    }

    public function getBlockPrefix(): string
    {
        return 'squad';
    }
}
```

**Uwaga wykryta w trakcie implementacji:** nazwę pól formularza głównego (czyli `squad[entry1]` zamiast domyślnego `squad_form[entry1]`) ustala się **wyłącznie** przez nadpisanie `getBlockPrefix()`. Opcja `block_prefix` w `configureOptions()` steruje czym innym — listą prefiksów używanych przez silnik szablonów do wyboru bloków motywu — i nie ma żadnego wpływu na atrybut `name` w HTML. `FormFactory::createBuilder()` odczytuje `getBlockPrefix()` z typu, zanim opcje zostaną w ogóle rozwiązane.

- [ ] **Step 5: Napisz kontroler**

`src/Controller/SettingsController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Form\SquadFormModel;
use App\Form\SquadFormType;
use App\League\SquadManager;
use App\League\UnknownEntryException;
use App\Repository\SquadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class SettingsController extends AbstractController
{
    #[Route('/settings', name: 'settings', methods: ['GET', 'POST'])]
    public function edit(Request $request, SquadManager $squads, SquadRepository $repository): Response
    {
        $model = new SquadFormModel();

        if (null !== $existing = $repository->findMine()) {
            $model->name = $existing->getName();
            foreach ($existing->entryIds() as $index => $entryId) {
                $model->{'entry'.($index + 1)} = $entryId;
            }
        }

        $form = $this->createForm(SquadFormType::class, $model);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            try {
                $squads->saveMySquad((string) $model->name, $model->entryIds());
                $this->addFlash('success', 'Zapisano moja czworke.');

                return $this->redirectToRoute('settings');
            } catch (UnknownEntryException $exception) {
                $form->addError(new \Symfony\Component\Form\FormError($exception->getMessage()));
            } catch (\InvalidArgumentException $exception) {
                $form->addError(new \Symfony\Component\Form\FormError($exception->getMessage()));
            }
        }

        return $this->render('settings/edit.html.twig', ['form' => $form->createView()]);
    }
}
```

- [ ] **Step 6: Napisz szablon bazowy i styl**

`templates/base.html.twig`:

```twig
<!DOCTYPE html>
<html lang="pl">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}Liga FPL 4x4{% endblock %}</title>
    <link rel="stylesheet" href="{{ asset('css/app.css') }}">
</head>
<body>
<header class="topbar">
    <a class="brand" href="{{ path('dashboard') }}">Liga FPL 4x4</a>
    <nav>
        <a href="{{ path('dashboard') }}">Bilans</a>
        <a href="{{ path('fixture_new') }}">Nowy mecz</a>
        <a href="{{ path('settings') }}">Moja czworka</a>
    </nav>
</header>

<main>
    {% for label, messages in app.flashes %}
        {% for message in messages %}
            <p class="flash flash--{{ label }}">{{ message }}</p>
        {% endfor %}
    {% endfor %}

    {% block body %}{% endblock %}
</main>
</body>
</html>
```

`templates/settings/edit.html.twig`:

```twig
{% extends 'base.html.twig' %}

{% block title %}Moja czworka{% endblock %}

{% block body %}
    <h1>Moja czworka</h1>
    <p class="hint">Podaj cztery identyfikatory druzyn z oficjalnej gry FPL. Sklad jest staly na caly sezon.</p>

    {{ form_start(form) }}
    {{ form_errors(form) }}
    <div class="form-grid">
        {{ form_row(form.name) }}
        {{ form_row(form.entry1) }}
        {{ form_row(form.entry2) }}
        {{ form_row(form.entry3) }}
        {{ form_row(form.entry4) }}
    </div>
    <button type="submit" class="button">Zapisz</button>
    {{ form_end(form) }}
{% endblock %}
```

`public/css/app.css`:

```css
:root {
    --bg: #0f1117;
    --panel: #171a23;
    --panel-alt: #1f2430;
    --line: #2a3040;
    --text: #e8eaf0;
    --muted: #8f97ab;
    --mine: #37d67a;
    --opponent: #ff6b6b;
    --accent: #4c8dff;
}

* { box-sizing: border-box; }

body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    font: 15px/1.5 "SF Mono", ui-monospace, Menlo, Consolas, monospace;
}

.topbar {
    display: flex;
    align-items: center;
    gap: 24px;
    padding: 14px 24px;
    background: var(--panel);
    border-bottom: 1px solid var(--line);
}

.brand { font-weight: 700; color: var(--text); text-decoration: none; }
.topbar nav { display: flex; gap: 16px; }
.topbar a { color: var(--muted); text-decoration: none; }
.topbar nav a:hover { color: var(--accent); }

main { max-width: 1080px; margin: 0 auto; padding: 24px; }

h1 { font-size: 22px; margin: 0 0 4px; }
h2 { font-size: 17px; margin: 28px 0 10px; }
.hint { color: var(--muted); margin: 0 0 20px; }

.flash { padding: 10px 14px; border-radius: 6px; background: var(--panel-alt); border-left: 3px solid var(--accent); }
.flash--success { border-left-color: var(--mine); }

.form-grid { display: grid; gap: 12px; max-width: 420px; margin-bottom: 16px; }
.form-grid label { display: block; color: var(--muted); font-size: 13px; margin-bottom: 4px; }
.form-grid input {
    width: 100%;
    padding: 8px 10px;
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: 6px;
    color: var(--text);
    font: inherit;
}

.button {
    padding: 9px 18px;
    background: var(--accent);
    border: 0;
    border-radius: 6px;
    color: #fff;
    font: inherit;
    cursor: pointer;
}

form ul { color: var(--opponent); padding-left: 18px; margin: 6px 0; }

table { width: 100%; border-collapse: collapse; margin-bottom: 8px; }
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--line); }
th { color: var(--muted); font-weight: 400; font-size: 13px; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }

tr.side--mine td:first-child { border-left: 3px solid var(--mine); }
tr.side--opponent td:first-child { border-left: 3px solid var(--opponent); }

.scoreline { display: flex; align-items: baseline; gap: 14px; font-size: 26px; margin: 0 0 6px; }
.scoreline .mine { color: var(--mine); }
.scoreline .opponent { color: var(--opponent); }

.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 16px; margin-bottom: 18px; }
.stale { color: var(--muted); font-size: 13px; }
.badge { display: inline-block; padding: 1px 6px; border-radius: 4px; background: var(--panel-alt); color: var(--muted); font-size: 12px; }

.buckets { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 720px) { .buckets { grid-template-columns: 1fr; } }

details { border-top: 1px solid var(--line); padding: 8px 0; }
details summary { cursor: pointer; }
details table { margin-top: 10px; }
.bench td { color: var(--muted); }
```

- [ ] **Step 7: Dodaj tymczasowe trasy, żeby nawigacja się renderowała**

`base.html.twig` odwołuje się do tras `dashboard` i `fixture_new`, których jeszcze nie ma — Twig rzuci wyjątkiem. Utwórz `src/Controller/DashboardController.php` z minimalną akcją, którą Task 13 rozbuduje:

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class DashboardController extends AbstractController
{
    #[Route('/', name: 'dashboard', methods: ['GET'])]
    public function index(): Response
    {
        return $this->render('dashboard/index.html.twig', ['record' => null, 'rows' => []]);
    }
}
```

`templates/dashboard/index.html.twig`:

```twig
{% extends 'base.html.twig' %}

{% block title %}Bilans sezonu{% endblock %}

{% block body %}
    <h1>Bilans sezonu</h1>
    <p class="hint">Brak rozegranych meczow.</p>
{% endblock %}
```

Oraz `src/Controller/FixtureController.php` z samą trasą `fixture_new`, którą Task 12 wypełni:

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class FixtureController extends AbstractController
{
    #[Route('/fixture/new', name: 'fixture_new', methods: ['GET', 'POST'])]
    public function new(): Response
    {
        return new Response('', Response::HTTP_NOT_IMPLEMENTED);
    }
}
```

- [ ] **Step 8: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS.

- [ ] **Step 9: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src templates public config tests
git commit -m "feat: szkielet UI i konfiguracja mojej czworki"
```

---

### Task 12: Dodawanie meczu kolejki

**Files:**
- Create: `src/Form/FixtureFormModel.php`, `src/Form/FixtureFormType.php`
- Create: `templates/fixture/new.html.twig`
- Modify: `src/Controller/FixtureController.php` (zastąp atrapę z Task 11)
- Test: `tests/Functional/FixtureNewControllerTest.php`

**Interfaces:**
- Consumes: `DictionarySync::sync()`, `SquadManager`, `SquadRepository::findMine()`, `GameweekRepository::find()`, `LeagueFixtureRepository::findOneByGameweek()`
- Produces: trasa `fixture_new` pod `/fixture/new` (GET + POST), po zapisie przekierowanie na `fixture_show` z parametrem `gameweek`

Walidacja przy zapisie. Wiążące jest tylko to, że **reguła 6 (odpytanie API) wykonuje się jako ostatnia** — literówka w numerze kolejki nie może kosztować czterech żądań sieciowych. Reguły 1–5 są darmowe i mogą zgłaszać błędy w dowolnej kolejności; reguła 4 wypada wcześniej niż 2 i 3, bo jest walidacją na modelu formularza, i tak ma zostać (dzięki temu błąd trafia na konkretne pole, a nie do ogólnej listy błędów formularza).

1. moja czwórka musi być skonfigurowana — inaczej przekierowanie na `settings` z komunikatem,
2. kolejka musi istnieć w słowniku,
3. dla tej kolejki nie może istnieć jeszcze mecz,
4. cztery ID rywala muszą być parami różne,
5. żadne ID rywala nie może występować w mojej czwórce,
6. każde ID musi istnieć w FPL.

- [ ] **Step 1: Napisz test**

`tests/Functional/FixtureNewControllerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional;

use App\Repository\LeagueFixtureRepository;
use App\Tests\Support\EntityFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class FixtureNewControllerTest extends WebTestCase
{
    private KernelBrowser $client;
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->client = self::createClient();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
    }

    private function configureMySquad(): void
    {
        $factory = new EntityFactory($this->em);
        $factory->gameweek(1);
        $factory->gameweek(2);
        $factory->squad('Kuba FC', [101, 102, 103, 104], true);
        $this->em->flush();
    }

    /** @param array<string, string> $overrides */
    private function submit(array $overrides = []): void
    {
        $crawler = $this->client->request('GET', '/fixture/new');

        $this->client->submit($crawler->selectButton('Dodaj mecz')->form(array_merge([
            'fixture[gameweek]' => '1',
            'fixture[opponentName]' => 'Rywale',
            'fixture[entry1]' => '201',
            'fixture[entry2]' => '202',
            'fixture[entry3]' => '203',
            'fixture[entry4]' => '204',
        ], $overrides)));
    }

    public function testRedirectsToSettingsWhenSquadMissing(): void
    {
        $this->client->request('GET', '/fixture/new');

        self::assertResponseRedirects('/settings');
    }

    public function testCreatesFixtureAndRedirectsToMatchView(): void
    {
        $this->configureMySquad();
        $this->submit();

        self::assertResponseRedirects('/fixture/1');

        $fixture = self::getContainer()->get(LeagueFixtureRepository::class)->findOneByGameweek(1);
        self::assertNotNull($fixture);
        self::assertSame('Rywale', $fixture->getOpponentSquad()->getName());
        self::assertSame([201, 202, 203, 204], $fixture->getOpponentSquad()->entryIds());
        self::assertSame([101, 102, 103, 104], $fixture->getMySquad()->entryIds());
    }

    public function testRejectsSecondFixtureForSameGameweek(): void
    {
        $this->configureMySquad();
        $this->submit();
        $this->submit(['fixture[entry1]' => '301']);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'Mecz na ta kolejke juz istnieje');
    }

    public function testRejectsUnknownGameweek(): void
    {
        $this->configureMySquad();
        $this->submit(['fixture[gameweek]' => '38']);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'Nie znam kolejki numer 38');
    }

    public function testRejectsOverlapWithMySquad(): void
    {
        $this->configureMySquad();
        $this->submit(['fixture[entry2]' => '103']);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'wystepuje juz w mojej czworce');
    }

    public function testRejectsDuplicatedOpponentIds(): void
    {
        $this->configureMySquad();
        $this->submit(['fixture[entry2]' => '201']);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'musza byc rozne');
    }

    public function testRejectsUnknownEntryId(): void
    {
        $this->configureMySquad();
        $this->submit(['fixture[entry1]' => '999999']);

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'FPL nie zna druzyny o ID 999999');
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Functional/FixtureNewControllerTest.php
```

Oczekiwane: FAIL — atrapa z Task 11 zwraca 501.

- [ ] **Step 3: Napisz model i typ formularza**

`src/Form/FixtureFormModel.php`:

```php
<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

class FixtureFormModel
{
    #[Assert\NotNull(message: 'Podaj numer kolejki.')]
    #[Assert\Range(min: 1, max: 38, notInRangeMessage: 'Kolejka musi byc z zakresu {{ min }}-{{ max }}.')]
    public ?int $gameweek = null;

    #[Assert\NotBlank(message: 'Podaj nazwe druzyny rywala.')]
    #[Assert\Length(max: 100)]
    public ?string $opponentName = null;

    #[Assert\NotNull(message: 'Podaj ID druzyny.')]
    #[Assert\Positive(message: 'ID musi byc liczba dodatnia.')]
    public ?int $entry1 = null;

    #[Assert\NotNull(message: 'Podaj ID druzyny.')]
    #[Assert\Positive(message: 'ID musi byc liczba dodatnia.')]
    public ?int $entry2 = null;

    #[Assert\NotNull(message: 'Podaj ID druzyny.')]
    #[Assert\Positive(message: 'ID musi byc liczba dodatnia.')]
    public ?int $entry3 = null;

    #[Assert\NotNull(message: 'Podaj ID druzyny.')]
    #[Assert\Positive(message: 'ID musi byc liczba dodatnia.')]
    public ?int $entry4 = null;

    /** @return list<int> */
    public function entryIds(): array
    {
        return [(int) $this->entry1, (int) $this->entry2, (int) $this->entry3, (int) $this->entry4];
    }

    #[Assert\Callback]
    public function validateIdsAreDistinct(ExecutionContextInterface $context): void
    {
        $ids = array_filter($this->entryIds());

        if (count($ids) === count(array_unique($ids))) {
            return;
        }

        $context->buildViolation('Identyfikatory druzyn rywala musza byc rozne.')
            ->atPath('entry1')
            ->addViolation();
    }
}
```

`src/Form/FixtureFormType.php`:

```php
<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class FixtureFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('gameweek', IntegerType::class, ['label' => 'Kolejka', 'required' => false])
            ->add('opponentName', TextType::class, ['label' => 'Nazwa druzyny rywala', 'required' => false]);

        foreach ([1, 2, 3, 4] as $slot) {
            $builder->add('entry'.$slot, IntegerType::class, [
                'label' => 'ID druzyny rywala '.$slot,
                'required' => false,
            ]);
        }
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => FixtureFormModel::class]);
    }

    public function getBlockPrefix(): string
    {
        return 'fixture';
    }
}
```

Nazwę pól ustala `getBlockPrefix()`, nie opcja `block_prefix` — patrz uwaga przy `SquadFormType` w Task 11.

- [ ] **Step 4: Napisz kontroler**

Zastąp całą treść `src/Controller/FixtureController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\LeagueFixture;
use App\Form\FixtureFormModel;
use App\Form\FixtureFormType;
use App\Fpl\Api\FplApiException;
use App\Fpl\Sync\DictionarySync;
use App\League\SquadManager;
use App\League\UnknownEntryException;
use App\Repository\GameweekRepository;
use App\Repository\LeagueFixtureRepository;
use App\Repository\SquadRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class FixtureController extends AbstractController
{
    #[Route('/fixture/new', name: 'fixture_new', methods: ['GET', 'POST'])]
    public function new(
        Request $request,
        SquadRepository $squads,
        SquadManager $squadManager,
        GameweekRepository $gameweeks,
        LeagueFixtureRepository $fixtures,
        DictionarySync $dictionaries,
        EntityManagerInterface $em,
        ClockInterface $clock,
        LoggerInterface $logger,
    ): Response {
        $mySquad = $squads->findMine();

        if (null === $mySquad || !$mySquad->isComplete()) {
            $this->addFlash('error', 'Najpierw skonfiguruj swoja czworke.');

            return $this->redirectToRoute('settings');
        }

        try {
            $dictionaries->sync();
        } catch (FplApiException $exception) {
            $logger->warning('Nie udalo sie odswiezyc slownikow.', ['exception' => $exception]);
        }

        $model = new FixtureFormModel();
        $form = $this->createForm(FixtureFormType::class, $model);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $gameweek = $gameweeks->find((int) $model->gameweek);

            if (null === $gameweek) {
                $form->addError(new FormError(sprintf('Nie znam kolejki numer %d. Odswiez slowniki i sprobuj ponownie.', (int) $model->gameweek)));
            } elseif (null !== $fixtures->findOneByGameweek($gameweek->getId())) {
                $form->addError(new FormError('Mecz na ta kolejke juz istnieje.'));
            } elseif (null !== $overlap = $this->findOverlap($mySquad->entryIds(), $model->entryIds())) {
                $form->addError(new FormError(sprintf('ID %d wystepuje juz w mojej czworce.', $overlap)));
            } elseif (null === $this->tryCreate($form, $squadManager, $em, $clock, $model, $gameweek, $mySquad)) {
                // blad zostal dopisany do formularza w tryCreate()
            } else {
                $this->addFlash('success', 'Dodano mecz kolejki '.$gameweek->getId().'.');

                return $this->redirectToRoute('fixture_show', ['gameweek' => $gameweek->getId()]);
            }
        }

        return $this->render('fixture/new.html.twig', [
            'form' => $form->createView(),
            'mySquad' => $mySquad,
        ]);
    }

    /**
     * @param list<int> $mine
     * @param list<int> $opponent
     */
    private function findOverlap(array $mine, array $opponent): ?int
    {
        $common = array_intersect($mine, $opponent);

        return [] === $common ? null : (int) reset($common);
    }

    private function tryCreate(
        FormInterface $form,
        SquadManager $squadManager,
        EntityManagerInterface $em,
        ClockInterface $clock,
        FixtureFormModel $model,
        \App\Entity\Gameweek $gameweek,
        \App\Entity\Squad $mySquad,
    ): ?LeagueFixture {
        try {
            $opponentSquad = $squadManager->createOpponentSquad((string) $model->opponentName, $model->entryIds());
        } catch (UnknownEntryException|\InvalidArgumentException $exception) {
            $form->addError(new FormError($exception->getMessage()));

            return null;
        } catch (FplApiException $exception) {
            $form->addError(new FormError('FPL API nie odpowiada, sprobuj za chwile.'));

            return null;
        }

        $fixture = new LeagueFixture($gameweek, $mySquad, $opponentSquad, $clock->now());
        $em->persist($fixture);
        $em->flush();

        return $fixture;
    }
}
```

- [ ] **Step 5: Napisz szablon**

`templates/fixture/new.html.twig`:

```twig
{% extends 'base.html.twig' %}

{% block title %}Nowy mecz{% endblock %}

{% block body %}
    <h1>Nowy mecz</h1>
    <p class="hint">
        Twoja czworka: <strong>{{ mySquad.name }}</strong>
        ({% for entry in mySquad.entries %}{{ entry.teamName }}{% if not loop.last %}, {% endif %}{% endfor %}).
        Podaj numer kolejki i cztery ID druzyn rywala.
    </p>

    {{ form_start(form) }}
    {{ form_errors(form) }}
    <div class="form-grid">
        {{ form_row(form.gameweek) }}
        {{ form_row(form.opponentName) }}
        {{ form_row(form.entry1) }}
        {{ form_row(form.entry2) }}
        {{ form_row(form.entry3) }}
        {{ form_row(form.entry4) }}
    </div>
    <button type="submit" class="button">Dodaj mecz</button>
    {{ form_end(form) }}
{% endblock %}
```

- [ ] **Step 6: Dodaj tymczasową trasę `fixture_show`**

Przekierowanie po zapisie celuje w trasę, której jeszcze nie ma. Dopisz do `FixtureController` metodę, którą Task 13 wypełni:

```php
    #[Route('/fixture/{gameweek}', name: 'fixture_show', requirements: ['gameweek' => '\d+'], methods: ['GET'])]
    public function show(int $gameweek): Response
    {
        return new Response('', Response::HTTP_NOT_IMPLEMENTED);
    }
```

- [ ] **Step 7: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS.

- [ ] **Step 8: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src templates tests
git commit -m "feat: dodawanie meczu kolejki z walidacja skladu rywala"
```

---

### Task 13: Widok meczu

**Files:**
- Create: `templates/fixture/show.html.twig`, `templates/fixture/_standings.html.twig`, `templates/fixture/_differentials.html.twig`, `templates/fixture/_picks.html.twig`
- Modify: `src/Controller/FixtureController.php` (metoda `show`)
- Test: `tests/Functional/FixtureShowControllerTest.php`

**Interfaces:**
- Consumes: `MatchViewBuilder::build()`, `LeagueFixtureRepository::findOneByGameweek()`, `MatchView`, `MatchResult`, `DifferentialReport`
- Produces: trasa `fixture_show` pod `/fixture/{gameweek}` renderująca pełny widok meczu; 404 gdy meczu nie ma

- [ ] **Step 1: Napisz test**

`tests/Functional/FixtureShowControllerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional;

use App\Tests\Support\EntityFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class FixtureShowControllerTest extends WebTestCase
{
    private KernelBrowser $client;
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->client = self::createClient();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
    }

    private function seedFixture(): void
    {
        $factory = new EntityFactory($this->em);
        $gameweek = $factory->gameweek(1);
        $team = $factory->plTeam(1);
        foreach ([301, 302, 303, 304] as $elementId) {
            $factory->element($elementId, $team);
        }
        $mine = $factory->squad('Kuba FC', [101, 102, 103, 104], true);
        $opponent = $factory->squad('Rywale', [201, 202, 203, 204], false);
        $factory->fixture($gameweek, $mine, $opponent);
        $this->em->flush();
    }

    public function testReturnsNotFoundForMissingFixture(): void
    {
        $this->client->request('GET', '/fixture/7');

        self::assertResponseStatusCodeSame(404);
    }

    public function testRendersScorelineAndEightRows(): void
    {
        $this->seedFixture();

        $crawler = $this->client->request('GET', '/fixture/1');

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h1', 'Kolejka 1');
        self::assertCount(8, $crawler->filter('table.standings tbody tr'));
        self::assertSelectorExists('.scoreline .mine');
        self::assertSelectorExists('.scoreline .opponent');
    }

    public function testRendersLeaguePointsAndFplPointsForEveryTeam(): void
    {
        $this->seedFixture();

        $crawler = $this->client->request('GET', '/fixture/1');
        $firstRow = $crawler->filter('table.standings tbody tr')->first();

        // atrapa zwraca ten sam sklad dla kazdej z 8 druzyn: 82 pkt api, hit 4 -> 78 netto, po 3.5 pkt ligowego
        self::assertStringContainsString('78', $firstRow->text());
        // Asercja na surowym tekscie: podmiana separatora przed asercja sprawilaby,
        // ze test przechodzi takze przy bledym separatorze, czyli nie sprawdza niczego.
        self::assertStringContainsString('3,5', $firstRow->text());
    }

    public function testRendersFifteenPlayersPerSquad(): void
    {
        $this->seedFixture();

        $crawler = $this->client->request('GET', '/fixture/1');

        self::assertCount(8, $crawler->filter('details.squad-picks'));
        self::assertCount(15, $crawler->filter('details.squad-picks')->first()->filter('tbody tr'));
    }

    public function testRendersDifferentialsSection(): void
    {
        $this->seedFixture();

        $this->client->request('GET', '/fixture/1');

        self::assertSelectorTextContains('body', 'Roznice w skladach');
        // wszystkie osiem druzyn ma identyczny sklad, wiec kazdy zawodnik jest wspolny
        self::assertSelectorTextContains('.bucket--shared', 'Wspolni');
        self::assertSelectorTextContains('.bucket--mine', 'Brak.');
        self::assertSelectorTextContains('.bucket--opponent', 'Brak.');
    }

    public function testShowsStalenessBannerWhenDataIsOld(): void
    {
        $this->seedFixture();
        $this->client->request('GET', '/fixture/1');

        self::assertSelectorExists('.stale');
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Functional/FixtureShowControllerTest.php
```

Oczekiwane: FAIL — atrapa `show` zwraca 501.

- [ ] **Step 3: Napisz akcję `show`**

Zastąp atrapową metodę `show` w `src/Controller/FixtureController.php`:

```php
    #[Route('/fixture/{gameweek}', name: 'fixture_show', requirements: ['gameweek' => '\d+'], methods: ['GET'])]
    public function show(
        int $gameweek,
        LeagueFixtureRepository $fixtures,
        MatchViewBuilder $builder,
        ClockInterface $clock,
    ): Response {
        $fixture = $fixtures->findOneByGameweek($gameweek)
            ?? throw $this->createNotFoundException(sprintf('Brak meczu na kolejke %d.', $gameweek));

        $view = $builder->build($fixture);

        $syncedMinutesAgo = null;
        if (null !== $view->oldestSyncedAt) {
            $syncedMinutesAgo = intdiv($clock->now()->getTimestamp() - $view->oldestSyncedAt->getTimestamp(), 60);
        }

        return $this->render('fixture/show.html.twig', [
            'view' => $view,
            'syncedMinutesAgo' => $syncedMinutesAgo,
        ]);
    }
```

Dopisz brakujące importy na górze pliku:

```php
use App\League\MatchViewBuilder;
```

`LeagueFixtureRepository`, `ClockInterface`, `Response` i `Route` są już zaimportowane z Task 12.

- [ ] **Step 4: Napisz szablon główny**

`templates/fixture/show.html.twig`:

```twig
{% extends 'base.html.twig' %}

{% set fixture = view.fixture %}
{% set result = view.result %}

{% block title %}Kolejka {{ fixture.gameweek.id }}{% endblock %}

{% block body %}
    <h1>Kolejka {{ fixture.gameweek.id }}</h1>

    <div class="panel">
        <p class="scoreline">
            <span class="mine">{{ fixture.mySquad.name }} {{ result.myPoints|number_format(1, ',', ' ') }}</span>
            <span>:</span>
            <span class="opponent">{{ result.opponentPoints|number_format(1, ',', ' ') }} {{ fixture.opponentSquad.name }}</span>
        </p>
        <p class="hint">
            {{ result.outcome.label }}
            {% if result.margin != 0 %}({{ result.margin > 0 ? '+' : '' }}{{ result.margin|number_format(1, ',', ' ') }}){% endif %}
            &middot;
            {% if fixture.gameweek.dataChecked %}
                <span class="badge">wynik ostateczny</span>
            {% elseif fixture.gameweek.finished %}
                <span class="badge">kolejka zakonczona, trwa naliczanie bonusow</span>
            {% else %}
                <span class="badge">kolejka w toku</span>
            {% endif %}
        </p>

        {% if view.syncFailed %}
            <p class="stale">FPL API nie odpowiedzialo &mdash; pokazuje ostatnie zapisane dane.</p>
        {% endif %}
        {% if syncedMinutesAgo is not null %}
            <p class="stale">Dane sprzed {{ syncedMinutesAgo }} min.</p>
        {% elseif not view.hasAnyData %}
            <p class="stale">Brak danych z FPL &mdash; kolejka jeszcze sie nie rozpoczela.</p>
        {% endif %}
    </div>

    {% include 'fixture/_standings.html.twig' %}
    {% include 'fixture/_differentials.html.twig' with {'report': view.differentials} %}
    {% include 'fixture/_picks.html.twig' %}
{% endblock %}
```

- [ ] **Step 5: Napisz tabelę wyników**

`templates/fixture/_standings.html.twig`:

```twig
<h2>Tabela kolejki</h2>
<table class="standings">
    <thead>
    <tr>
        <th class="num">#</th>
        <th>Druzyna</th>
        <th class="num">FPL</th>
        <th class="num">Hit</th>
        <th class="num">Kapitan</th>
        <th class="num">Lawka</th>
        <th>Chip</th>
        <th class="num">Pkt</th>
    </tr>
    </thead>
    <tbody>
    {% for row in view.result.rows %}
        {% set snapshot = view.snapshotFor(row.input.fplEntryId) %}
        <tr class="side--{{ row.input.side.value }}">
            <td class="num">{{ row.position }}</td>
            <td>
                {{ row.input.label }}
                {% if not row.input.hasData %}<span class="badge">brak danych</span>{% endif %}
            </td>
            <td class="num">{{ row.input.netPoints }}</td>
            <td class="num">{% if snapshot and snapshot.transfersCost > 0 %}-{{ snapshot.transfersCost }}{% else %}&mdash;{% endif %}</td>
            <td class="num">{{ snapshot ? snapshot.captainPoints : 0 }}</td>
            <td class="num">{{ snapshot ? snapshot.pointsOnBench : 0 }}</td>
            <td>{{ snapshot and snapshot.activeChip ? snapshot.activeChip : '' }}</td>
            <td class="num"><strong>{{ row.leaguePoints|number_format(1, ',', ' ') }}</strong></td>
        </tr>
    {% endfor %}
    </tbody>
</table>
```

- [ ] **Step 6: Napisz sekcję różnic**

`templates/fixture/_differentials.html.twig`:

```twig
<h2>Roznice w skladach</h2>

<div class="panel">
    <p class="scoreline">
        <span class="mine">{{ report.minePoints }}</span>
        <span>:</span>
        <span class="opponent">{{ report.opponentPoints }}</span>
    </p>
    <p class="hint">
        Bilans roznic: {{ report.netSwing > 0 ? '+' : '' }}{{ report.netSwing }} pkt.
        Liczone z jedenastek obu stron, punkty po mnozniku — kapitan wazy wiecej.
    </p>
</div>

<div class="buckets">
    <div class="bucket bucket--mine">
        <h3>Tylko moi ({{ report.minePoints }} pkt)</h3>
        <table>
            <tbody>
            {% for row in report.onlyMine %}
                <tr>
                    <td>{{ row.element.webName }} <span class="badge">{{ row.element.plTeam.shortName }}</span></td>
                    <td class="num">x{{ row.mineStartedBy }}</td>
                    <td class="num">{{ row.minePoints }}</td>
                    <td>{% if row.opponentBenchedBy > 0 %}<span class="badge">rywal na lawce x{{ row.opponentBenchedBy }}</span>{% endif %}</td>
                </tr>
            {% else %}
                <tr><td colspan="4" class="hint">Brak.</td></tr>
            {% endfor %}
            </tbody>
        </table>
    </div>

    <div class="bucket bucket--opponent">
        <h3>Tylko rywala ({{ report.opponentPoints }} pkt)</h3>
        <table>
            <tbody>
            {% for row in report.onlyOpponent %}
                <tr>
                    <td>{{ row.element.webName }} <span class="badge">{{ row.element.plTeam.shortName }}</span></td>
                    <td class="num">x{{ row.opponentStartedBy }}</td>
                    <td class="num">{{ row.opponentPoints }}</td>
                    <td>{% if row.mineBenchedBy > 0 %}<span class="badge">u mnie na lawce x{{ row.mineBenchedBy }}</span>{% endif %}</td>
                </tr>
            {% else %}
                <tr><td colspan="4" class="hint">Brak.</td></tr>
            {% endfor %}
            </tbody>
        </table>
    </div>
</div>

<div class="bucket bucket--shared">
    <h3>Wspolni</h3>
    <table>
        <tbody>
        {% for row in report.shared %}
            <tr>
                <td>{{ row.element.webName }} <span class="badge">{{ row.element.plTeam.shortName }}</span></td>
                <td class="num">ja x{{ row.mineStartedBy }} &rarr; {{ row.minePoints }}</td>
                <td class="num">rywal x{{ row.opponentStartedBy }} &rarr; {{ row.opponentPoints }}</td>
            </tr>
        {% else %}
            <tr><td colspan="3" class="hint">Brak.</td></tr>
        {% endfor %}
        </tbody>
    </table>
</div>
```

- [ ] **Step 7: Napisz rozwijane składy**

`templates/fixture/_picks.html.twig`:

```twig
<h2>Sklady</h2>

{% for row in view.result.rows %}
    {% set snapshot = view.snapshotFor(row.input.fplEntryId) %}
    <details class="squad-picks side--{{ row.input.side.value }}">
        <summary>
            {{ row.input.label }} &mdash; {{ row.input.netPoints }} pkt
            <span class="badge">{{ row.input.side.value == 'mine' ? 'moja' : 'rywal' }}</span>
        </summary>

        {% if snapshot and snapshot.hasData %}
            <table>
                <thead>
                <tr>
                    <th class="num">#</th>
                    <th>Zawodnik</th>
                    <th>Poz</th>
                    <th>Klub</th>
                    <th class="num">Min</th>
                    <th class="num">Pkt</th>
                </tr>
                </thead>
                <tbody>
                {% for pick in snapshot.picks %}
                    <tr class="{{ pick.multiplier == 0 ? 'bench' : '' }}">
                        <td class="num">{{ pick.position }}</td>
                        <td>
                            {{ pick.element.webName }}
                            {% if pick.captain %}<span class="badge">C</span>{% endif %}
                            {% if pick.viceCaptain %}<span class="badge">V</span>{% endif %}
                            {% if pick.multiplier == 0 %}<span class="badge">lawka</span>{% endif %}
                        </td>
                        <td>{{ pick.element.positionLabel }}</td>
                        <td>{{ pick.element.plTeam.shortName }}</td>
                        <td class="num">{{ pick.minutes }}</td>
                        <td class="num">{{ pick.effectivePoints }}{% if pick.multiplier > 1 %} <span class="badge">x{{ pick.multiplier }}</span>{% endif %}</td>
                    </tr>
                {% endfor %}
                </tbody>
            </table>
        {% else %}
            <p class="hint">Brak danych z FPL dla tej druzyny.</p>
        {% endif %}
    </details>
{% endfor %}
```

- [ ] **Step 8: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS. Jeśli `testRendersFifteenPlayersPerSquad` znajdzie mniej niż 15 wierszy, sprawdź, czy słownik `Element` w teście zawiera wszystkie ID z `picks_101_gw1.json`.

- [ ] **Step 9: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src templates tests
git commit -m "feat: widok meczu z tabela kolejki, roznicami i skladami"
```

---

### Task 14: Bilans sezonu na stronie głównej

**Files:**
- Modify: `src/Controller/DashboardController.php` (zastąp atrapę z Task 11), `templates/dashboard/index.html.twig`
- Test: `tests/Functional/DashboardControllerTest.php`

**Interfaces:**
- Consumes: `LeagueFixtureRepository::findAllOrdered()`, `MatchViewBuilder::build($fixture, false)`, `SeasonRecordCalculator::calculate()`, `GameweekRepository::findCurrent()`, `SquadRepository::findMine()`
- Produces: trasa `dashboard` pod `/` z bilansem i listą meczów

Strona główna **nie odświeża** danych z API — buduje widoki z `refresh: false`. Inaczej wejście na stronę główną po 20 kolejkach oznaczałoby 180 zapytań.

- [ ] **Step 1: Napisz test**

`tests/Functional/DashboardControllerTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Functional;

use App\Tests\Support\EntityFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpClient\MockHttpClient;

final class DashboardControllerTest extends WebTestCase
{
    private KernelBrowser $client;
    private EntityManagerInterface $em;

    protected function setUp(): void
    {
        $this->client = self::createClient();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);
    }

    public function testShowsEmptyStateWithoutSquad(): void
    {
        $this->client->request('GET', '/');

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('body', 'Nie masz jeszcze skonfigurowanej czworki');
    }

    public function testListsFixturesAndRecord(): void
    {
        $factory = new EntityFactory($this->em);
        $gameweek = $factory->gameweek(1, finished: true, dataChecked: true);
        $team = $factory->plTeam(1);
        foreach ([301, 302, 303, 304] as $elementId) {
            $factory->element($elementId, $team);
        }
        $mine = $factory->squad('Kuba FC', [101, 102, 103, 104], true);
        $opponent = $factory->squad('Rywale', [201, 202, 203, 204], false);
        $factory->fixture($gameweek, $mine, $opponent);
        $this->em->flush();

        // najpierw wypelnij snapshoty przez widok meczu
        $this->client->request('GET', '/fixture/1');

        $crawler = $this->client->request('GET', '/');

        self::assertResponseIsSuccessful();
        self::assertSelectorTextContains('h1', 'Bilans sezonu');
        self::assertCount(1, $crawler->filter('table.fixtures tbody tr'));
        self::assertSelectorTextContains('.record', '0-1-0');
        self::assertSelectorExists('table.fixtures a[href="/fixture/1"]');
    }

    public function testDoesNotCallApi(): void
    {
        $factory = new EntityFactory($this->em);
        $gameweek = $factory->gameweek(1);
        $mine = $factory->squad('Kuba FC', [101, 102, 103, 104], true);
        $opponent = $factory->squad('Rywale', [201, 202, 203, 204], false);
        $factory->fixture($gameweek, $mine, $opponent);
        $this->em->flush();

        /** @var MockHttpClient $http */
        $http = self::getContainer()->get('App\Tests\Support\FplMockHttpClient');
        $before = $http->getRequestsCount();

        $this->client->request('GET', '/');

        self::assertSame($before, $http->getRequestsCount(), 'Strona glowna nie moze odpytywac FPL.');
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Functional/DashboardControllerTest.php
```

Oczekiwane: FAIL, brak treści „Bilans sezonu" z danymi.

- [ ] **Step 3: Napisz kontroler**

Zastąp całą treść `src/Controller/DashboardController.php`:

```php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\League\MatchViewBuilder;
use App\League\SeasonRecordCalculator;
use App\Repository\GameweekRepository;
use App\Repository\LeagueFixtureRepository;
use App\Repository\SquadRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class DashboardController extends AbstractController
{
    #[Route('/', name: 'dashboard', methods: ['GET'])]
    public function index(
        LeagueFixtureRepository $fixtures,
        MatchViewBuilder $builder,
        SeasonRecordCalculator $calculator,
        GameweekRepository $gameweeks,
        SquadRepository $squads,
    ): Response {
        $views = [];
        foreach ($fixtures->findAllOrdered() as $fixture) {
            $views[] = $builder->build($fixture, false);
        }

        return $this->render('dashboard/index.html.twig', [
            'mySquad' => $squads->findMine(),
            'record' => $calculator->calculate($views),
            'views' => array_reverse($views),
            'currentGameweek' => $gameweeks->findCurrent(),
        ]);
    }
}
```

- [ ] **Step 4: Napisz szablon**

Zastąp całą treść `templates/dashboard/index.html.twig`:

```twig
{% extends 'base.html.twig' %}

{% block title %}Bilans sezonu{% endblock %}

{% block body %}
    <h1>Bilans sezonu</h1>

    {% if mySquad is null %}
        <p class="hint">Nie masz jeszcze skonfigurowanej czworki. <a href="{{ path('settings') }}">Ustaw ja teraz</a>.</p>
    {% else %}
        <div class="panel">
            <p class="scoreline">
                <span class="record">{{ record.wins }}-{{ record.draws }}-{{ record.losses }}</span>
            </p>
            <p class="hint">
                {{ record.played }} {{ record.played == 1 ? 'mecz' : 'meczow' }} &middot;
                punkty {{ record.pointsFor|number_format(1, ',', ' ') }} : {{ record.pointsAgainst|number_format(1, ',', ' ') }}
                ({{ record.difference > 0 ? '+' : '' }}{{ record.difference|number_format(1, ',', ' ') }})
                {% if currentGameweek %}&middot; aktualna kolejka: {{ currentGameweek.id }}{% endif %}
            </p>
            <p class="hint">Moja czworka: <strong>{{ mySquad.name }}</strong> &mdash;
                {% for entry in mySquad.entries %}{{ entry.teamName }}{% if not loop.last %}, {% endif %}{% endfor %}
            </p>
        </div>

        <h2>Mecze</h2>
        <table class="fixtures">
            <thead>
            <tr>
                <th class="num">GW</th>
                <th>Rywal</th>
                <th class="num">Wynik</th>
                <th>Rozstrzygniecie</th>
                <th></th>
            </tr>
            </thead>
            <tbody>
            {% for view in views %}
                <tr>
                    <td class="num">{{ view.fixture.gameweek.id }}</td>
                    <td>{{ view.fixture.opponentSquad.name }}</td>
                    <td class="num">
                        {{ view.result.myPoints|number_format(1, ',', ' ') }} : {{ view.result.opponentPoints|number_format(1, ',', ' ') }}
                    </td>
                    <td>
                        {% if view.hasAnyData %}
                            {{ view.result.outcome.label }}
                        {% else %}
                            <span class="badge">brak danych</span>
                        {% endif %}
                    </td>
                    <td><a href="{{ path('fixture_show', {gameweek: view.fixture.gameweek.id}) }}">szczegoly</a></td>
                </tr>
            {% else %}
                <tr><td colspan="5" class="hint">Brak meczow. <a href="{{ path('fixture_new') }}">Dodaj pierwszy</a>.</td></tr>
            {% endfor %}
            </tbody>
        </table>
    {% endif %}
{% endblock %}
```

- [ ] **Step 5: Uruchom testy**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS.

- [ ] **Step 6: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src templates tests
git commit -m "feat: strona glowna z bilansem sezonu i lista meczow"
```

---

### Task 15: Komenda synchronizacji

Ostatni element: ta sama logika co w widoku, ale odpalana z konsoli — do podpięcia pod crona w trakcie kolejki.

**Files:**
- Create: `src/Command/FplSyncCommand.php`
- Test: `tests/Integration/FplSyncCommandTest.php`

**Interfaces:**
- Consumes: `DictionarySync::sync()`, `EntryGameweekSync::syncMany()`, `LeagueFixtureRepository`
- Produces: komenda `app:fpl:sync` z opcjami `--gw=N` (jedna kolejka; domyślnie wszystkie mecze) i `--force` (ignoruje TTL słowników i snapshotów, ale nie rusza snapshotów zamrożonych)

- [ ] **Step 1: Napisz test**

`tests/Integration/FplSyncCommandTest.php`:

```php
<?php

declare(strict_types=1);

namespace App\Tests\Integration;

use App\Repository\EntryGameweekRepository;
use App\Tests\Support\EntityFactory;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;

final class FplSyncCommandTest extends KernelTestCase
{
    private EntityManagerInterface $em;
    private CommandTester $tester;

    protected function setUp(): void
    {
        self::bootKernel();
        $this->em = self::getContainer()->get(EntityManagerInterface::class);

        $application = new Application(self::$kernel);
        $this->tester = new CommandTester($application->find('app:fpl:sync'));
    }

    private function seedTwoFixtures(): void
    {
        $factory = new EntityFactory($this->em);
        $team = $factory->plTeam(1);
        foreach ([301, 302, 303, 304] as $elementId) {
            $factory->element($elementId, $team);
        }
        $mine = $factory->squad('Kuba FC', [101, 102, 103, 104], true);

        foreach ([1, 2] as $gameweekId) {
            $gameweek = $factory->gameweek($gameweekId);
            $opponent = $factory->squad('Rywale '.$gameweekId, [200 + $gameweekId * 10, 201 + $gameweekId * 10, 202 + $gameweekId * 10, 203 + $gameweekId * 10], false);
            $factory->fixture($gameweek, $mine, $opponent);
        }

        $this->em->flush();
    }

    public function testSyncsAllFixtures(): void
    {
        $this->seedTwoFixtures();

        $this->tester->execute([]);
        $this->tester->assertCommandIsSuccessful();

        $this->em->clear();
        $count = (int) $this->em->createQuery('SELECT COUNT(s.id) FROM App\Entity\EntryGameweek s')->getSingleScalarResult();

        self::assertSame(16, $count, '2 mecze x 8 druzyn');
        self::assertStringContainsString('Kolejka 1', $this->tester->getDisplay());
        self::assertStringContainsString('Kolejka 2', $this->tester->getDisplay());
    }

    public function testSyncsSingleGameweek(): void
    {
        $this->seedTwoFixtures();

        $this->tester->execute(['--gw' => 2]);
        $this->tester->assertCommandIsSuccessful();

        $this->em->clear();
        $count = (int) $this->em->createQuery('SELECT COUNT(s.id) FROM App\Entity\EntryGameweek s')->getSingleScalarResult();

        self::assertSame(8, $count);
        self::assertStringNotContainsString('Kolejka 1', $this->tester->getDisplay());
    }

    public function testReportsMissingFixture(): void
    {
        $this->tester->execute(['--gw' => 9]);

        self::assertSame(1, $this->tester->getStatusCode());
        self::assertStringContainsString('Brak meczu na kolejke 9', $this->tester->getDisplay());
    }
}
```

- [ ] **Step 2: Uruchom test — musi się wywalić**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit tests/Integration/FplSyncCommandTest.php
```

Oczekiwane: FAIL, `Command "app:fpl:sync" is not defined.`

- [ ] **Step 3: Napisz komendę**

`src/Command/FplSyncCommand.php`:

```php
<?php

declare(strict_types=1);

namespace App\Command;

use App\Fpl\Api\FplApiException;
use App\Fpl\Sync\DictionarySync;
use App\Fpl\Sync\EntryGameweekSync;
use App\Repository\LeagueFixtureRepository;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(name: 'app:fpl:sync', description: 'Pobiera z FPL API dane kolejek dla dodanych meczow.')]
final class FplSyncCommand extends Command
{
    public function __construct(
        private readonly DictionarySync $dictionaries,
        private readonly EntryGameweekSync $entries,
        private readonly LeagueFixtureRepository $fixtures,
    ) {
        parent::__construct();
    }

    protected function configure(): void
    {
        $this
            ->addOption('gw', null, InputOption::VALUE_REQUIRED, 'Numer kolejki; domyslnie wszystkie mecze.')
            ->addOption('force', null, InputOption::VALUE_NONE, 'Ignoruj TTL (snapshoty ostateczne i tak zostaja nietkniete).');
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $io = new SymfonyStyle($input, $output);
        $force = (bool) $input->getOption('force');
        $gameweekOption = $input->getOption('gw');

        try {
            $io->text($this->dictionaries->sync($force) ? 'Slowniki odswiezone.' : 'Slowniki aktualne.');
        } catch (FplApiException $exception) {
            $io->error('Nie udalo sie odswiezyc slownikow: '.$exception->getMessage());

            return Command::FAILURE;
        }

        if (null !== $gameweekOption) {
            $fixture = $this->fixtures->findOneByGameweek((int) $gameweekOption);

            if (null === $fixture) {
                $io->error(sprintf('Brak meczu na kolejke %d.', (int) $gameweekOption));

                return Command::FAILURE;
            }

            $fixtures = [$fixture];
        } else {
            $fixtures = $this->fixtures->findAllOrdered();
        }

        if ([] === $fixtures) {
            $io->warning('Nie dodano jeszcze zadnego meczu.');

            return Command::SUCCESS;
        }

        $failed = false;

        foreach ($fixtures as $fixture) {
            $snapshots = $this->entries->syncMany($fixture->allEntries(), $fixture->getGameweek(), $force);
            $withData = array_filter($snapshots, static fn ($snapshot) => $snapshot->hasData());

            $io->text(sprintf(
                'Kolejka %d: %d/%d druzyn z danymi%s',
                $fixture->getGameweek()->getId(),
                count($withData),
                count($snapshots),
                $this->entries->lastSyncFailed() ? ' (czesc zapytan sie nie powiodla)' : '',
            ));

            $failed = $failed || $this->entries->lastSyncFailed();
        }

        if ($failed) {
            // Kod wyjscia jest jedynym sygnalem dla harmonogramu, wiec czesciowa
            // awaria nie moze konczyc sie sukcesem — nawet gdy dane sa nienaruszone.
            $io->warning('Synchronizacja zakonczona z bledami — zachowano poprzednie snapshoty.');

            return Command::FAILURE;
        }

        $io->success('Synchronizacja zakonczona.');

        return Command::SUCCESS;
    }
}
```

- [ ] **Step 4: Uruchom pełny zestaw testów**

```bash
cd /Users/gladki/projects/fpl && vendor/bin/phpunit
```

Oczekiwane: PASS, wszystkie testy zielone.

- [ ] **Step 5: Sprawdź aplikację ręcznie**

```bash
cd /Users/gladki/projects/fpl
php bin/console lint:twig templates
php bin/console lint:container
php bin/console debug:router
symfony server:start -d
```

Wejdź na `/settings`, zapisz cztery prawdziwe ID, dodaj mecz na kolejkę 1 i sprawdź `/fixture/1`. Do 21.08.2026 zobaczysz komunikat „Brak danych z FPL" — to poprawne zachowanie.

```bash
symfony server:stop
```

- [ ] **Step 6: Commit**

```bash
cd /Users/gladki/projects/fpl
git add src tests
git commit -m "feat: komenda app:fpl:sync do odswiezania kolejek z konsoli"
```

---

## Po pierwszej rozegranej kolejce (22.08.2026)

Zadanie do wykonania, gdy pojawią się prawdziwe dane — nie da się go zrobić wcześniej:

1. Zapisz prawdziwe odpowiedzi API jako fixture'y:
   `curl -s "https://fantasy.premierleague.com/api/entry/<TWOJE_ID>/event/1/picks/" > tests/fixtures/api/real_picks.json`
2. Sprawdź w logu, czy `NetPointsResolver` wykrył interpretację: pole `pointsIncludeHit` w tabeli `entry_gameweek` powinno być ustawione dla drużyny, która wzięła hit.
3. Porównaj `netPoints` w aplikacji z wynikiem widocznym w oficjalnej grze. Jeśli się różnią, to znaczy, że detekcja trafiła na przypadek brzegowy — dopisz test na podstawie zapisanego fixture'u i popraw regułę.
4. Podmień ręcznie skonstruowane fixture'y na przycięte prawdziwe odpowiedzi i uruchom pełny zestaw testów.
