<?php
/**
 * Title/tag tokenizer that produces a per-lot keyword bag.
 *
 * Pipeline: lowercase → Unicode normalize → split on non-alpha →
 * drop tokens shorter than 3 chars → drop pure-digit tokens → drop
 * stop-words. The leftover bag is what gets tallied per user as
 * dimension=keyword.
 *
 * Pre-extraction is cached into wp_af_lot_keywords on save_post_lot
 * (and on the BidSpirit importer's after-insert action) so view-time
 * lookups are a single indexed read.
 */

if (!defined('ABSPATH')) exit;

require_once __DIR__ . '/stop-words.php';

class AF_Keyword_Extractor {

    const TABLE = 'af_stats_lot_keywords';

    /**
     * Tokenize a free-text string into a unique bag of keyword slugs.
     * Pure function — easy to unit-test.
     *
     * Three pre-passes catch firearm/coin tokens that the naive split-on-
     * non-alpha would lose:
     *
     *   1. Hyphenated/x-separated calibers: ".45-70", "7.62x54r", ".30-06",
     *      "8x57". Without this, the tokenizer would split each into pure-
     *      digit fragments and drop them all, losing the actual model
     *      identifier ("45-70 Government").
     *   2. Caliber-with-suffix: "9mm", ".22lr", ".380acp" — the suffix
     *      letters keep these alive through the digit drop, but only because
     *      they happen to have letters. Pre-extracted explicitly so the rule
     *      is intentional, not accidental.
     *   3. 4-digit years 1700–2099: "1873", "1903", "1971" — pure digits, so
     *      the standard pass would drop them, but they're often the model
     *      year of the lot ("1873 Springfield", "1911 Colt") and are real
     *      buyer-discriminating signal.
     *
     * Pre-passes mask the matched substrings out of the source before the
     * standard split runs, so we don't double-count fragments.
     */
    public static function tokenize($text) {
        if (empty($text)) return array();

        $text = (string) $text;
        $text = wp_strip_all_tags($text);
        $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $text = mb_strtolower($text, 'UTF-8');
        if (function_exists('iconv')) {
            $ascii = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
            if ($ascii !== false) $text = $ascii;
        }

        $stop = array_flip(af_stats_default_stop_words());
        $out  = array();

        // --- pre-pass 1: hyphenated / x-separated caliber patterns -------
        if (preg_match_all('/\b(\d+(?:\.\d+)?)[-x](\d+[a-z]*)\b/i', $text, $m, PREG_OFFSET_CAPTURE)) {
            foreach ($m[0] as $hit) {
                $tok = $hit[0];
                $slug = preg_replace('/[^a-z0-9]+/', '-', $tok);
                $slug = trim($slug, '-');
                if ($slug !== '' && strlen($slug) >= 3 && !isset($stop[$slug])) {
                    $out[$slug] = true;
                }
            }
            $text = preg_replace('/\b\d+(?:\.\d+)?[-x]\d+[a-z]*\b/i', ' ', $text);
        }

        // --- pre-pass 2: caliber-with-suffix tokens (9mm, .22lr, .380acp) ---
        if (preg_match_all('/\b(\d+(?:\.\d+)?[a-z]+)\b/i', $text, $m)) {
            foreach ($m[0] as $tok) {
                $slug = preg_replace('/[^a-z0-9]+/', '', $tok);
                if ($slug !== '' && strlen($slug) >= 3 && !isset($stop[$slug])) {
                    $out[$slug] = true;
                }
            }
        }

        // --- pre-pass 3: 4-digit year tokens (1700-2099) -----------------
        if (preg_match_all('/\b(1[7-9]\d{2}|20\d{2})\b/', $text, $m)) {
            foreach ($m[0] as $year) {
                if (!isset($stop[$year])) $out[$year] = true;
            }
        }

        // --- standard pass: split on anything that isn't a-z 0-9 ----------
        $tokens = preg_split('/[^a-z0-9]+/', $text, -1, PREG_SPLIT_NO_EMPTY);
        if ($tokens) {
            foreach ($tokens as $tok) {
                if (strlen($tok) < 3) continue;
                // Pure-digit tokens drop EXCEPT 4-digit years 1700-2099.
                if (ctype_digit($tok)) {
                    if (strlen($tok) !== 4) continue;
                    $n = (int) $tok;
                    if ($n < 1700 || $n > 2099) continue;
                }
                if (isset($stop[$tok])) continue;
                $out[$tok] = true;
            }
        }

        return array_keys($out);
    }

    /**
     * Pull every relevant text source off a lot post and tokenize.
     * Returns array of [keyword => source].
     *
     * Sources merged into the same bag:
     *   - title (highest signal)
     *   - lot_tags from wp_bp_meta_data.tags (BidSpirit free-form tags)
     *   - lot_author from wp_bp_meta_data.author (maker/artist)
     */
    public static function extract_for_post($post_id) {
        $post_id = (int) $post_id;
        if ($post_id <= 0) return array();

        $post = get_post($post_id);
        if (!$post || $post->post_type !== 'lot') return array();

        $bag = array();

        foreach (self::tokenize($post->post_title) as $kw) {
            if (!isset($bag[$kw])) $bag[$kw] = 'title';
        }

        global $wpdb;
        $bp = $wpdb->prefix . 'bp_meta_data';
        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT tags, author FROM $bp WHERE postId = %d LIMIT 1",
            $post_id
        ));

        if ($row) {
            if (!empty($row->tags)) {
                foreach (self::tokenize($row->tags) as $kw) {
                    if (!isset($bag[$kw])) $bag[$kw] = 'tag';
                }
            }
            if (!empty($row->author)) {
                foreach (self::tokenize($row->author) as $kw) {
                    if (!isset($bag[$kw])) $bag[$kw] = 'author';
                }
            }
        }

        return $bag;
    }

    /**
     * Cache the bag for a post into wp_af_stats_lot_keywords.
     * Idempotent: clears existing rows then inserts the current bag.
     */
    public static function cache_for_post($post_id) {
        global $wpdb;

        if (!self::table_exists()) return false;

        $bag = self::extract_for_post($post_id);
        $table = $wpdb->prefix . self::TABLE;

        $wpdb->delete($table, array('post_id' => $post_id), array('%d'));

        if (empty($bag)) return true;

        foreach ($bag as $kw => $source) {
            $wpdb->insert($table, array(
                'post_id' => $post_id,
                'keyword' => $kw,
                'source'  => $source,
                'weight'  => 1,
            ), array('%d', '%s', '%s', '%d'));
        }

        return true;
    }

    /**
     * Read cached keywords for a post. Returns ['keyword' => source].
     * Falls through to live extraction if the cache row is missing.
     */
    public static function get_for_post($post_id) {
        global $wpdb;
        if (!self::table_exists()) return self::extract_for_post($post_id);

        $table = $wpdb->prefix . self::TABLE;
        $rows = $wpdb->get_results($wpdb->prepare(
            "SELECT keyword, source FROM $table WHERE post_id = %d",
            $post_id
        ));

        if (empty($rows)) {
            self::cache_for_post($post_id);
            $rows = $wpdb->get_results($wpdb->prepare(
                "SELECT keyword, source FROM $table WHERE post_id = %d",
                $post_id
            ));
        }

        $out = array();
        foreach ($rows as $r) {
            $out[$r->keyword] = $r->source;
        }
        return $out;
    }

    private static function table_exists() {
        static $exists = null;
        if ($exists !== null) return $exists;

        global $wpdb;
        $table = $wpdb->prefix . self::TABLE;
        $exists = ($wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $table)) === $table);
        return $exists;
    }

    /**
     * save_post_lot hook — re-cache when a lot is created/updated.
     * Skips revisions and autosaves; defers to a shutdown action so the
     * BidSpirit importer's bulk inserts don't pay the per-row cost on the
     * critical path.
     */
    public static function on_save_post($post_id, $post = null, $update = false) {
        if (!$post_id || (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE)) return;
        if (wp_is_post_revision($post_id)) return;
        if (!$post) $post = get_post($post_id);
        if (!$post || $post->post_type !== 'lot') return;

        // Defer to shutdown so save_post handlers chain cheaply.
        $deferred = wp_cache_get('af_kw_pending', 'af_stats') ?: array();
        $deferred[$post_id] = true;
        wp_cache_set('af_kw_pending', $deferred, 'af_stats', 60);

        if (!has_action('shutdown', array(__CLASS__, 'flush_pending'))) {
            add_action('shutdown', array(__CLASS__, 'flush_pending'));
        }
    }

    public static function flush_pending() {
        $deferred = wp_cache_get('af_kw_pending', 'af_stats') ?: array();
        if (empty($deferred)) return;
        wp_cache_delete('af_kw_pending', 'af_stats');

        foreach (array_keys($deferred) as $post_id) {
            self::cache_for_post((int) $post_id);
        }
    }
}
