<?php
declare(strict_types=1);

namespace App\Ads;

use App\Support\TranslatableException;
use InvalidArgumentException;
use PDO;
use Throwable;

/**
 * Stored advertisements.
 *
 * One advertisement holds both a code and an image; the renderer prefers the
 * code when both are filled, so an operator can switch a slot from a hosted
 * image to a network tag by pasting into a single field.
 */
final class AdCreativeRepository
{
    /** @var list<string> */
    public const AUDIENCES = ['any', 'guest', 'member'];

    /** @var list<string> */
    public const DEVICES = ['desktop', 'tablet', 'mobile'];

    public function __construct(private readonly PDO $pdo)
    {
    }

    /** @return array<int, array<string, mixed>> */
    public function all(string $placement = ''): array
    {
        try {
            if ($placement !== '') {
                $statement = $this->pdo->prepare(
                    'SELECT * FROM ad_creatives WHERE placement = :placement ORDER BY sort_order ASC, id ASC'
                );
                $statement->execute(['placement' => $placement]);
                return $statement->fetchAll(PDO::FETCH_ASSOC);
            }

            return $this->pdo->query('SELECT * FROM ad_creatives ORDER BY placement ASC, sort_order ASC, id ASC')
                ->fetchAll(PDO::FETCH_ASSOC);
        } catch (Throwable) {
            // Before migration 028 the table does not exist.
            return [];
        }
    }

    /** @return array<string, mixed>|null */
    public function find(int $id): ?array
    {
        try {
            $statement = $this->pdo->prepare('SELECT * FROM ad_creatives WHERE id = :id LIMIT 1');
            $statement->execute(['id' => $id]);
            $row = $statement->fetch(PDO::FETCH_ASSOC);
            return is_array($row) ? $row : null;
        } catch (Throwable) {
            return null;
        }
    }

    /**
     * Advertisements eligible for this request.
     *
     * Filtering happens here rather than in CSS so an excluded advertisement is
     * never delivered: its markup and provider script are not sent at all.
     *
     * @return array<int, array<string, mixed>>
     */
    public function eligible(string $placement, string $device, string $locale, bool $isMember): array
    {
        $candidates = [];

        foreach ($this->all($placement) as $row) {
            if (empty($row['is_enabled'])) continue;

            $startsAt = (string) ($row['starts_at'] ?? '');
            $endsAt = (string) ($row['ends_at'] ?? '');
            $now = gmdate('Y-m-d H:i:s');
            if ($startsAt !== '' && $startsAt > $now) continue;
            if ($endsAt !== '' && $endsAt < $now) continue;

            $audience = (string) ($row['target_audience'] ?? 'any');
            if ($audience === 'guest' && $isMember) continue;
            if ($audience === 'member' && !$isMember) continue;

            if (!$this->matchesList((string) ($row['target_devices'] ?? ''), $device)) continue;
            if (!$this->matchesList((string) ($row['target_locales'] ?? ''), $locale)) continue;

            $candidates[] = $row;
        }

        return $candidates;
    }

    /**
     * Refuse a second running advertisement in a placement without rotation.
     *
     * Enforced here as well as in the renderer on purpose: this explains the
     * situation to a person, the renderer guarantees the invariant whatever
     * wrote the row.
     */
    public function assertPlacementCapacity(string $placement, int $excludeId, bool $rotationEnabled): void
    {
        if ($rotationEnabled) {
            return;
        }

        foreach ($this->all($placement) as $row) {
            if ((int) $row['id'] === $excludeId) continue;
            if (empty($row['is_enabled'])) continue;

            throw new TranslatableException('admin.ads_validation_capacity');
        }
    }

    /** @param array<string, mixed> $input */
    public function save(array $input, bool $rotationEnabled): int
    {
        $id = max(0, (int) ($input['id'] ?? 0));
        $name = trim((string) ($input['name'] ?? ''));
        $placement = trim((string) ($input['placement'] ?? ''));
        $code = (string) ($input['code'] ?? '');
        $imageUrl = trim((string) ($input['image_url'] ?? ''));
        $linkUrl = trim((string) ($input['link_url'] ?? ''));
        $alt = trim((string) ($input['alt'] ?? ''));
        $enabled = !empty($input['is_enabled']);

        if ($name === '') {
            throw new TranslatableException('admin.ads_validation_name');
        }
        if (!in_array($placement, AdPlacementService::PLACEMENTS, true)) {
            throw new TranslatableException('admin.ads_validation_placement');
        }

        // Ad code is deliberately never sanitised -- providers such as AdSense
        // require <script>/<ins>, and only administrators may save it. PHP tags
        // are refused so server-side code can never be introduced here.
        if (strlen($code) > 100000) {
            throw new TranslatableException('admin.ads_validation_code_too_long');
        }
        if (str_contains($code, '<?') || str_contains($code, '?>')) {
            throw new TranslatableException('admin.ads_validation_php_tags');
        }

        $service = new AdPlacementService(new \App\Settings\SettingsRepository($this->pdo));

        if ($imageUrl !== '' && !$service->allowedUrl($imageUrl)) {
            throw new TranslatableException('admin.ads_validation_image_url');
        }
        if ($linkUrl !== '' && !$service->allowedUrl($linkUrl)) {
            throw new TranslatableException('admin.ads_validation_link_url');
        }
        if (trim($code) === '' && $imageUrl === '') {
            throw new TranslatableException('admin.ads_validation_content');
        }
        if (trim($code) === '' && $alt === '') {
            throw new TranslatableException('admin.ads_alt_required');
        }
        if (quaeriva_utf8_length($alt) > 200) {
            throw new TranslatableException('admin.ads_validation_alt_too_long');
        }

        $startsAt = $this->normalizeDate($input['starts_at'] ?? '');
        $endsAt = $this->normalizeDate($input['ends_at'] ?? '');

        // A window that closes before it opens saves cleanly but can never
        // match, so the advertisement would be permanently invisible while the
        // admin table still called it "Scheduled".
        if ($startsAt !== null && $endsAt !== null && $endsAt < $startsAt) {
            throw new TranslatableException('admin.ads_validation_schedule_order');
        }

        if ($enabled) {
            $this->assertPlacementCapacity($placement, $id, $rotationEnabled);
        }

        $params = [
            'name' => $name,
            'placement' => $placement,
            'code' => $code,
            'image_url' => $imageUrl,
            'link_url' => $linkUrl,
            'alt' => $alt,
            'image_width' => max(0, min(2000, (int) ($input['image_width'] ?? 0))),
            'image_height' => max(0, min(2000, (int) ($input['image_height'] ?? 0))),
            'weight' => max(1, min(100, (int) ($input['weight'] ?? 1))),
            'target_devices' => $this->normalizeList($input['target_devices'] ?? [], self::DEVICES),
            'target_locales' => $this->normalizeList($input['target_locales'] ?? '', []),
            'target_audience' => in_array((string) ($input['target_audience'] ?? 'any'), self::AUDIENCES, true)
                ? (string) ($input['target_audience'] ?? 'any')
                : 'any',
            'starts_at' => $startsAt,
            'ends_at' => $endsAt,
            'is_enabled' => $enabled ? 1 : 0,
            'sort_order' => max(-100000, min(100000, (int) ($input['sort_order'] ?? 0))),
        ];

        if ($id > 0) {
            $statement = $this->pdo->prepare(
                'UPDATE ad_creatives SET name=:name, placement=:placement, code=:code, image_url=:image_url,
                    link_url=:link_url, alt=:alt, image_width=:image_width, image_height=:image_height,
                    weight=:weight, target_devices=:target_devices, target_locales=:target_locales,
                    target_audience=:target_audience, starts_at=:starts_at, ends_at=:ends_at,
                    is_enabled=:is_enabled, sort_order=:sort_order, updated_at=NOW()
                 WHERE id=:id'
            );
            $statement->execute($params + ['id' => $id]);
            return $id;
        }

        $statement = $this->pdo->prepare(
            'INSERT INTO ad_creatives (name, placement, code, image_url, link_url, alt, image_width, image_height,
                weight, target_devices, target_locales, target_audience, starts_at, ends_at, is_enabled, sort_order,
                created_at, updated_at)
             VALUES (:name, :placement, :code, :image_url, :link_url, :alt, :image_width, :image_height,
                :weight, :target_devices, :target_locales, :target_audience, :starts_at, :ends_at, :is_enabled,
                :sort_order, NOW(), NOW())'
        );
        $statement->execute($params);

        return (int) $this->pdo->lastInsertId();
    }

    public function setEnabled(int $id, bool $enabled): void
    {
        $statement = $this->pdo->prepare('UPDATE ad_creatives SET is_enabled = :is_enabled, updated_at = NOW() WHERE id = :id');
        $statement->execute(['is_enabled' => $enabled ? 1 : 0, 'id' => $id]);
    }

    public function delete(int $id): void
    {
        $statement = $this->pdo->prepare('DELETE FROM ad_creatives WHERE id = :id');
        $statement->execute(['id' => $id]);
    }

    /** @param mixed $value @param list<string> $allowed */
    private function normalizeList(mixed $value, array $allowed): string
    {
        $items = is_array($value) ? $value : explode(',', (string) $value);
        $normalized = [];

        foreach ($items as $item) {
            $item = strtolower(trim((string) $item));
            if ($item === '') continue;
            if ($allowed !== [] && !in_array($item, $allowed, true)) continue;
            $normalized[$item] = true;
        }

        return implode(',', array_keys($normalized));
    }

    private function normalizeDate(mixed $value): ?string
    {
        $value = trim((string) $value);
        if ($value === '') {
            return null;
        }

        // The browser sends datetime-local as YYYY-MM-DDTHH:MM.
        $value = str_replace('T', ' ', $value);
        $timestamp = strtotime($value);

        if ($timestamp === false) {
            throw new TranslatableException('admin.ads_validation_schedule_date');
        }

        return date('Y-m-d H:i:s', $timestamp);
    }

    /** An empty restriction means no restriction. */
    private function matchesList(string $csv, string $candidate): bool
    {
        $allowed = array_values(array_filter(array_map(
            static fn (string $value): string => strtolower(trim($value)),
            explode(',', $csv)
        ), static fn (string $value): bool => $value !== ''));

        return $allowed === [] || in_array(strtolower(trim($candidate)), $allowed, true);
    }
}
