<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\AutoCreate;

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared
// phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter
// Custom plugin tables (prli_*): table names interpolated from $wpdb->prefix (trusted),
// user values bind through $wpdb->prepare(). No caching: these tables are the source
// of truth for click/redirect data and must read-through. "meta_key"/"meta_value" here
// refer to our own prli_link_metas table, not wp_postmeta.
use PrettyLinks\Pro\Options\Store as OptionsStore;
use PrettyLinks\Pro\PostOptions\PostOptions;
use PrettyLinks\Repositories\Links;
use WP_Post;

/**
 * Auto-creates (or updates) a pretty link whenever a post of a configured
 * post type is saved.
 *
 * V3 drop-in. Uses v3's data shape end-to-end:
 *
 *   - `_pretty-link` post meta (int link id)   — forward pointer from post
 *     to its pretty link. SocialButtons, PostPrettyLink, and the post-edit
 *     meta box all read this.
 *   - `_prlipro-post-options` post meta        — per-post blob with
 *     `requested_slug` plus toggles. Read/written via {@see PostOptions}.
 *
 * No new data structures, no reverse link_meta pointer, no v4-only keys.
 */
class AutoCreator
{
    private const POST_META_LINK_ID     = '_pretty-link';
    private const POST_META_APPLIED_SIG = '_pretty-link-autocreate-sig';
    private const POST_META_APPLIED_CAT = '_pretty-link-autocreate-cat';

    /**
     * Pro options store.
     *
     * @var OptionsStore
     */
    private OptionsStore $options;

    /**
     * Links repository.
     *
     * @var Links
     */
    private Links $links;

    /**
     * Constructor.
     *
     * @param OptionsStore $options Pro options store.
     * @param Links        $links   Links repository.
     */
    public function __construct(OptionsStore $options, Links $links)
    {
        $this->options = $options;
        $this->links   = $links;
    }

    /**
     * `save_post` handler at priority 20 — runs after the PostOptions meta box
     * saves at priority 10, so the requested slug is already in place when we
     * read it here.
     *
     * Runs for every save (including REST). If the REST save beats the classic
     * meta-box iframe POST and the iframe later submits a different requested
     * slug, the second save lands in `syncExisting()` and updates the stored
     * slug — end state matches what the user typed.
     *
     * @param  integer $postId Post ID being saved.
     * @param  WP_Post $post   Post object being saved.
     * @return void
     */
    public function onSavePost(int $postId, WP_Post $post): void
    {
        if ($post->post_status !== 'publish') {
            return;
        }
        if (wp_is_post_revision($post) || wp_is_post_autosave($post)) {
            return;
        }
        // Always reconcile on save — title/URL/description can change on any
        // save regardless of whether the auto-create config moved.
        $this->reconcile($postId, $post);
    }

    /**
     * `template_redirect` handler — backfills existing posts on first view
     * after auto-create is enabled or its category is changed. Gated by a
     * per-post "applied signature" meta so repeat views are a single
     * meta-read no-op.
     */
    public function onSingularView(): void
    {
        if (is_admin() || wp_doing_ajax() || (defined('REST_REQUEST') && REST_REQUEST)) {
            return;
        }
        if (!is_singular()) {
            return;
        }
        $post = get_post();
        if (!$post instanceof WP_Post || $post->post_status !== 'publish') {
            return;
        }
        if (!$this->isEnabledFor((string) $post->post_type)) {
            return;
        }
        // View-time gate: only touch the DB when the effective config for this
        // post type has changed since the last reconcile.
        $currentSig = $this->configSignatureFor((string) $post->post_type);
        $appliedSig = (string) get_post_meta((int) $post->ID, self::POST_META_APPLIED_SIG, true);
        if ($appliedSig === $currentSig) {
            return;
        }
        $this->reconcile((int) $post->ID, $post);
    }

    /**
     * Create-or-sync the auto-generated link for this post. Shared by the
     * save_post and template_redirect paths.
     *
     * @param  integer $postId Post ID to reconcile.
     * @param  WP_Post $post   Post object to reconcile.
     * @return void
     */
    private function reconcile(int $postId, WP_Post $post): void
    {
        if (!$this->isEnabledFor($post->post_type)) {
            return;
        }

        $permalink = (string) get_permalink($post);
        if ($permalink === '') {
            return;
        }

        $payload       = [
            'url'         => $permalink,
            'name'        => $post->post_title !== '' ? $post->post_title : __('Untitled', 'pretty-link-pro'),
            'description' => (string) $post->post_excerpt,
        ];
        $requestedSlug = $this->requestedSlug($postId);

        $linkId = $this->findExistingLink($postId, $permalink);
        if ($linkId !== null) {
            $this->syncExisting($linkId, $payload, $requestedSlug);
        } else {
            if ($requestedSlug !== '') {
                $payload['slug'] = $requestedSlug;
            }
            $created = $this->links->create($payload);
            if (!isset($created['id'])) {
                return;
            }
            $linkId = (int) $created['id'];
            $this->attachLinkToPost($postId, $linkId);
        }

        $this->reconcileCategory($postId, $linkId, (string) $post->post_type);
        update_post_meta(
            $postId,
            self::POST_META_APPLIED_SIG,
            $this->configSignatureFor((string) $post->post_type)
        );
    }

    /**
     * V3 post meta key holding the pretty-link id for this post. Everything
     * that resolves "this post → its pretty link" reads this key.
     *
     * @param  integer $postId Post ID to attach the link to.
     * @param  integer $linkId Pretty link ID to store.
     * @return void
     */
    private function attachLinkToPost(int $postId, int $linkId): void
    {
        update_post_meta($postId, self::POST_META_LINK_ID, $linkId);
    }

    /**
     * Returns the user-requested slug stored in this post's PostOptions blob.
     *
     * @param  integer $postId Post ID to read the requested slug from.
     * @return string
     */
    private function requestedSlug(int $postId): string
    {
        // PostOptions::save already trimmed unsafe chars; return as-is.
        // Don't re-apply `sanitize_title` — it strips `/` and would
        // mangle prefix-baked slugs like `go/abcd`.
        return (string) PostOptions::get($postId)['requested_slug'];
    }

    /**
     * Per-CPT row from `autocreate`. Shape:
     *   ['enabled' => 0|1, 'category' => int, 'socbtns' => 0|1]
     *
     * @param  string $postType Post type slug to look up.
     * @return array<string, mixed>
     */
    private function rowFor(string $postType): array
    {
        $rows = $this->options->getPro('autocreate', []);
        if (!is_array($rows) || !isset($rows[$postType]) || !is_array($rows[$postType])) {
            return [];
        }
        return $rows[$postType];
    }

    /**
     * Whether auto-create is enabled for the given post type.
     *
     * @param  string $postType Post type slug to check.
     * @return boolean
     */
    private function isEnabledFor(string $postType): bool
    {
        if (!$this->options->getPro('autocreate_enable', false)) {
            return false;
        }
        return !empty($this->rowFor($postType)['enabled']);
    }

    /**
     * Reconcile the auto-assigned category for this post's link: if the
     * configured category has changed (including being unset), remove the
     * previously-applied assignment and insert the new one. Records the
     * last-applied term id in post meta so we only ever remove our own
     * assignment — manually-added categories on the link are left alone.
     *
     * @param  integer $postId   Post ID being reconciled.
     * @param  integer $linkId   Pretty link ID to assign the category to.
     * @param  string  $postType Post type slug whose configured category applies.
     * @return void
     */
    private function reconcileCategory(int $postId, int $linkId, string $postType): void
    {
        $desired  = (int) ($this->rowFor($postType)['category'] ?? 0);
        $previous = (int) get_post_meta($postId, self::POST_META_APPLIED_CAT, true);

        if ($desired === $previous) {
            return;
        }

        global $wpdb;
        $table = $wpdb->prefix . 'prli_link_terms';
        if ($previous > 0) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            $wpdb->query(
                $wpdb->prepare(
                    "DELETE FROM {$table} WHERE link_id = %d AND term_id = %d AND taxonomy = 'category'",
                    $linkId,
                    $previous
                )
            );
        }
        if ($desired > 0) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            $wpdb->query(
                $wpdb->prepare(
                    "INSERT IGNORE INTO {$table} (link_id, term_id, taxonomy) VALUES (%d, %d, 'category')",
                    $linkId,
                    $desired
                )
            );
        }

        if ($desired > 0) {
            update_post_meta($postId, self::POST_META_APPLIED_CAT, $desired);
        } else {
            delete_post_meta($postId, self::POST_META_APPLIED_CAT);
        }
    }

    /**
     * Fingerprint of the inputs that affect auto-create output for this
     * post type. Changes to the fingerprint trigger a re-run on next save
     * or view; matches short-circuit the reconcile path.
     *
     * @param  string $postType Post type slug to fingerprint.
     * @return string
     */
    private function configSignatureFor(string $postType): string
    {
        $row = $this->rowFor($postType);
        return md5(wp_json_encode([
            'enabled'  => !empty($row['enabled']) ? 1 : 0,
            'category' => (int) ($row['category'] ?? 0),
        ]));
    }

    /**
     * Forward pointer lookup, then URL fallback. Priority:
     *   1. `_pretty-link` post meta  — O(1) lookup via wp_postmeta index.
     *   2. Any live link whose target URL matches the post permalink —
     *      catches v3 auto-links whose post meta somehow got dropped, and
     *      manual links that happen to point at this post. On adopt we
     *      write the post meta so future lookups are O(1).
     *
     * @param  integer $postId    Post ID to resolve the link for.
     * @param  string  $permalink Post permalink used for the URL fallback.
     * @return integer|null
     */
    private function findExistingLink(int $postId, string $permalink): ?int
    {
        $linkId = (int) get_post_meta($postId, self::POST_META_LINK_ID, true);
        if ($linkId > 0) {
            $existing = $this->links->find($linkId);
            if ($existing !== null) {
                return $linkId;
            }
            // Stale pointer — the link was deleted out from under us. Clear
            // and fall through so we try the URL-based adoption path.
            delete_post_meta($postId, self::POST_META_LINK_ID);
        }

        $byUrl = $this->links->findByUrl($permalink);
        if ($byUrl === null) {
            return null;
        }
        $adoptedId = (int) $byUrl['id'];
        $this->attachLinkToPost($postId, $adoptedId);
        return $adoptedId;
    }

    /**
     * Updates an existing link in place when its derived fields drift.
     *
     * @param  integer                                               $linkId        Existing pretty link ID to update.
     * @param  array{url: string, name: string, description: string} $payload       Derived link fields from the post.
     * @param  string                                                $requestedSlug User-requested slug, empty when none.
     * @return void
     */
    private function syncExisting(int $linkId, array $payload, string $requestedSlug): void
    {
        $link = $this->links->find($linkId);
        if ($link === null) {
            return;
        }

        $diff = [];
        foreach ($payload as $key => $value) {
            if ((string) ($link[$key] ?? '') !== $value) {
                $diff[$key] = $value;
            }
        }

        // Apply a newly typed (or cleared + retyped) requested slug. We don't
        // revert to a generated slug just because the field is empty — that
        // would thrash the URL on every save after the initial auto-gen.
        if ($requestedSlug !== '' && $requestedSlug !== (string) ($link['slug'] ?? '')) {
            $diff['slug'] = $requestedSlug;
        }

        if ($diff !== []) {
            $this->links->update($linkId, $diff);
        }
    }
}
