<?php
declare(strict_types=1);

namespace App\Support;

use PDO;
use Throwable;

final class CustomCodeService
{
    /** @var list<string> */
    private const POSITIONS = ['head', 'body_start', 'body_end'];

    public function __construct(
        private readonly PDO $pdo,
        private readonly FileLogger $logger = new FileLogger(),
    ) {
    }

    /** @var list<string> */
    public const AUDIENCES = ['any', 'guest', 'member'];

    /**
     * Page types a snippet may be limited to. Derived from the active route so
     * an operator can, for example, load a conversion pixel only where it is
     * relevant instead of on every request.
     *
     * @var list<string>
     */
    public const PAGES = PublicPageType::ALL;

    /**
     * @param array<string, mixed>|null $currentUser
     */
    public function render(string $position, string $pageType = '', string $locale = '', ?array $currentUser = null): string
    {
        try {
            if (!in_array($position, self::POSITIONS, true)) {
                return '';
            }

            $statement = $this->pdo->prepare(
                'SELECT id, code, pages, locales, audience FROM custom_code_snippets '
                . 'WHERE position = :position AND is_enabled = 1 '
                . 'ORDER BY sort_order ASC, id ASC'
            );
            $statement->execute(['position' => $position]);
            $rendered = '';
            $isMember = is_array($currentUser) && (int) ($currentUser['id'] ?? 0) > 0;

            foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
                $code = (string) ($row['code'] ?? '');
                if ($code === '' || strlen($code) > 100000) {
                    continue;
                }
                if (str_contains($code, '<?') || str_contains($code, '?>')) {
                    continue;
                }

                // Out-of-scope snippets are skipped entirely rather than being
                // emitted and disabled: not shipping the code is the only form
                // of scoping that actually means anything.
                $audience = (string) ($row['audience'] ?? 'any');
                if ($audience === 'guest' && $isMember) continue;
                if ($audience === 'member' && !$isMember) continue;

                if (!$this->matchesList((string) ($row['pages'] ?? ''), $pageType)) continue;
                if (!$this->matchesList((string) ($row['locales'] ?? ''), $locale)) continue;

                $rendered .= $code;
            }

            return $rendered;
        } catch (Throwable $throwable) {
            $this->logger->error('Custom code rendering failed.', $throwable, ['position' => $position]);
            return '';
        }
    }

    /**
     * An empty restriction means "no restriction", so existing snippets keep
     * running everywhere after the scoping columns are added.
     */
    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 !== ''));

        if ($allowed === []) {
            return true;
        }

        return in_array(strtolower(trim($candidate)), $allowed, true);
    }
}
