<?php
/** История решений Complianz. Не запускает аналитику и не меняет цели. */
if (!class_exists('PB_Cookie_Consent_History')) {
class PB_Cookie_Consent_History {
    private static $before = null;
    private static $ready = false;
    public static function boot() {
        add_action('init', [__CLASS__, 'types']);
        add_action('cmplz_store_consent', [__CLASS__, 'before'], 1, 3);
        add_action('cmplz_store_consent', [__CLASS__, 'record'], 999, 3);
        add_action('admin_menu', [__CLASS__, 'menu']);
        add_action('wp_footer', [__CLASS__, 'context'], 100);
        add_action('admin_post_pb_cookie_export', [__CLASS__, 'export']);
    }
    public static function types() {
        if (self::$ready) return;
        self::$ready = true;
        foreach (['pb_cookie_event', 'pb_cookie_version'] as $type) register_post_type($type, ['public'=>false, 'show_ui'=>false, 'show_in_rest'=>false, 'rewrite'=>false, 'query_var'=>false, 'supports'=>[]]);
    }
    private static function visitor() { return isset($_COOKIE['cmplz_id']) ? absint($_COOKIE['cmplz_id']) : 0; }
    public static function before($categories, $services, $type) {
        global $wpdb;
        $id=self::visitor();
        self::$before=$id ? $wpdb->get_row($wpdb->prepare("SELECT * FROM {$wpdb->prefix}cmplz_statistics WHERE ID=%d", $id), ARRAY_A) : null;
    }
    private static function banner() {
        global $wpdb;
        return $wpdb->get_row("SELECT * FROM {$wpdb->prefix}cmplz_cookiebanners ORDER BY `default` DESC, ID ASC LIMIT 1", ARRAY_A);
    }
    private static function snapshot($banner) {
        $keys=['ID','banner_version','header','accept','dismiss','view_preferences','save_preferences','message_optin','message_optout','category_functional','category_stats','category_prefs','category_all','functional_text','statistics_text','statistics_text_anonymous','preferences_text','marketing_text','use_categories','disable_cookiebanner','dismiss_on_scroll','dismiss_on_timeout','legal_documents'];
        $out=['schema'=>1,'site'=>home_url('/'),'locale'=>get_locale(),'banner'=>[],'documents'=>[]];
        foreach ($keys as $key) if (isset($banner[$key])) $out['banner'][$key]=maybe_unserialize($banner[$key]);
        foreach (['politika-konfidenczialnosti','soglasie-na-obrabotku-dannyh'] as $slug) {
            $page=get_page_by_path($slug);
            if ($page) $out['documents'][]=['id'=>$page->ID,'url'=>get_permalink($page),'title'=>$page->post_title,'modified_gmt'=>$page->post_modified_gmt,'content'=>$page->post_content];
        }
        $json=wp_json_encode($out, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
        $hash=hash('sha256',$json);
        $existing=get_posts(['post_type'=>'pb_cookie_version','post_status'=>'private','name'=>$hash,'numberposts'=>1]);
        $id=$existing ? $existing[0]->ID : wp_insert_post(wp_slash(['post_type'=>'pb_cookie_version','post_status'=>'private','post_title'=>'Баннер '.$banner['banner_version'].' / '.substr($hash,0,12),'post_name'=>$hash,'post_content'=>$json]), true);
        if (!is_wp_error($id) && $id) { add_post_meta($id,'pb_banner_version',(int)$banner['banner_version'],true); }
        return ['id'=>is_wp_error($id)?0:(int)$id,'sha256'=>$hash,'banner_version'=>(int)$banner['banner_version']];
    }
    public static function context() {
        $banner=self::banner(); if(!$banner)return;
        self::types(); $snapshot=self::snapshot($banner);
        $config=['banner'=>(int)$banner['ID'],'version'=>$snapshot['banner_version'],'hash'=>$snapshot['sha256'],'signature'=>hash_hmac('sha256',$snapshot['sha256'],wp_salt('auth'))];
        echo '<script data-cmplz-ignore data-no-optimize="1">(function(c){document.addEventListener("click",function(e){var b=e.target.closest(".cmplz-accept,.cmplz-deny,.cmplz-save-preferences,.cmplz-close");if(!b)return;var n=window.complianz||{};var value={banner:Number(n.user_banner_id||c.banner),version:Number(n.banner_version||0),hash:c.hash,signature:c.signature,action:b.classList.contains("cmplz-accept")?"accept":b.classList.contains("cmplz-deny")?"deny":b.classList.contains("cmplz-save-preferences")?"save":"close"};document.cookie="pb_cc_context="+encodeURIComponent(JSON.stringify(value))+"; Path=/; Max-Age=300; SameSite=Lax; Secure";},true);})('.wp_json_encode($config).');</script>';
    }
    public static function record($categories, $services, $type) {
        global $wpdb;
        self::types();
        $id=self::visitor();
        if (!$id) $id=(int)$wpdb->insert_id;
        if (!$id) return;
        $native=$wpdb->get_row($wpdb->prepare("SELECT * FROM {$wpdb->prefix}cmplz_statistics WHERE ID=%d",$id),ARRAY_A);
        $banner=self::banner();
        if (!$native || !$banner) return;
        $categories=array_values(array_intersect((array)$categories,['functional','preferences','statistics','marketing','no_choice','no_warning','do_not_track']));
        $allowed=in_array('statistics',$categories,true);
        $prior=self::$before;
        $event=in_array('no_choice',$categories,true)?'no_choice':(in_array('no_warning',$categories,true)?'no_warning':($allowed?'analytics_allowed':(($prior && !empty($prior['statistics']))?'analytics_withdrawn':'analytics_denied')));
        $snapshot=self::snapshot($banner);
        $basis='Серверная конфигурация на момент записи; версия из браузера не передана';
        $context=isset($_COOKIE['pb_cc_context'])?json_decode(rawurldecode(wp_unslash($_COOKIE['pb_cc_context'])),true):null;
        $action='not_reported';
        if(is_array($context) && isset($context['hash'],$context['signature'],$context['version'])) {
            $hash=preg_replace('/[^a-f0-9]/','',(string)$context['hash']);
            if(strlen($hash)===64 && hash_equals(hash_hmac('sha256',$hash,wp_salt('auth')),(string)$context['signature'])) {
                $saved=get_posts(['post_type'=>'pb_cookie_version','post_status'=>'private','name'=>$hash,'numberposts'=>1]);
                if($saved) {
                    $data=json_decode($saved[0]->post_content,true);
                    if((int)$data['banner']['banner_version']===(int)$context['version']) {
                        $snapshot=['id'=>$saved[0]->ID,'sha256'=>$hash,'banner_version'=>(int)$context['version']];
                        $basis='Архивный снимок страницы; версия Complianz передана браузером при действии';
                        $action=in_array($context['action']??'', ['accept','deny','save','close'],true)?$context['action']:'unknown';
                    } else {
                        $snapshot=['id'=>0,'sha256'=>'','banner_version'=>(int)$context['version']];
                        $basis='Версия в браузере отличается от снимка страницы; соответствие условий не подтверждено';
                    }
                }
            }
        }
        // Исправляем только идентификатор баннера у текущего нового обновления. Старые записи не переписываем.
        if (empty($native['cookiebanner_id'])) $wpdb->update($wpdb->prefix.'cmplz_statistics',['cookiebanner_id'=>(int)$banner['ID']],['ID'=>$id],['%d'],['%d']);
        $entry=['schema'=>1,'recorded_at_utc'=>gmdate('Y-m-d\TH:i:s\Z'),'visitor_id'=>$id,'event'=>$event,'categories'=>$categories,'previous_categories'=>[],'consent_type'=>sanitize_key($type),'region'=>$native['region'],'banner_id'=>(int)$banner['ID'],'banner_version'=>$snapshot['banner_version'],'snapshot_id'=>$snapshot['id'],'snapshot_sha256'=>$snapshot['sha256'],'policy_cookie'=>isset($_COOKIE['cmplz_policy_id'])?sanitize_text_field(wp_unslash($_COOKIE['cmplz_policy_id'])):'','source'=>'Complianz cmplz_store_consent','action'=>$action,'version_basis'=>$basis];
        if ($prior) foreach (['functional','preferences','statistics','marketing'] as $cat) if (!empty($prior[$cat])) $entry['previous_categories'][]=$cat;
        $entry['integrity_hmac']=hash_hmac('sha256',wp_json_encode($entry),wp_salt('auth'));
        $post=wp_insert_post(wp_slash(['post_type'=>'pb_cookie_event','post_status'=>'private','post_title'=>'ID '.$id.' / '.$event,'post_content'=>wp_json_encode($entry,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)]),true);
        if (!is_wp_error($post)) add_post_meta($post,'pb_visitor_id',$id,true);
    }
    public static function menu() { add_management_page('История согласий cookie','История согласий cookie','manage_options','pb-consent-audit',[__CLASS__,'page']); }
    private static function label($event) {
        $labels=['analytics_allowed'=>'Аналитика разрешена','analytics_denied'=>'Аналитика не разрешена','analytics_withdrawn'=>'Согласие на аналитику отозвано','no_choice'=>'Выбор не сделан','no_warning'=>'Баннер не показан'];
        return isset($labels[$event])?$labels[$event]:$event;
    }
    private static function categories($cats) {
        $labels=['functional'=>'Необходимые','preferences'=>'Предпочтения','statistics'=>'Аналитика','marketing'=>'Маркетинг','no_choice'=>'Без выбора','no_warning'=>'Без баннера'];
        return implode(', ',array_map(function($cat) use($labels){return isset($labels[$cat])?$labels[$cat]:$cat;},(array)$cats));
    }
    private static function url($id=0) { return wp_nonce_url(admin_url('admin-post.php?action=pb_cookie_export&record='.absint($id)),'pb_cookie_export'); }
    public static function page() {
        if (!current_user_can('manage_options')) return;
        self::types();
        if(isset($_GET['view'])) {
            $post=get_post(absint($_GET['view']));
            if(!$post || $post->post_type!=='pb_cookie_event'){echo '<div class="wrap"><h1>Запись не найдена</h1></div>';return;}
            $e=json_decode($post->post_content,true);$saved=get_post((int)$e['snapshot_id']);$s=$saved?json_decode($saved->post_content,true):null;
            echo '<div class="wrap"><h1>Подтверждение выбора cookie № '.esc_html($post->ID).'</h1><p><a href="'.esc_url(admin_url('tools.php?page=pb-consent-audit')).'">Вернуться к истории</a> · <a href="'.esc_url(self::url($post->ID)).'">Скачать JSON с текстом</a></p><table class="widefat striped">';
            $fields=['ID Complianz'=>$e['visitor_id'],'Время UTC'=>$e['recorded_at_utc'],'Решение'=>self::label($e['event']),'Категории'=>self::categories($e['categories']),'Предыдущие категории'=>self::categories($e['previous_categories']),'Версия настроек'=>$e['banner_version'],'Источник версии'=>$e['version_basis'],'Действие в браузере'=>['accept'=>'Принять','deny'=>'Отклонить','save'=>'Сохранить выбор','close'=>'Закрыть окно'][$e['action']??'']??'Не передано','SHA-256 снимка'=>$e['snapshot_sha256']];
            foreach($fields as $key=>$value)echo '<tr><th>'.esc_html($key).'</th><td>'.esc_html($value).'</td></tr>';echo '</table>';
            if($s){echo '<h2>Сохранённый текст баннера</h2><div style="background:white;padding:20px;max-width:900px">'.wp_kses_post($s['banner']['message_optin']).'</div><h2>Документы в снимке</h2>';foreach($s['documents'] as $doc){echo '<h3>'.esc_html($doc['title']).'</h3>';if(trim($doc['content'])==='')echo '<p><strong>На момент снимка страница не содержала текста. Это не подтверждение содержания политики или согласия.</strong></p>';else echo '<pre style="white-space:pre-wrap">'.esc_html($doc['content']).'</pre>';}}
            else echo '<p>Снимок для этой версии не подтверждён. Текущие условия не подставлены вместо неизвестных.</p>';
            echo '<p>Техническое подтверждение выбора браузера. История до включения доработки не восстанавливается задним числом.</p></div>';return;
        }
        $visitor=isset($_GET['visitor'])?absint($_GET['visitor']):0;
        $args=['post_type'=>'pb_cookie_event','post_status'=>'private','posts_per_page'=>30,'orderby'=>'ID','order'=>'DESC'];
        if ($visitor) { $args['meta_key']='pb_visitor_id';$args['meta_value']=$visitor; }
        $query=new WP_Query($args);
        $banner=self::banner();
        echo '<div class="wrap"><h1>История согласий cookie</h1><p>Дополнение к Complianz: изменения сохраняются отдельными записями, предыдущие решения не перезаписываются. История начинается с включения этой доработки.</p>';
        echo '<p>Текущий баннер: <strong>'.esc_html($banner['ID'] ?? '').'</strong>; версия настроек: <strong>'.esc_html($banner['banner_version'] ?? '').'</strong>. Все даты ниже — UTC.</p>';
        echo '<p><a class="button" href="'.esc_url(self::url()).'">Скачать историю CSV</a> <a class="button" href="'.esc_url(admin_url('admin.php?page=complianz#tools/records-of-consent')).'">Последнее состояние в Complianz</a></p>';
        echo '<form method="get"><input type="hidden" name="page" value="pb-consent-audit"><label>ID Complianz <input type="number" name="visitor" min="1" value="'.($visitor?esc_attr($visitor):'').'"></label> <button class="button">Найти историю</button> <a href="'.esc_url(admin_url('tools.php?page=pb-consent-audit')).'">Все записи</a></form>';
        echo '<p>Записей по фильтру: '.esc_html($query->found_posts).'. Показаны последние 30. Полная история доступна в CSV. IP, ФИО и ClientID Метрики дополнительно не собираются.</p><table class="widefat striped"><thead><tr><th>Запись</th><th>Дата UTC</th><th>ID Complianz</th><th>Решение</th><th>Категории</th><th>Версия настроек / снимок</th><th>Подтверждение</th></tr></thead><tbody>';
        foreach($query->posts as $post) {
            $e=json_decode($post->post_content,true); if(!is_array($e))continue;
            echo '<tr><td>'.esc_html($post->ID).'</td><td>'.esc_html($e['recorded_at_utc']).'</td><td>'.esc_html($e['visitor_id']).'</td><td>'.esc_html(self::label($e['event'])).'</td><td>'.esc_html(self::categories($e['categories'])).'</td><td>'.esc_html($e['banner_version'].' / '.substr($e['snapshot_sha256'],0,12)).'</td><td><a href="'.esc_url(admin_url('tools.php?page=pb-consent-audit&view='.$post->ID)).'">Посмотреть запись и текст</a></td></tr>';
        }
        if(!$query->posts)echo '<tr><td colspan="7">Новых решений пока нет. Старые записи остаются в Complianz и не выдаются за восстановленную историю.</td></tr>';
        echo '</tbody></table><p>Снимок содержит архив настроек и текстов страницы. В JSON указан источник версии: подтверждение браузером либо серверная конфигурация без подтверждения. Это техническая запись браузерного выбора, а не установление личности человека. Открытие настроек или просмотр сайта сами по себе не означают согласия.</p></div>';
    }
    public static function export() {
        if(!current_user_can('manage_options'))wp_die('Нет доступа',403);
        check_admin_referer('pb_cookie_export');
        nocache_headers();
        $id=isset($_GET['record'])?absint($_GET['record']):0;
        if($id){
            $post=get_post($id);if(!$post||$post->post_type!=='pb_cookie_event')wp_die('Запись не найдена',404);
            $entry=json_decode($post->post_content,true);$snapshot=get_post((int)$entry['snapshot_id']);
            $out=['record'=>$entry,'snapshot'=>$snapshot?json_decode($snapshot->post_content,true):null];
            header('Content-Type: application/json; charset=UTF-8');header('Content-Disposition: attachment; filename="cookie-consent-'.$id.'.json"');echo wp_json_encode($out,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT);exit;
        }
        header('Content-Type: text/csv; charset=UTF-8');header('Content-Disposition: attachment; filename="cookie-consent-history.csv"');
        $f=fopen('php://output','w');fwrite($f,"\xEF\xBB\xBF");fputcsv($f,['record_id','utc','complianz_id','decision','categories','previous_categories','banner_id','banner_version','snapshot_sha256']);
        $page=1;
        do{$posts=get_posts(['post_type'=>'pb_cookie_event','post_status'=>'private','numberposts'=>200,'paged'=>$page++,'orderby'=>'ID','order'=>'ASC']);foreach($posts as $post){$e=json_decode($post->post_content,true);fputcsv($f,[$post->ID,$e['recorded_at_utc'],$e['visitor_id'],self::label($e['event']),self::categories($e['categories']),self::categories($e['previous_categories']),$e['banner_id'],$e['banner_version'],$e['snapshot_sha256']]);}}while(count($posts)===200);
        fclose($f);exit;
    }
}
PB_Cookie_Consent_History::boot();
}


/** Monthly retention for the private consent history. No analytics changes. */
if (!class_exists('PB_Cookie_Consent_Retention')) {
class PB_Cookie_Consent_Retention {
    const MONTHLY = 'pb_cc_retention_monthly';
    const BATCH = 'pb_cc_retention_batch';
    const STATE = 'pb_cc_retention_state';
    const HOLDS = 'pb_cc_retention_holds';
    const LOCK = 'pb_cc_retention_lock';

    public static function boot() {
        add_action('init', [__CLASS__, 'init']);
        add_action('save_post_pb_cookie_event', [__CLASS__, 'index'], 10, 2);
        add_action(self::MONTHLY, [__CLASS__, 'monthly']);
        add_action(self::BATCH, [__CLASS__, 'batch']);
        add_action('admin_menu', [__CLASS__, 'menu']);
        add_action('admin_post_pb_cc_retention', [__CLASS__, 'action']);
        add_action('admin_notices', [__CLASS__, 'notice']);
        add_filter('pre_delete_post', [__CLASS__, 'protect_delete'], 10, 3);
    }
    public static function init() {
        register_post_type('pb_cookie_native', ['public'=>false,'show_ui'=>false,'show_in_rest'=>false,'rewrite'=>false,'query_var'=>false,'supports'=>[]]);
        if (!wp_next_scheduled(self::MONTHLY)) wp_schedule_single_event(self::next_month(), self::MONTHLY);
        // Some hosts disable the automatic WP-Cron launcher. Run only this due
        // maintenance task at shutdown; do not enable global cron or issue HTTP calls.
        if(defined('DISABLE_WP_CRON') && DISABLE_WP_CRON && !(defined('DOING_CRON') && DOING_CRON)) {
            $monthly=wp_next_scheduled(self::MONTHLY);$batch=wp_next_scheduled(self::BATCH);
            if(($monthly && $monthly<=time()) || ($batch && $batch<=time()))add_action('shutdown',[__CLASS__,'run_due'],100);
        }
    }
    public static function run_due() {
        $monthly=wp_next_scheduled(self::MONTHLY);
        if($monthly && $monthly<=time()) { wp_unschedule_event($monthly,self::MONTHLY);self::monthly();return; }
        $batch=wp_next_scheduled(self::BATCH);
        if($batch && $batch<=time()) { wp_unschedule_event($batch,self::BATCH);self::batch(); }
    }
    public static function next_month($now = null) {
        $date = new DateTimeImmutable($now === null ? 'now' : '@'.$now);
        return $date->setTimezone(new DateTimeZone('UTC'))->modify('first day of next month')->setTime(2,0)->getTimestamp();
    }
    public static function cutoff($now = null) {
        $date = new DateTimeImmutable($now === null ? 'now' : '@'.$now);
        return $date->setTimezone(new DateTimeZone('UTC'))->modify('first day of this month')->setTime(0,0)->modify('-24 months')->format('Y-m-d H:i:s');
    }
    public static function index($id, $post) {
        $entry = json_decode($post->post_content, true);
        if (!is_array($entry)) return;
        if (isset($entry['snapshot_id'])) update_post_meta($id, 'pb_snapshot_id', absint($entry['snapshot_id']));
    }
    private static function holds() { $holds=get_option(self::HOLDS, []); return is_array($holds)?$holds:[]; }
    public static function protect_delete($delete,$post,$force) {
        if(!in_array($post->post_type,['pb_cookie_event','pb_cookie_native','pb_cookie_version'],true))return $delete;
        if($post->post_type==='pb_cookie_version')return self::used_snapshot($post->ID)?false:$delete;
        $data=json_decode($post->post_content,true);
        $visitor=absint($data['visitor_id']??$data['native_row']['ID']??0);
        return $visitor && isset(self::holds()[$visitor]) ? false : $delete;
    }
    private static function old_events($cutoff, $limit=50) {
        $args=['post_type'=>'pb_cookie_event','post_status'=>'private','posts_per_page'=>$limit,'fields'=>'ids','orderby'=>'ID','order'=>'ASC','date_query'=>[['column'=>'post_date_gmt','before'=>$cutoff,'inclusive'=>false]]];
        $holds=array_map('absint',array_keys(self::holds()));
        if ($holds) $args['meta_query']=[['key'=>'pb_visitor_id','value'=>$holds,'compare'=>'NOT IN','type'=>'NUMERIC']];
        return get_posts($args);
    }
    private static function old_native($cutoff, $limit=50) {
        global $wpdb;
        // Complianz stores its timestamp shifted by the WordPress UTC offset.
        $time=strtotime($cutoff.' UTC')+(int)(3600*(float)get_option('gmt_offset',0));
        $sql="SELECT * FROM {$wpdb->prefix}cmplz_statistics WHERE CAST(time AS UNSIGNED)>0 AND CAST(time AS UNSIGNED)<%d";
        $params=[$time]; $holds=array_map('absint',array_keys(self::holds()));
        if ($holds) { $sql.=' AND ID NOT IN ('.implode(',',array_fill(0,count($holds),'%d')).')';$params=array_merge($params,$holds); }
        $sql.=' ORDER BY ID ASC LIMIT %d'; $params[]=$limit;
        return $wpdb->get_results($wpdb->prepare($sql,$params), ARRAY_A) ?: [];
    }
    private static function missing_index() {
        return get_posts(['post_type'=>'pb_cookie_event','post_status'=>['private','trash'],'posts_per_page'=>100,'orderby'=>'ID','order'=>'ASC','meta_query'=>[['key'=>'pb_snapshot_id','compare'=>'NOT EXISTS']]]);
    }
    private static function snapshots($cutoff) {
        global $wpdb;
        $current=(int)$wpdb->get_var("SELECT banner_version FROM {$wpdb->prefix}cmplz_cookiebanners ORDER BY `default` DESC, ID ASC LIMIT 1");
        $sql="SELECT s.* FROM {$wpdb->posts} s WHERE s.post_type='pb_cookie_version' AND s.post_status='private' AND s.post_date_gmt<%s
            AND NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} m JOIN {$wpdb->posts} e ON e.ID=m.post_id WHERE m.meta_key='pb_snapshot_id' AND CAST(m.meta_value AS UNSIGNED)=s.ID AND e.post_type='pb_cookie_event' AND e.post_status IN ('private','trash'))
            AND NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} v WHERE v.post_id=s.ID AND v.meta_key='pb_banner_version' AND CAST(v.meta_value AS UNSIGNED)=%d)
            ORDER BY s.ID ASC LIMIT 20";
        return $wpdb->get_results($wpdb->prepare($sql,$cutoff,$current)) ?: [];
    }
    private static function used_snapshot($id) {
        return (bool)get_posts(['post_type'=>'pb_cookie_event','post_status'=>['private','trash'],'posts_per_page'=>1,'fields'=>'ids','meta_key'=>'pb_snapshot_id','meta_value'=>$id]);
    }
    private static function expired_trash() {
        global $wpdb;
        $sql="SELECT p.* FROM {$wpdb->posts} p WHERE p.post_status='trash' AND p.post_type IN ('pb_cookie_event','pb_cookie_native','pb_cookie_version')
            AND EXISTS (SELECT 1 FROM {$wpdb->postmeta} t WHERE t.post_id=p.ID AND t.meta_key='_wp_trash_meta_time' AND CAST(t.meta_value AS UNSIGNED)<%d)";
        $params=[time()-86400*(int)EMPTY_TRASH_DAYS];$holds=array_map('absint',array_keys(self::holds()));
        if($holds){$sql.=" AND NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} h WHERE h.post_id=p.ID AND h.meta_key='pb_visitor_id' AND CAST(h.meta_value AS UNSIGNED) IN (".implode(',',array_fill(0,count($holds),'%d')).'))';$params=array_merge($params,$holds);}
        $sql.=" AND (p.post_type<>'pb_cookie_version' OR NOT EXISTS (SELECT 1 FROM {$wpdb->postmeta} m JOIN {$wpdb->posts} e ON e.ID=m.post_id WHERE m.meta_key='pb_snapshot_id' AND CAST(m.meta_value AS UNSIGNED)=p.ID AND e.post_type='pb_cookie_event' AND e.post_status IN ('private','trash'))) ORDER BY p.ID ASC LIMIT 50";
        return $wpdb->get_results($wpdb->prepare($sql,$params)) ?: [];
    }
    public static function monthly() {
        $name=self::LOCK.'_launcher';$lock=(int)get_option($name,0);
        if($lock && $lock<time()-3600)delete_option($name);
        if(!add_option($name,time(),'','no'))return;
        try {
            if (!wp_next_scheduled(self::MONTHLY)) wp_schedule_single_event(self::next_month(),self::MONTHLY);
            update_option(self::STATE,['started_utc'=>gmdate('c'),'cutoff'=>self::cutoff(),'events'=>0,'native'=>0,'snapshots'=>0,'errors'=>[],'finished_utc'=>''],false);
            self::batch();
        } finally { delete_option($name); }
    }
    public static function batch() {
        global $wpdb;
        // Never turn this operation into irreversible deletion if Trash was disabled.
        if (!defined('EMPTY_TRASH_DAYS') || EMPTY_TRASH_DAYS < 1) {
            $state=get_option(self::STATE,[]);$state['errors']=['Корзина WordPress отключена. Очистка остановлена, данные не удалялись.'];update_option(self::STATE,$state,false);return;
        }
        $lock=(int)get_option(self::LOCK,0);
        if ($lock && $lock < time()-3600) delete_option(self::LOCK);
        if (!add_option(self::LOCK,time(),'','no')) return;
        $state=get_option(self::STATE,[]);
        if (empty($state['cutoff'])) $state=['started_utc'=>gmdate('c'),'cutoff'=>self::cutoff(),'events'=>0,'native'=>0,'snapshots'=>0,'errors'=>[],'finished_utc'=>''];
        $again=false;
        try {
            $missing=self::missing_index();
            foreach($missing as $post) self::index($post->ID,$post);
            if(count($missing)===100) { $again=true; }
            else {
                // Purge only our own expired Trash, never other WordPress content.
                // Also works when the host has disabled the global WP-Cron launcher.
                $trash=self::expired_trash();
                foreach($trash as $post){if(self::protect_delete(null,$post,true)===false)continue;if(!wp_delete_post($post->ID,true))throw new RuntimeException('Не удалось очистить корзину журнала: '.$post->ID);$state['purged']=($state['purged']??0)+1;}
                if(count($trash)===50)$again=true;
                $events=self::old_events($state['cutoff']);
                foreach($events as $id) { if(!wp_trash_post($id)) throw new RuntimeException('Не удалось переместить запись в корзину: '.$id);$state['events']++; }
                if(count($events)===50)$again=true;
                $rows=self::old_native($state['cutoff']);
                foreach($rows as $row) {
                    // Keep a recoverable copy before removing the row from the native table.
                    $backup=wp_insert_post(wp_slash(['post_type'=>'pb_cookie_native','post_status'=>'private','post_title'=>'Complianz ID '.absint($row['ID']),'post_content'=>wp_json_encode(['native_row'=>$row,'archived_utc'=>gmdate('c')],JSON_UNESCAPED_UNICODE)]),true);
                    if(is_wp_error($backup) || !$backup)throw new RuntimeException('Не удалось сохранить копию Complianz. Исходная строка оставлена.');
                    add_post_meta($backup,'pb_visitor_id',absint($row['ID']),true);
                    if(!wp_trash_post($backup))throw new RuntimeException('Не удалось поместить копию Complianz в корзину. Исходная строка оставлена.');
                    $deleted=$wpdb->delete($wpdb->prefix.'cmplz_statistics',['ID'=>(int)$row['ID']],['%d']);
                    if($deleted===false)throw new RuntimeException('Ошибка удаления из Complianz. Копия сохранена.');
                    if($deleted)$state['native']++;
                    wp_cache_delete('cmplz_user_banner_id_'.absint($row['ID']),'complianz');
                }
                if(count($rows)===50)$again=true;
                // Do not remove snapshots while index backfill is incomplete, while any
                // active/trashed event references them, or while their banner version is current.
                $current=(int)$wpdb->get_var("SELECT banner_version FROM {$wpdb->prefix}cmplz_cookiebanners ORDER BY `default` DESC, ID ASC LIMIT 1");
                $snapshots=self::snapshots($state['cutoff']);
                foreach($snapshots as $post) {
                    if(self::used_snapshot($post->ID) || (int)get_post_meta($post->ID,'pb_banner_version',true)===$current)continue;
                    if(!wp_trash_post($post->ID))throw new RuntimeException('Не удалось переместить снимок в корзину: '.$post->ID);
                    $state['snapshots']++;
                }
                if(count($snapshots)===20)$again=true;
            }
            $state['last_batch_utc']=gmdate('c');
            if(!$again)$state['finished_utc']=gmdate('c');
        } catch(Throwable $error) {
            $state['errors'][]=$error->getMessage();$state['errors']=array_slice($state['errors'],-10);$again=false;
        } finally { update_option(self::STATE,$state,false);delete_option(self::LOCK); }
        if($again && !wp_next_scheduled(self::BATCH)) wp_schedule_single_event(time()+300,self::BATCH);
    }
    private static function endpoint($operation,$id=0) {
        return wp_nonce_url(admin_url('admin-post.php?action=pb_cc_retention&operation='.$operation.'&id='.absint($id)),'pb_cc_retention');
    }
    public static function menu() { add_management_page('Хранение журнала cookie','Хранение журнала cookie','manage_options','pb-cookie-retention',[__CLASS__,'page']); }
    public static function notice() {
        if(!current_user_can('manage_options') || ($_GET['page']??'')!=='pb-consent-audit')return;
        echo '<div class="notice notice-info"><p>Автоочистка журнала: ежемесячно, записи старше 24 полных месяцев. <a href="'.esc_url(admin_url('tools.php?page=pb-cookie-retention')).'">Хранение, защита записей и корзина</a></p></div>';
    }
    public static function page() {
        if(!current_user_can('manage_options'))return;
        $next=wp_next_scheduled(self::MONTHLY);$state=get_option(self::STATE,[]);$check=get_option('pb_cc_retention_check',[]);$holds=self::holds();
        $trashDays=defined('EMPTY_TRASH_DAYS')?(int)EMPTY_TRASH_DAYS:0;
        echo '<div class="wrap"><h1>Хранение журнала cookie</h1><p>История и последнее состояние Complianz хранятся 24 полных календарных месяца. Очистка запускается первого числа месяца в 02:00 UTC; при большом объёме продолжается пакетами через 5 минут.</p><p><strong>Следующий запуск UTC: '.esc_html($next?gmdate('Y-m-d H:i',$next):'не запланирован').'</strong>. Задачу выполняет WordPress cron. В TimeWeb настроен серверный запуск wp-cron.php каждую минуту; посещения сайта для него не требуются.</p>';
        if(defined('DISABLE_WP_CRON') && DISABLE_WP_CRON)echo '<p>Автозапуск WP-Cron от посещений отключён, используется серверный cron TimeWeb. Дополнительно для этой очистки оставлен резерв: первое обращение к сайту после наступления срока, в конце обработки запроса. Дополнительных сетевых запросов нет; глобальные настройки WP-Cron не меняются.</p>';
        $counts=wp_count_posts('pb_cookie_event');$oldest=get_posts(['post_type'=>'pb_cookie_event','post_status'=>'private','numberposts'=>1,'orderby'=>'date','order'=>'ASC']);
        echo '<p>В активной истории: '.esc_html($counts->private??0).'; в корзине истории: '.esc_html($counts->trash??0).'; самая старая активная запись UTC: '.esc_html($oldest?$oldest[0]->post_date_gmt:'нет').'.</p>';
        echo '<p>После очистки данные доступны для восстановления в корзине WordPress ещё минимум '.esc_html($trashDays).' дней. После этого их окончательно удаляет штатная очистка WordPress либо очередной ежемесячный запуск нашей очистки. При отключённой корзине наша очистка останавливается.</p><p>Записи под защитой пропускаются, в том числе в корзине. Снимки сохраняются, пока на них ссылаются оставшиеся записи, включая корзину; снимки текущей версии баннера также сохраняются. Резервные копии сервера этим механизмом не изменяются.</p>';
        echo '<p><a class="button" href="'.esc_url(self::endpoint('check')).'">Проверить без удаления</a> <a class="button" href="'.esc_url(admin_url('tools.php?page=pb-consent-audit')).'">Открыть историю</a></p>';
        if($check)echo '<p>Проверка UTC: '.esc_html($check['utc']).'; граница: '.esc_html($check['cutoff']).'; найдено кандидатов в проверенной порции: история '.esc_html($check['events']).', Complianz '.esc_html($check['native']).'. Данные не удалялись.</p>';
        echo '<h2>Последняя очистка</h2>';
        if(!$state)echo '<p>Ещё не выполнялась. Расписание включено.</p>';
        else {echo '<p>Начало UTC: '.esc_html($state['started_utc']??'—').'; окончание: '.esc_html($state['finished_utc']??'—').'. Перемещено в корзину: история '.esc_html($state['events']??0).', Complianz '.esc_html($state['native']??0).', снимки '.esc_html($state['snapshots']??0).'; окончательно удалено из просроченной корзины: '.esc_html($state['purged']??0).'.</p>';foreach($state['errors']??[] as $error)echo '<p><strong>Ошибка: '.esc_html($error).'</strong></p>';}
        echo '<h2>Защита записей по спору или проверке</h2><form method="post" action="'.esc_url(admin_url('admin-post.php')).'"><input type="hidden" name="action" value="pb_cc_retention"><input type="hidden" name="operation" value="hold">';wp_nonce_field('pb_cc_retention');echo '<label>ID Complianz <input type="number" name="id" min="1" required></label> <label>Причина <input type="text" name="reason" maxlength="300" required></label> <button class="button">Защитить историю ID</button></form>';
        echo '<ul>';foreach($holds as $id=>$hold)echo '<li>ID '.esc_html($id).': '.esc_html($hold['reason']).' <a href="'.esc_url(self::endpoint('release',$id)).'">Снять защиту</a></li>';echo '</ul><h2>Корзина журнала</h2><p>Показаны последние 30 записей. Восстановление автоматически защищает соответствующий ID от следующей очистки до снятия защиты.</p><table class="widefat striped"><tr><th>Запись</th><th>Тип</th><th>Действие</th></tr>';
        $trashPage=max(1,absint($_GET['trash_page']??1));$trashQuery=new WP_Query(['post_type'=>['pb_cookie_event','pb_cookie_native','pb_cookie_version'],'post_status'=>'trash','posts_per_page'=>30,'paged'=>$trashPage,'orderby'=>'ID','order'=>'DESC']);$trash=$trashQuery->posts;
        foreach($trash as $post)echo '<tr><td>'.esc_html($post->ID.' '.$post->post_title).'</td><td>'.esc_html(['pb_cookie_event'=>'История','pb_cookie_native'=>'Копия Complianz','pb_cookie_version'=>'Снимок'][$post->post_type]).'</td><td><a href="'.esc_url(self::endpoint('restore',$post->ID)).'">Восстановить</a></td></tr>';
        if(!$trash)echo '<tr><td colspan="3">Корзина пуста.</td></tr>';echo '</table><p>';
        if($trashPage>1)echo '<a href="'.esc_url(admin_url('tools.php?page=pb-cookie-retention&trash_page='.($trashPage-1))).'">Предыдущая страница корзины</a> ';
        if($trashPage<$trashQuery->max_num_pages)echo '<a href="'.esc_url(admin_url('tools.php?page=pb-cookie-retention&trash_page='.($trashPage+1))).'">Следующая страница корзины</a>';
        echo '</p></div>';
    }
    public static function action() {
        if(!current_user_can('manage_options'))wp_die('Нет доступа',403);
        check_admin_referer('pb_cc_retention');
        $operation=sanitize_key($_REQUEST['operation']??'');$id=absint($_REQUEST['id']??0);$holds=self::holds();
        if($operation==='check')update_option('pb_cc_retention_check',['utc'=>gmdate('c'),'cutoff'=>self::cutoff(),'events'=>count(self::old_events(self::cutoff())),'native'=>count(self::old_native(self::cutoff()))],false);
        elseif($operation==='hold' && $id){$holds[$id]=['reason'=>sanitize_text_field(wp_unslash($_POST['reason']??'Защита')),'utc'=>gmdate('c')];update_option(self::HOLDS,$holds,false);}
        elseif($operation==='release' && $id){unset($holds[$id]);update_option(self::HOLDS,$holds,false);}
        elseif($operation==='restore' && $id){
            global $wpdb;$post=get_post($id);
            if(!$post || $post->post_status!=='trash' || !in_array($post->post_type,['pb_cookie_event','pb_cookie_native','pb_cookie_version'],true))wp_die('Запись не найдена',404);
            $data=json_decode($post->post_content,true);$visitor=0;
            if($post->post_type==='pb_cookie_native'){
                $row=$data['native_row']??null;if(!is_array($row)||empty($row['ID']))wp_die('Копия повреждена');$visitor=absint($row['ID']);
                if($wpdb->get_var($wpdb->prepare("SELECT ID FROM {$wpdb->prefix}cmplz_statistics WHERE ID=%d",$visitor)))wp_die('Этот ID уже существует. Более новые данные не перезаписаны.');
                if(!$wpdb->insert($wpdb->prefix.'cmplz_statistics',$row))wp_die('Не удалось восстановить. Копия осталась в корзине.');
                wp_cache_delete('cmplz_user_banner_id_'.$visitor,'complianz');
            } else {
                if(!wp_untrash_post($id))wp_die('Не удалось восстановить запись');
                $visitor=absint($data['visitor_id']??0);
            }
            if($visitor){$holds[$visitor]=['reason'=>'Восстановлено из корзины; требуется ручное снятие защиты','utc'=>gmdate('c')];update_option(self::HOLDS,$holds,false);}
        }
        wp_safe_redirect(admin_url('tools.php?page=pb-cookie-retention'));exit;
    }
}
PB_Cookie_Consent_Retention::boot();
}
