<?php

if ( ! defined( 'ABSPATH' ) ) {

    exit;

}



/**

 * RSS 使用済み履歴 + 予約（reservation）管理

 *

 * フィードごとに最新 HISTORY_PER_FEED 件の guid を FIFO で保持する。

 * 並行採用レース対策として「予約」機構を併設:

 *   - pick_unused() で候補を選んだ瞬間に reserved に書き込む

 *   - 投稿成功時に mark_used() が reserved を消して used_guids に移す

 *   - 失敗時は RESERVATION_TTL_SECONDS で自然失効

 *

 * aiap_rss_state option を独占管理する（直接 get_option を呼ばないこと）。

 *

 * 構造:

 *   array(

 *       'feed_xxx' => array(

 *           'used_guids' => array( 'guid1', 'guid2', ... ),

 *           'reserved'   => array(

 *               'guid_A' => 1715766600,  // 失効 UNIX 秒

 *               'guid_B' => 1715766900,

 *           ),

 *       ),

 *       ...

 *   )

 */

class AIAP_RSS_State {



    const OPTION_KEY        = 'aiap_rss_state';

    const HISTORY_PER_FEED  = 500;

    const RESERVATION_TTL_SECONDS = 600;  // 10 分（cron + 手動 step 全体の最大想定時間）

    const LOCK_KEY                = 'rss_reserve';



    /**

     * 削除対象のトラッキング系クエリパラメータ（前方一致）

     *

     * 同一記事が `?utm_source=rss`/`?fbclid=...`/`#anchor` 等で別 GUID として配信されたとき、

     * 重複検出 (used_guids) や予約 (reserved) のキー一致を取れなくする原因になる。

     * normalize_guid() で URL タイプの GUID を正規化する際に使用する。

     */

    const TRACKING_PARAM_PREFIXES = array( 'utm_', 'fbclid', 'gclid', 'msclkid', 'mc_eid', 'mc_cid', 'yclid', 'twclid', 'ref_', 'igshid' );



    /**

     * GUID を正規化する（URL 形式の場合のみ）

     *

     * Bug fix: 同一記事を「末尾スラッシュ有無」「UTM パラメータ有無」「フラグメント有無」で

     * 別 GUID として誤って二度採用する事故を防ぐ。is_used / is_reserved / reserve / mark_used /

     * release_reservation すべての入口でこの関数を通すことで、保存/参照ともに正規形を使う。

     *

     * 正規化対象:

     *   - スキーム/ホストを小文字化 (パスは大小区別を保持)

     *   - 末尾スラッシュ削除 (パスが "/" 単独の場合は保持)

     *   - フラグメント (#...) 削除

     *   - トラッキングパラメータ (utm_*, fbclid, gclid, ...) を除去し、空クエリは "?" ごと削除

     *

     * URL 形式でない GUID (tag:foo,2024:bar, uuid:..., 純粋な ID 文字列等) は trim のみ。

     *

     * @param string $guid 生の GUID

     * @return string 正規化済み GUID

     */

    public static function normalize_guid( $guid ) {

        $guid = trim( (string) $guid );

        if ( $guid === '' ) {

            return '';

        }

        if ( strpos( $guid, 'http://' ) !== 0 && strpos( $guid, 'https://' ) !== 0 ) {

            return $guid;

        }



        $parts = wp_parse_url( $guid );

        if ( ! is_array( $parts ) || empty( $parts['host'] ) ) {

            return $guid;

        }



        $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : 'http';

        $host   = strtolower( $parts['host'] );

        $port   = isset( $parts['port'] ) ? ':' . (int) $parts['port'] : '';

        $path   = isset( $parts['path'] ) ? $parts['path'] : '';



        if ( $path !== '' && $path !== '/' && substr( $path, -1 ) === '/' ) {

            $path = rtrim( $path, '/' );

        }



        $query_str = '';

        if ( ! empty( $parts['query'] ) ) {

            $pairs = explode( '&', $parts['query'] );

            $kept  = array();

            foreach ( $pairs as $pair ) {

                if ( $pair === '' ) {

                    continue;

                }

                $key_lower = strtolower( strtok( $pair, '=' ) );

                $drop      = false;

                foreach ( self::TRACKING_PARAM_PREFIXES as $prefix ) {

                    if ( strpos( $key_lower, $prefix ) === 0 ) {

                        $drop = true;

                        break;

                    }

                }

                if ( ! $drop ) {

                    $kept[] = $pair;

                }

            }

            if ( ! empty( $kept ) ) {

                sort( $kept );

                $query_str = '?' . implode( '&', $kept );

            }

        }



        return $scheme . '://' . $host . $port . $path . $query_str;

    }



    /**

     * 全状態を取得

     *

     * @return array

     */

    public static function get_all() {

        $state = get_option( self::OPTION_KEY, array() );

        return is_array( $state ) ? $state : array();

    }



    /**

     * 全状態を保存（autoload=no）

     *

     * @param array $state

     */

    private static function save_all( array $state ) {

        update_option( self::OPTION_KEY, $state, false );

    }



    /**

     * 指定フィードで guid が使用済みか

     *

     * @param string $feed_id

     * @param string $guid

     * @return bool

     */

    public static function is_used( $feed_id, $guid ) {

        if ( $feed_id === '' || $guid === '' ) {

            return false;

        }

        $guid = self::normalize_guid( $guid );

        if ( $guid === '' ) {

            return false;

        }

        $state = self::get_all();

        if ( ! isset( $state[ $feed_id ]['used_guids'] ) || ! is_array( $state[ $feed_id ]['used_guids'] ) ) {

            return false;

        }

        return in_array( $guid, $state[ $feed_id ]['used_guids'], true );

    }



    /**

     * 指定フィードで guid が現在予約中か（TTL 内）

     *

     * 失効した予約は false を返すだけで、option 書き込みはしない（lazy prune は

     * 次回 reserve() 呼び出し時にまとめて行う）。

     *

     * @param string $feed_id

     * @param string $guid

     * @return bool

     */

    public static function is_reserved( $feed_id, $guid ) {

        if ( $feed_id === '' || $guid === '' ) {

            return false;

        }

        $guid = self::normalize_guid( $guid );

        if ( $guid === '' ) {

            return false;

        }

        $state = self::get_all();

        if ( ! isset( $state[ $feed_id ]['reserved'] ) || ! is_array( $state[ $feed_id ]['reserved'] ) ) {

            return false;

        }

        if ( ! isset( $state[ $feed_id ]['reserved'][ $guid ] ) ) {

            return false;

        }

        return (int) $state[ $feed_id ]['reserved'][ $guid ] > time();

    }



    /**

     * guid 配列を指定 TTL で予約する（AIAP_Lock で保護）

     *

     * - used_guids に既存の guid は予約しない（冪等）

     * - 失効済み reserved エントリは prune する

     * - ロック取得失敗時は warning ログ + そのまま続行（既存挙動と整合）

     *

     * @param string $feed_id

     * @param array  $guid_list

     * @param int    $ttl_seconds

     */

    public static function reserve( $feed_id, array $guid_list, $ttl_seconds = self::RESERVATION_TTL_SECONDS ) {

        if ( $feed_id === '' || empty( $guid_list ) ) {

            return;

        }

        $ttl_seconds = max( 1, (int) $ttl_seconds );



        $lock_token = null;
        if ( class_exists( 'AIAP_Lock' ) ) {
            // TTL=10s, 10 回 × 100ms = 最大 1 秒待機（reserve は短時間操作）
            $lock_token = AIAP_Lock::acquire_with_retry( self::LOCK_KEY, 10, 10, 100000 );
            if ( $lock_token === false ) {
                return;
            }
        }


        try {

            $state = self::get_all();

            if ( ! isset( $state[ $feed_id ] ) || ! is_array( $state[ $feed_id ] ) ) {

                $state[ $feed_id ] = array( 'used_guids' => array(), 'reserved' => array() );

            }

            if ( ! isset( $state[ $feed_id ]['used_guids'] ) || ! is_array( $state[ $feed_id ]['used_guids'] ) ) {

                $state[ $feed_id ]['used_guids'] = array();

            }

            if ( ! isset( $state[ $feed_id ]['reserved'] ) || ! is_array( $state[ $feed_id ]['reserved'] ) ) {

                $state[ $feed_id ]['reserved'] = array();

            }



            // 失効済み prune

            $now = time();

            foreach ( $state[ $feed_id ]['reserved'] as $g => $exp ) {

                if ( (int) $exp <= $now ) {

                    unset( $state[ $feed_id ]['reserved'][ $g ] );

                }

            }



            // 新規予約

            $expires_at = $now + $ttl_seconds;

            $used_map   = array_flip( $state[ $feed_id ]['used_guids'] );

            foreach ( $guid_list as $guid ) {

                $guid = self::normalize_guid( $guid );

                if ( $guid === '' ) {

                    continue;

                }

                if ( isset( $used_map[ $guid ] ) ) {

                    continue;  // 既に使用済みなら予約不要

                }

                $state[ $feed_id ]['reserved'][ $guid ] = $expires_at;

            }



            self::save_all( $state );

        } finally {

            if ( $lock_token !== null && class_exists( 'AIAP_Lock' ) ) {
                AIAP_Lock::release( self::LOCK_KEY, $lock_token );
            }
        }
    }



    /**

     * 単一 guid の予約を解除する

     *

     * reserve() と同じ LOCK_KEY でロック保護し、option の read-modify-write を

     * atomic にする。旧実装はロックを取らずに get_all → unset → save_all を

     * 実行していたため、並行する reserve() の書き込みを上書きする可能性があった。

     *

     * @param string $feed_id

     * @param string $guid

     */

    public static function release_reservation( $feed_id, $guid ) {

        if ( $feed_id === '' || $guid === '' ) {

            return;

        }

        $guid = self::normalize_guid( $guid );

        if ( $guid === '' ) {

            return;

        }

        $lock_token = null;
        if ( class_exists( 'AIAP_Lock' ) ) {
            $lock_token = AIAP_Lock::acquire_with_retry( self::LOCK_KEY, 10, 10, 100000 );
            if ( $lock_token === false ) {
                return;
            }
        }
        try {
            self::release_reservation_unlocked( $feed_id, $guid );
        } finally {
            if ( $lock_token !== null && class_exists( 'AIAP_Lock' ) ) {
                AIAP_Lock::release( self::LOCK_KEY, $lock_token );
            }
        }
    }



    /**

     * release_reservation のロック取得無し版 (既にロックを保持している経路から呼ぶ用)

     *

     * @param string $feed_id

     * @param string $guid

     */

    private static function release_reservation_unlocked( $feed_id, $guid ) {

        $state = self::get_all();

        if ( ! isset( $state[ $feed_id ]['reserved'][ $guid ] ) ) {

            return;

        }

        unset( $state[ $feed_id ]['reserved'][ $guid ] );

        if ( empty( $state[ $feed_id ]['reserved'] ) ) {

            unset( $state[ $feed_id ]['reserved'] );

        }

        self::save_all( $state );

    }



    /**

     * guid を使用済みとしてマーク（FIFO 500 件、冪等）

     *

     * LOCK_KEY ロックで保護し、used_guids 追加 + reserved 解除を atomic に行う。

     * 旧実装は save_all を 2 回呼んでおり、並行する reserve() との競合で

     * used_guids の追加が失われる可能性があった。

     *

     * @param string $feed_id

     * @param string $guid

     * @param int    $post_id   将来の拡張用（現状未使用、postmeta 側で post→guid を辿るため）

     */

    public static function mark_used( $feed_id, $guid, $post_id = 0 ) {

        if ( $feed_id === '' || $guid === '' ) {

            return;

        }

        $guid = self::normalize_guid( $guid );

        if ( $guid === '' ) {

            return;

        }

        unset( $post_id ); // 将来拡張のための予約引数



        $lock_token = null;
        if ( class_exists( 'AIAP_Lock' ) ) {
            $lock_token = AIAP_Lock::acquire_with_retry( self::LOCK_KEY, 10, 10, 100000 );
            if ( $lock_token === false ) {
                return;
            }
        }
        try {

            $state = self::get_all();

            if ( ! isset( $state[ $feed_id ] ) || ! is_array( $state[ $feed_id ] ) ) {

                $state[ $feed_id ] = array( 'used_guids' => array() );

            }

            if ( ! isset( $state[ $feed_id ]['used_guids'] ) || ! is_array( $state[ $feed_id ]['used_guids'] ) ) {

                $state[ $feed_id ]['used_guids'] = array();

            }

            // 冪等性

            if ( ! in_array( $guid, $state[ $feed_id ]['used_guids'], true ) ) {

                $state[ $feed_id ]['used_guids'][] = $guid;

                // FIFO トリム

                $count = count( $state[ $feed_id ]['used_guids'] );

                if ( $count > self::HISTORY_PER_FEED ) {

                    $state[ $feed_id ]['used_guids'] = array_slice(

                        $state[ $feed_id ]['used_guids'],

                        $count - self::HISTORY_PER_FEED

                    );

                }

            }

            // 予約済みだった場合は同じトランザクション内で解除する

            if ( isset( $state[ $feed_id ]['reserved'][ $guid ] ) ) {

                unset( $state[ $feed_id ]['reserved'][ $guid ] );

                if ( empty( $state[ $feed_id ]['reserved'] ) ) {

                    unset( $state[ $feed_id ]['reserved'] );

                }

            }

            self::save_all( $state );

        } finally {

            if ( $lock_token !== null && class_exists( 'AIAP_Lock' ) ) {
                AIAP_Lock::release( self::LOCK_KEY, $lock_token );
            }
        }
    }



    /**

     * 指定フィードの履歴をすべて削除（フィード削除時、または UI からの履歴クリア時に呼ぶ）

     *

     * @param string $feed_id

     * @return int 削除した guid 件数（0 ならエントリ無し）

     */

    public static function purge_feed( $feed_id ) {
        if ( $feed_id === '' ) {
            return 0;
        }
        $lock_token = null;
        if ( class_exists( 'AIAP_Lock' ) ) {
            $lock_token = AIAP_Lock::acquire_with_retry( self::LOCK_KEY, 10, 10, 100000 );
            if ( $lock_token === false ) {
                return 0;
            }
        }
        try {
            $state = self::get_all();
            if ( ! isset( $state[ $feed_id ]['used_guids'] ) ) {
                return 0;
            }
            $count = count( $state[ $feed_id ]['used_guids'] );
            unset( $state[ $feed_id ] );
            self::save_all( $state );
            return $count;
        } finally {
            if ( $lock_token !== null && class_exists( 'AIAP_Lock' ) ) {
                AIAP_Lock::release( self::LOCK_KEY, $lock_token );
            }
        }
    }


    /**

     * 指定フィードの使用済み件数を返す（UI 表示用）

     *

     * @param string $feed_id

     * @return int

     */

    public static function count_used( $feed_id ) {

        if ( $feed_id === '' ) {

            return 0;

        }

        $state = self::get_all();

        return isset( $state[ $feed_id ]['used_guids'] ) && is_array( $state[ $feed_id ]['used_guids'] )

            ? count( $state[ $feed_id ]['used_guids'] )

            : 0;

    }

}

