<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Replacements;

use wpdb;

/**
 * Persistent per-post URL replacement cache.
 *
 * Stores the resolved `{url → link}` map for each post so the URL replacer
 * doesn't have to re-scan `prli_links` on every page load. Unmatched hrefs
 * are also recorded (with `is_replaced = 0`, `link_id = NULL`) so that
 * adding a new URL replacement to a link can surgically invalidate only
 * the posts that actually contain that URL — no global purge.
 *
 * Full-table invalidation is still used for block-list / blanket-mode
 * changes where the set of "relevant URLs" itself changes.
 */
class PostUrlCache
{
    /**
     * The WordPress database handle.
     *
     * @var wpdb
     */
    private wpdb $db;

    /**
     * Constructor.
     *
     * @param wpdb $db The WordPress database handle.
     */
    public function __construct(wpdb $db)
    {
        $this->db = $db;
    }

    /**
     * The fully-prefixed cache table name.
     *
     * @return string
     */
    private function table(): string
    {
        return $this->db->prefix . 'prli_post_url_cache';
    }

    /**
     * Whether this post has ever been scanned. A return of true means any
     * absent URL is genuinely absent from the post, not just uncached.
     *
     * @param integer $postId The post id to check.
     *
     * @return boolean
     */
    public function hasEntries(int $postId): bool
    {
        if ($postId <= 0) {
            return false;
        }
        $table = $this->table();
        $sql   = $this->db->prepare(
            "SELECT 1 FROM {$table} WHERE post_id = %d LIMIT 1",
            $postId
        );
        return (bool) $this->db->get_var($sql);
    }

    /**
     * Resolved replacement map for a post: normalized URL → link row.
     * Only `is_replaced = 1` rows are returned; callers treat an empty
     * array as "no active replacements" and combine it with `hasEntries`
     * to distinguish cache miss from cache hit-with-zero-matches.
     *
     * @param integer $postId The post id to fetch the map for.
     *
     * @return array<string, array{id:int, pretty_url:string, slug:string, nofollow:bool, sponsored:bool, new_window:bool}>
     */
    public function getReplacedMap(int $postId): array
    {
        if ($postId <= 0) {
            return [];
        }
        $table = $this->table();
        $links = $this->db->prefix . 'prli_links';
        $sql   = $this->db->prepare(
            "SELECT c.url, l.id, l.slug, l.nofollow, l.sponsored, l.new_window
             FROM {$table} c
             INNER JOIN {$links} l ON l.id = c.link_id
             WHERE c.post_id = %d AND c.is_replaced = 1 AND l.deleted_at IS NULL",
            $postId
        );
        $rows  = (array) $this->db->get_results($sql, ARRAY_A);
        $map   = [];
        foreach ($rows as $row) {
            $url = (string) $row['url'];
            if ($url === '') {
                continue;
            }
            $map[$url] = [
                'id'         => (int) $row['id'],
                'pretty_url' => \PrettyLinks\Helpers\LinkUrl::build((string) $row['slug']),
                'slug'       => (string) $row['slug'],
                'nofollow'   => (bool) $row['nofollow'],
                'sponsored'  => (bool) $row['sponsored'],
                'new_window' => (bool) $row['new_window'],
            ];
        }
        return $map;
    }

    /**
     * Persist the scan result for a post. `$replaced` is keyed by
     * normalized URL → link_id (ints); `$unmatched` is a flat list of
     * normalized URLs that were found in the post but had no pretty-link
     * match. Uses INSERT IGNORE so concurrent scans of the same post
     * don't fight over the primary key.
     *
     * @param integer            $postId    The post id being cached.
     * @param array<string, int> $replaced  Map of normalized URL to link id.
     * @param array<int, string> $unmatched List of normalized URLs with no match.
     *
     * @return void
     */
    public function store(int $postId, array $replaced, array $unmatched): void
    {
        if ($postId <= 0) {
            return;
        }
        if (empty($replaced) && empty($unmatched)) {
            return;
        }

        $table  = $this->table();
        $values = [];
        $params = [];

        foreach ($replaced as $url => $linkId) {
            $url = (string) $url;
            if ($url === '') {
                continue;
            }
            $values[] = '(%d, %s, %d, 1)';
            $params[] = $postId;
            $params[] = $url;
            $params[] = (int) $linkId;
        }
        foreach ($unmatched as $url) {
            $url = (string) $url;
            if ($url === '') {
                continue;
            }
            $values[] = '(%d, %s, NULL, 0)';
            $params[] = $postId;
            $params[] = $url;
        }

        if (empty($values)) {
            return;
        }

        $sql = "INSERT IGNORE INTO {$table} (post_id, url, link_id, is_replaced) VALUES "
            . implode(', ', $values);

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $this->db->query($this->db->prepare($sql, $params));
    }

    /**
     * Delete every cached row for a single post.
     *
     * @param integer $postId The post id to invalidate.
     *
     * @return void
     */
    public function invalidatePost(int $postId): void
    {
        if ($postId <= 0) {
            return;
        }
        $table = $this->table();
        $this->db->query(
            $this->db->prepare("DELETE FROM {$table} WHERE post_id = %d", $postId)
        );
    }

    /**
     * Invalidate every post whose cached row contains any of the supplied
     * URLs. Used when a link gains or loses URL replacements — only the
     * posts that actually contain those URLs need to be re-scanned.
     *
     * @param array<int, string> $urls The normalized URLs to invalidate by.
     *
     * @return void
     */
    public function invalidateByUrls(array $urls): void
    {
        $urls = array_values(array_filter(array_map(
            static function ($u) {
                return is_string($u) ? $u : '';
            },
            $urls
        ), static function ($u) {
            return $u !== '';
        }));

        if (empty($urls)) {
            return;
        }

        $table        = $this->table();
        $placeholders = implode(', ', array_fill(0, count($urls), '%s'));

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $this->db->query($this->db->prepare(
            "DELETE FROM {$table} WHERE url IN ({$placeholders})",
            $urls
        ));
    }

    /**
     * Delete every cached row that maps to a given link.
     *
     * @param integer $linkId The link id to invalidate by.
     *
     * @return void
     */
    public function invalidateByLinkId(int $linkId): void
    {
        if ($linkId <= 0) {
            return;
        }
        $table = $this->table();
        $this->db->query(
            $this->db->prepare("DELETE FROM {$table} WHERE link_id = %d", $linkId)
        );
    }

    /**
     * Wipe the entire post URL cache.
     *
     * @return void
     */
    public function invalidateAll(): void
    {
        $table = $this->table();
        $this->db->query("DELETE FROM {$table}");
    }
}
