<?php
declare(strict_types=1);

namespace App\Ads;

use App\Settings\SettingsRepository;

final class AdPlacementService
{
    /** @var list<string> */
    public const PLACEMENTS = ['header', 'footer', 'homepage', 'after_question', 'between_answers'];

    /** @var array<string, array<string, mixed>|null> */
    private array $selected = [];

    public function __construct(
        private readonly SettingsRepository $settings,
        private readonly ?string $userAgent = null,
        private readonly ?AdCreativeRepository $creatives = null,
        private readonly string $locale = '',
        private readonly bool $isMember = false,
    ) {
    }

    /**
     * Coarse device class for the current request.
     *
     * Device visibility used to be applied with CSS alone, which meant a
     * "mobile only" advertisement was still delivered to every desktop
     * visitor: the markup and any provider script were downloaded and
     * executed, then hidden. That wasted the bandwidth of the visitors least
     * able to afford it and recorded impressions the advertiser was billed for
     * on screens where the advertisement was never visible.
     *
     * The classification is deliberately coarse and fails towards desktop, so
     * an unrecognised agent still sees advertising rather than silently
     * losing it.
     */
    public function device(): string
    {
        $agent = strtolower(trim((string) ($this->userAgent ?? '')));

        if ($agent === '') return 'desktop';
        if (str_contains($agent, 'ipad') || str_contains($agent, 'tablet') || str_contains($agent, 'kindle') || str_contains($agent, 'playbook')) return 'tablet';
        // Android without "mobile" is conventionally a tablet.
        if (str_contains($agent, 'android') && !str_contains($agent, 'mobile')) return 'tablet';
        if (str_contains($agent, 'mobi') || str_contains($agent, 'iphone') || str_contains($agent, 'ipod') || str_contains($agent, 'windows phone')) return 'mobile';

        return 'desktop';
    }

    /**
     * Whether any placement restricts itself by device.
     *
     * The caller uses this to decide whether the response must advertise
     * `Vary: User-Agent`; without it a shared cache could serve one device's
     * variant to another.
     */
    public function hasDeviceTargeting(): bool
    {
        if (!(bool) $this->settings->get('ads.enabled', false)) return false;
        if (!$this->creatives instanceof AdCreativeRepository) return false;

        foreach ($this->creatives->all() as $creative) {
            if (trim((string) ($creative['target_devices'] ?? '')) !== '') return true;
        }

        return false;
    }

    /** @return list<string> */
    public static function placements(): array
    {
        return self::PLACEMENTS;
    }

    public function render(string $placement): string
    {
        if (!in_array($placement, self::PLACEMENTS, true)) return '';
        if (!(bool) $this->settings->get('ads.enabled', false)) return '';

        $creative = $this->select($placement);
        if ($creative === null) return '';

        $code = trim((string) ($creative['code'] ?? ''));
        // One advertisement carries both; the code wins so an operator can move
        // a slot from a hosted image to a network tag by pasting one field.
        $inner = $code !== ''
            ? $this->renderCreativeCode($code)
            : $this->renderCreativeImage($creative);

        // No empty wrapper: a placement with nothing to show emits nothing.
        if ($inner === '') return '';

        $classes = ['ad-slot', 'ad-slot-' . $placement];

        return '<aside class="' . $this->e(implode(' ', $classes)) . '" data-ad-placement="' . $this->e($placement) . '">'
            . $inner
            . '</aside>';
    }

    /**
     * Choose the advertisement for this placement and request.
     *
     * With rotation off the placement shows one advertisement, so the first by
     * order wins even if several rows are somehow enabled -- the admin guard
     * explains that to a person, this guarantees it whatever wrote the row.
     * With rotation on, eligible advertisements enter a weighted draw.
     *
     * The result is memoised per placement so the same advertisement does not
     * change between two calls inside one render.
     *
     * @return array<string, mixed>|null
     */
    private function select(string $placement): ?array
    {
        if (array_key_exists($placement, $this->selected)) {
            return $this->selected[$placement];
        }

        $this->selected[$placement] = null;

        if (!$this->creatives instanceof AdCreativeRepository) {
            return null;
        }

        $candidates = $this->creatives->eligible(
            $placement,
            $this->device(),
            $this->locale,
            $this->isMember
        );

        if ($candidates === []) {
            return null;
        }

        if (!(bool) $this->settings->get('ads.' . $placement . '.rotation', false)) {
            return $this->selected[$placement] = $candidates[0];
        }

        $total = 0;
        foreach ($candidates as $candidate) {
            $total += max(1, (int) ($candidate['weight'] ?? 1));
        }

        $ticket = random_int(1, max(1, $total));
        foreach ($candidates as $candidate) {
            $ticket -= max(1, (int) ($candidate['weight'] ?? 1));
            if ($ticket <= 0) {
                return $this->selected[$placement] = $candidate;
            }
        }

        return $this->selected[$placement] = $candidates[0];
    }

    public function renderBetweenAnswers(int $answerPosition, ?int $totalAnswers = null): string
    {
        $position = max(1, $answerPosition);
        $totalAnswers = $totalAnswers === null ? null : max(0, $totalAnswers);
        // An insertion belongs between two answers. Never append it after the
        // final answer, even when the interval is one.
        if ($totalAnswers !== null && $totalAnswers > 0 && $position >= $totalAnswers) return '';
        $interval = max(1, min(20, (int) $this->settings->get('ads.between_answers.interval', 2)));
        $maxInsertions = max(1, min(10, (int) $this->settings->get('ads.between_answers.max_insertions', 3)));
        if ($position % $interval !== 0) return '';
        if ((int) floor($position / $interval) > $maxInsertions) return '';
        return $this->render('between_answers');
    }

    private function renderCreativeCode(string $code): string
    {
        // Advertisement code is intentionally not sanitized because providers such as
        // AdSense require <script>/<ins>. Only administrators may save it, and the
        // same checks run again here so a row written outside the admin cannot
        // introduce server-side code.
        if (str_contains($code, '<?') || str_contains($code, '?>')) return '';
        if (strlen($code) > 100000) return '';
        return $code;
    }

    /** @param array<string, mixed> $creative */
    private function renderCreativeImage(array $creative): string
    {
        $imageUrl = trim((string) ($creative['image_url'] ?? ''));
        $linkUrl = trim((string) ($creative['link_url'] ?? ''));
        $alt = trim((string) ($creative['alt'] ?? ''));

        if (!$this->allowedUrl($imageUrl, true)) return '';
        if ($alt === '') return '';
        if ($linkUrl !== '' && !$this->allowedUrl($linkUrl, true)) return '';

        // Explicit dimensions reserve the space before the image loads. Without
        // them an image advertisement shifts the page as it arrives, which is
        // the one ad behaviour a reader actually notices.
        $width = max(0, (int) ($creative['image_width'] ?? 0));
        $height = max(0, (int) ($creative['image_height'] ?? 0));
        $dimensions = $width > 0 && $height > 0
            ? ' width="' . $this->e((string) $width) . '" height="' . $this->e((string) $height) . '"'
            : '';

        $image = '<img class="ad-slot-image" src="' . $this->e($imageUrl) . '" alt="' . $this->e($alt) . '"' . $dimensions . ' loading="lazy" decoding="async">';
        if ($linkUrl === '') return $image;

        $external = preg_match('~^https?://~i', $linkUrl) === 1;
        return '<a class="ad-slot-link" href="' . $this->e($linkUrl) . '" rel="sponsored' . ($external ? ' noopener noreferrer' : '') . '"'
            . ($external ? ' target="_blank"' : '') . '>' . $image . '</a>';
    }

    public function allowedUrl(string $url, bool $allowRootRelative = true): bool
    {
        $url = trim($url);
        if ($url === '') return false;
        if ($allowRootRelative && str_starts_with($url, '/') && !str_starts_with($url, '//')) return true;
        if (filter_var($url, FILTER_VALIDATE_URL) === false) return false;
        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
        return in_array($scheme, ['http', 'https'], true);
    }

    private function e(string $value): string
    {
        return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
    }
}
