<?php

namespace App\Services;

use App\Models\PromptLandingPageAttachment;

class PromptLandingPageHtmlSanitizer
{
    /**
     * A filename short enough to appear in ordinary markup is matched in context
     * only, never as a bare string.
     */
    private const SHORTEST_SAFE_NEEDLE = 12;

    /**
     * Strip any reference to analyzed attachments so they cannot appear on the page.
     *
     * The stripping used to be an unguarded str_replace of every token across the
     * whole document, and one of those tokens is `original_name`, straight from
     * `$file->getClientOriginalName()`. So the uploader chose the needle. A file
     * called `a` removed every letter a from the page, its stylesheet and its
     * JavaScript; `div` unpicked the markup; `>` would leave no markup at all.
     * Nothing validated the name, because nothing expected it to be used this way.
     *
     * What this is actually for is references - a path or a filename appearing in
     * a src, an href, or a CSS url() - so that is where it now looks. A token with
     * a directory separator in it cannot collide with prose and is still removed
     * everywhere, and so is a filename long enough that a collision is not
     * credible. Anything shorter is only removed where it is being used as a file
     * reference, which is the only place it ever mattered.
     *
     * @param  list<PromptLandingPageAttachment>  $attachments
     */
    public function sanitize(string $html, array $attachments = []): string
    {
        $html = (string) preg_replace(
            '/src=(["\'])data:image\/(?:png|jpe?g|gif|webp)[^"\']*\1/i',
            'src=""',
            $html,
        );

        $tokens = array_values(array_filter($this->tokens($attachments), fn (string $t): bool => $t !== ''));

        if ($tokens === []) {
            return $html;
        }

        $html = $this->blankReferencesTo($html, $tokens);

        foreach ($tokens as $token) {
            if (! $this->safeAsABareNeedle($token)) {
                continue;
            }

            $html = str_replace($token, '', $html);
            $html = str_replace(e($token), '', $html);
        }

        return $html;
    }

    /**
     * Whether removing this token everywhere can only remove a reference.
     *
     * A directory separator settles it: `prompt-landing-pages/` or
     * `storage/app/private` is not going to turn up in a sentence. Otherwise the
     * token has to be long enough that a collision would be a coincidence, which
     * the generated storage paths are and an uploaded filename may not be.
     */
    private function safeAsABareNeedle(string $token): bool
    {
        return str_contains($token, '/') || mb_strlen($token) >= self::SHORTEST_SAFE_NEEDLE;
    }

    /**
     * Empty out any attribute or CSS url() that points at one of these files.
     *
     * The whole value goes rather than the matched part of it: a src with the
     * filename cut out of the middle is a request for some other file, which is a
     * worse outcome than an empty src.
     *
     * @param  list<string>  $tokens
     */
    private function blankReferencesTo(string $html, array $tokens): string
    {
        $referenced = function (string $value) use ($tokens): bool {
            $value = html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');

            foreach ($tokens as $token) {
                // Whole path segment, so `hero.png` does not match
                // `other-hero.png` and a one-letter name matches only a file
                // actually called that.
                if (preg_match('#(^|[/\\\\])'.preg_quote($token, '#').'($|[?\#])#i', $value) === 1) {
                    return true;
                }
            }

            return false;
        };

        $html = (string) preg_replace_callback(
            '/\b(src|href|srcset|poster|data-src|data-original|content)\s*=\s*(["\'])(.*?)\2/is',
            fn (array $m): string => $referenced($m[3]) ? $m[1].'=""' : $m[0],
            $html,
        );

        return (string) preg_replace_callback(
            '/url\(\s*(["\']?)([^)"\']*)\1\s*\)/i',
            fn (array $m): string => $referenced($m[2]) ? 'url()' : $m[0],
            $html,
        );
    }

    /**
     * @param  list<PromptLandingPageAttachment>  $attachments
     * @return list<string>
     */
    public function tokens(array $attachments): array
    {
        $tokens = ['prompt-landing-pages/', 'storage/app/private', 'file://'];

        foreach ($attachments as $attachment) {
            $tokens[] = $attachment->path;
            $tokens[] = $attachment->original_name;

            if (filled($attachment->path)) {
                $tokens[] = basename($attachment->path);
            }
        }

        return array_values(array_unique(array_filter($tokens)));
    }
}
