<?php

namespace KeyLair;

class Helpers
{
    /** Escape a string for safe HTML output. */
    public static function e(?string $value): string
    {
        return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
    }

    /** Cryptographically random URL-safe token, e.g. for claim link tokens. */
    public static function randomToken(int $bytes = 16): string
    {
        return bin2hex(random_bytes($bytes));
    }

    /** Hash the visitor's IP for rate limiting / redemption logs without storing raw IPs. */
    public static function hashIp(string $ip): string
    {
        // Salted with the app's encryption key so hashes aren't reversible via rainbow tables.
        $salt = Config::get('security', 'encryption_key') ?? '';
        return hash('sha256', $salt . '|' . $ip);
    }

    public static function clientIp(): string
    {
        // Behind Apache directly (no trusted proxy configured by default).
        return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
    }

    public static function redirect(string $path): void
    {
        header('Location: ' . $path);
        exit;
    }

    public static function baseUrl(): string
    {
        return rtrim(Config::get('app', 'base_url') ?? '', '/');
    }

    /** Slugify a product name into a URL-safe slug. */
    public static function slugify(string $text): string
    {
        $text = strtolower(trim($text));
        $text = preg_replace('/[^a-z0-9]+/', '-', $text);
        return trim($text, '-') ?: 'product';
    }

    /** Convert a YouTube watch/share URL into an embeddable one. Returns null if unrecognized. */
    public static function youtubeEmbedUrl(string $url): ?string
    {
        if (preg_match('~(?:youtu\.be/|youtube\.com/(?:watch\?v=|embed/|shorts/))([A-Za-z0-9_-]{6,})~', $url, $m)) {
            return 'https://www.youtube-nocookie.com/embed/' . $m[1];
        }
        return null;
    }

    /**
     * Very small, safe subset of "rich text" rendering for admin-authored
     * intro/outro copy: escapes everything, then re-enables a short allow-list
     * of tags the editor toolbar can produce. No raw HTML from the DB is ever
     * trusted directly.
     */
    public static function renderRichText(?string $raw): string
    {
        if ($raw === null || $raw === '') {
            return '';
        }

        $escaped = self::e($raw);

        // Re-enable a minimal allow-list of simple, self-contained tags.
        $simpleTags = ['strong', 'em', 'b', 'i', 'p', 'br'];
        foreach ($simpleTags as $tag) {
            $escaped = preg_replace('/&lt;(\/?' . $tag . ')&gt;/i', '<$1>', $escaped);
        }

        // Links are re-enabled as one open+close unit, not two independent
        // substitutions — otherwise a rejected opening tag (e.g. a javascript:
        // href, which correctly stays escaped) can still leave its matching
        // closing tag unescaped, producing a stray unbalanced </a>.
        $escaped = preg_replace_callback(
            '/&lt;a[^&]*href=(?:&quot;|&#039;)(https?:\/\/[^\s&]+)(?:&quot;|&#039;)[^&]*&gt;(.*?)&lt;\/a&gt;/is',
            fn ($m) => '<a href="' . self::e($m[1]) . '" rel="noopener noreferrer" target="_blank">' . $m[2] . '</a>',
            $escaped
        );

        return $escaped;
    }

    /** Pulls just one version's entry (e.g. "0.2.25") out of a full CHANGELOG.md, without its heading. */
    public static function changelogSection(?string $changelog, ?string $version): ?string
    {
        if ($changelog === null || $version === null) {
            return null;
        }
        $escapedVersion = preg_quote($version, '/');
        if (!preg_match('/^##\s*\[' . $escapedVersion . '\][^\n]*\n(.*?)(?=\n##\s*\[|\z)/ms', $changelog, $m)) {
            return null;
        }
        return trim($m[1]);
    }

    /**
     * The full released history of a CHANGELOG.md -- every "## [X.Y.Z] -
     * date" section from the first one to the end -- with headings kept in
     * (unlike changelogSection()), for renderChangelogHtml() to break out
     * per version. Skips the "## [Unreleased]" preamble at the top.
     */
    public static function changelogAll(?string $changelog): ?string
    {
        if ($changelog === null) {
            return null;
        }
        if (!preg_match('/^##\s*\[\d/m', $changelog, $m, PREG_OFFSET_CAPTURE)) {
            return null;
        }
        return trim(substr($changelog, $m[0][1]));
    }

    /**
     * Every released version's entry strictly newer than $sinceVersionExclusive
     * -- i.e. everything you'd actually receive by updating from that version
     * to the latest, since an update always jumps straight to the newest
     * build rather than applying versions one at a time. Falls back to the
     * full history if $sinceVersionExclusive isn't found in $changelog at all
     * (e.g. an install so old its own version dropped out of the file).
     */
    public static function changelogSince(?string $changelog, ?string $sinceVersionExclusive): ?string
    {
        $all = self::changelogAll($changelog);
        if ($all === null || $sinceVersionExclusive === null) {
            return $all;
        }
        $escaped = preg_quote($sinceVersionExclusive, '/');
        if (!preg_match('/^##\s*\[' . $escaped . '\]/m', $all, $m, PREG_OFFSET_CAPTURE)) {
            return $all;
        }
        return trim(substr($all, 0, $m[0][1]));
    }

    /**
     * Tiny renderer for one or more concatenated CHANGELOG.md version
     * sections: escapes everything first (same trust model as
     * renderRichText), then re-enables just the handful of Markdown
     * constructs this project's changelog actually uses -- "## [X.Y.Z]"
     * version headings, "### " subheadings, "- " bullet lists, `code`, and
     * **bold**.
     */
    public static function renderChangelogHtml(string $section): string
    {
        $lines = preg_split('/\r\n|\r|\n/', trim($section));
        $html = '';
        $inList = false;

        foreach ($lines as $line) {
            $trimmed = trim($line);

            if ($trimmed === '') {
                continue;
            }

            if (preg_match('/^##\s+\[([^\]]+)\]\s*-?\s*(.*)$/', $trimmed, $m)) {
                if ($inList) { $html .= '</ul>'; $inList = false; }
                $rule = $html === '' ? '' : 'border-top:1px solid var(--line); padding-top:12px; margin-top:14px;';
                $date = $m[2] !== '' ? ' <span style="font-weight:400; color:var(--text-faint); font-size:11px;">' . self::inlineMarkdown($m[2]) . '</span>' : '';
                $html .= '<p style="font-weight:700; font-size:13px; color:var(--text-primary); margin:0 0 6px; ' . $rule . '">' . self::inlineMarkdown($m[1]) . $date . '</p>';
                continue;
            }

            if (preg_match('/^###\s+(.*)$/', $trimmed, $m)) {
                if ($inList) { $html .= '</ul>'; $inList = false; }
                $html .= '<p style="font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:0.05em; color:var(--text-faint); margin:14px 0 6px;">' . self::inlineMarkdown($m[1]) . '</p>';
                continue;
            }

            if (preg_match('/^-\s+(.*)$/', $trimmed, $m)) {
                if (!$inList) { $html .= '<ul style="margin:0 0 8px; padding-left:18px;">'; $inList = true; }
                $html .= '<li style="margin-bottom:4px;">' . self::inlineMarkdown($m[1]) . '</li>';
                continue;
            }

            // A wrapped continuation line of the previous bullet.
            if ($inList) {
                $html = preg_replace('/<\/li>$/', ' ' . self::inlineMarkdown($trimmed) . '</li>', $html, 1);
                continue;
            }

            $html .= '<p style="margin:0 0 8px;">' . self::inlineMarkdown($trimmed) . '</p>';
        }

        if ($inList) {
            $html .= '</ul>';
        }

        return $html;
    }

    private static function inlineMarkdown(string $text): string
    {
        $escaped = self::e($text);
        $escaped = preg_replace('/`([^`]+)`/', '<code>$1</code>', $escaped);
        $escaped = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $escaped);
        return $escaped;
    }
}
