<?php
/**
 * Retroactive media-library provenance scan.
 *
 * Walks the existing attachment library in cursor-batched WP-Cron passes and
 * records source C2PA provenance for assets uploaded before the plugin was
 * installed (or before source detection existed). Detection is LOCAL ONLY, via
 * {@see C2paDetector}: no Enterprise API calls, no signing, no cost. The scan
 * stores the same _encypher_source_* attachment meta the upload path stores,
 * plus a _encypher_source_scanned_at marker so the media UI can distinguish
 * "scanned and clean" from "not yet scanned".
 *
 * Cron shape: ONE recurring event name ({@see CRON_HOOK}) that reschedules
 * itself while work remains, never one scheduled event per attachment (which
 * would bloat the serialized cron option). A single option ({@see STATE_OPTION})
 * holds the cursor and progress counters.
 *
 * This class is also the single source of truth for source-provenance meta
 * writers and media coverage/filter predicates. Coverage treats imported
 * source provenance and media signed by Encypher as provenance-bearing, so
 * the coverage card, media list filter, attachment UI, and reader agree.
 *
 * @package EncypherProvenance
 */

namespace EncypherProvenance;

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

class MediaScan
{
    /**
     * Option holding the scan cursor and progress counters.
     */
    public const STATE_OPTION = 'encypher_media_scan_state';

    /**
     * Single recurring cron hook. Reschedules itself while work remains.
     */
    public const CRON_HOOK = 'encypher_media_scan_batch';

    /**
     * Attachments processed per cron run.
     */
    public const BATCH_SIZE = 25;

    /**
     * Meta keys whose presence marks an attachment as already scanned for
     * source provenance, either by this retroactive scan
     * (_encypher_source_scanned_at) or the upload-time detection path
     * (_encypher_source_file_hash, written for every uploaded file). Used by
     * both the per-attachment classifier and the WP_Query predicates so the
     * coverage card, the list filter, and the attachment UI never disagree.
     */
    private const SCANNED_MARKER_KEYS = [
        '_encypher_source_scanned_at',
        '_encypher_source_file_hash',
    ];

    /**
     * Register the batch cron handler.
     */
    public function register_hooks(): void
    {
        add_action(self::CRON_HOOK, [$this, 'run_batch']);
    }

    // =========================================================================
    // State
    // =========================================================================

    /**
     * @return array<string,mixed> Normalized scan state (never partial).
     */
    public static function get_state(): array
    {
        $stored = get_option(self::STATE_OPTION, []);
        if (! is_array($stored)) {
            $stored = [];
        }

        $status = (string) ($stored['status'] ?? 'idle');
        $scanned = max(0, (int) ($stored['scanned'] ?? 0));
        $skipped = max(0, (int) ($stored['skipped_offloaded'] ?? 0));
        $processed = $scanned + $skipped;
        $total = max(0, (int) ($stored['total'] ?? 0));

        // Uploads can arrive during a cursor scan. Never expose a denominator
        // below work already processed, including files that could not be read.
        $total = max($total, $processed);

        return [
            'status'            => in_array($status, ['idle', 'running', 'complete', 'cancelled'], true) ? $status : 'idle',
            'cursor'            => max(0, (int) ($stored['cursor'] ?? 0)),
            'total'             => $total,
            'processed'         => $processed,
            'scanned'           => $scanned,
            'found'             => max(0, (int) ($stored['found'] ?? 0)),
            'skipped_offloaded' => $skipped,
            'started_at'        => (string) ($stored['started_at'] ?? ''),
            'completed_at'      => (string) ($stored['completed_at'] ?? ''),
            'updated_at'        => (string) ($stored['updated_at'] ?? ''),
        ];
    }

    /**
     * @param array<string,mixed> $state
     */
    private static function save_state(array $state): void
    {
        update_option(self::STATE_OPTION, $state, false);
    }

    // =========================================================================
    // Lifecycle
    // =========================================================================

    /**
     * (Re)start a full-library scan from the beginning. Resets counters, records
     * the total, and schedules the first batch. Returns the fresh state.
     *
     * @return array<string,mixed>
     */
    public static function start(): array
    {
        $now = gmdate('c');
        $total = self::count_attachments();
        if (function_exists('wp_clear_scheduled_hook')) {
            wp_clear_scheduled_hook(self::CRON_HOOK);
        }
        $state = [
            'status'            => 0 === $total ? 'complete' : 'running',
            'cursor'            => 0,
            'total'             => $total,
            'scanned'           => 0,
            'found'             => 0,
            'skipped_offloaded' => 0,
            'started_at'        => $now,
            'completed_at'      => 0 === $total ? $now : '',
            'updated_at'        => $now,
        ];
        self::save_state($state);
        if ($total > 0) {
            self::schedule_next();
        }

        return self::get_state();
    }

    /**
     * Stop an active scan without discarding its last-run counters.
     *
     * @return array<string,mixed> Normalized cancelled or existing state.
     */
    public static function cancel(): array
    {
        $state = self::get_state();
        if ('running' !== $state['status']) {
            return $state;
        }

        $now = gmdate('c');
        $state['status'] = 'cancelled';
        $state['completed_at'] = $now;
        $state['updated_at'] = $now;
        unset($state['processed']);
        self::save_state($state);
        if (function_exists('wp_clear_scheduled_hook')) {
            wp_clear_scheduled_hook(self::CRON_HOOK);
        }

        return self::get_state();
    }

    /**
     * Process one batch of attachments, advance the cursor, and reschedule while
     * work remains. Safe to call directly (WP-Cron does).
     */
    public function run_batch(): void
    {
        $state = self::get_state();
        if ('running' !== $state['status']) {
            // Not an active scan; ignore any stray/duplicate event.
            return;
        }

        $cursor = (int) $state['cursor'];
        $ids    = $this->query_batch($cursor);

        $scanned = 0;
        $found   = 0;
        $skipped = 0;

        foreach ($ids as $attachment_id) {
            $attachment_id = (int) $attachment_id;
            if ($attachment_id > $cursor) {
                $cursor = $attachment_id;
            }

            $file_path = get_attached_file($attachment_id);
            if (! $file_path || ! file_exists($file_path)) {
                // Offloaded to remote storage, or the local file is gone. Local
                // detection is impossible; count it and move on (same
                // file_exists guard the upload path uses).
                $skipped++;
                continue;
            }

            $mime_type = (string) get_post_mime_type($attachment_id);
            if (self::record_local_detection($attachment_id, $file_path, $mime_type)) {
                $found++;
            }
            update_post_meta($attachment_id, '_encypher_source_scanned_at', current_time('mysql'));
            $scanned++;
        }

        $state['cursor']             = $cursor;
        $state['scanned']           += $scanned;
        $state['found']             += $found;
        $state['skipped_offloaded'] += $skipped;
        $state['updated_at']         = gmdate('c');

        $processed = (int) $state['scanned'] + (int) $state['skipped_offloaded'];
        $has_more = count($ids) >= self::BATCH_SIZE;
        if ($has_more) {
            $state['status'] = 'running';
            $state['total'] = max($processed, self::count_attachments());
        } else {
            $state['status']       = 'complete';
            $state['total']        = $processed;
            $state['completed_at'] = gmdate('c');
        }
        self::save_state($state);

        // The coverage card caches media counts in the coverage transient; drop
        // it so the next render reflects the freshly recorded provenance.
        if (class_exists(Admin::class)) {
            Admin::flush_local_coverage_cache();
        }

        if ($has_more) {
            self::schedule_next();
        }
    }

    /**
     * Schedule the next batch as a single self-rescheduling event (never one
     * event per attachment), and nudge cron so progress is prompt.
     */
    private static function schedule_next(): void
    {
        if (! wp_next_scheduled(self::CRON_HOOK)) {
            wp_schedule_single_event(time(), self::CRON_HOOK);
        }

        if (defined('DISABLE_WP_CRON') && DISABLE_WP_CRON) {
            return;
        }
        if (function_exists('spawn_cron')) {
            spawn_cron();
        }
    }

    /**
     * Fetch the next page of attachment IDs strictly after the cursor, ordered
     * by ascending ID (stable against concurrent uploads).
     *
     * @return int[]
     */
    private function query_batch(int $cursor): array
    {
        $where_filter = static function (string $where) use ($cursor): string {
            global $wpdb;
            if ($cursor > 0 && isset($wpdb->posts)) {
                $where .= ' AND ' . $wpdb->posts . '.ID > ' . (int) $cursor;
            }
            return $where;
        };

        add_filter('posts_where', $where_filter);
        $query = new \WP_Query([
            'post_type'      => 'attachment',
            'post_status'    => 'inherit',
            'posts_per_page' => self::BATCH_SIZE,
            'orderby'        => 'ID',
            'order'          => 'ASC',
            'fields'         => 'ids',
            'no_found_rows'  => true,
            'cache_results'  => false,
        ]);
        remove_filter('posts_where', $where_filter);

        $ids = is_array($query->posts) ? $query->posts : [];
        return array_map('intval', $ids);
    }

    /**
     * Total attachment count, used for the progress denominator.
     */
    public static function count_attachments(): int
    {
        $query = new \WP_Query([
            'post_type'      => 'attachment',
            'post_status'    => 'inherit',
            'posts_per_page' => 1,
            'fields'         => 'ids',
            'no_found_rows'  => false,
        ]);

        return (int) $query->found_posts;
    }

    // =========================================================================
    // Shared source-provenance meta writers (single convention)
    // =========================================================================

    /**
     * Whether a verified upload snapshot must no longer follow the mutable file.
     *
     * Once Encypher signing starts, the attachment bytes become a child asset.
     * The source metadata must keep describing the verified parent ingredient.
     */
    public static function source_snapshot_is_locked(int $attachment_id): bool
    {
        if (! get_post_meta($attachment_id, '_encypher_source_c2pa_valid', true)) {
            return false;
        }
        if (get_post_meta($attachment_id, '_encypher_signing_in_progress', true)) {
            return true;
        }

        $status = (string) get_post_meta($attachment_id, '_encypher_provenance_status', true);
        return in_array($status, ['signed', 'verified', 'c2pa_protected', 'c2pa_verified'], true);
    }

    /**
     * Run a local-only C2PA presence scan and, when a manifest is present,
     * persist the source-provenance meta. Shared by the upload path and the
     * retroactive scan so both record identical meta.
     *
     * @return bool True when a C2PA manifest was detected.
     */
    public static function record_local_detection(int $attachment_id, string $file_path, ?string $mime_type): bool
    {
        if (self::source_snapshot_is_locked($attachment_id)) {
            return true;
        }
        $detection = C2paDetector::detect($file_path, $mime_type);
        if (empty($detection['detected'])) {
            return false;
        }

        self::store_source_provenance_meta($attachment_id, [
            'detected'    => true,
            'valid'       => false,
            'media_type'  => '',
            'format'      => $detection['format'] ?? strtolower((string) $mime_type),
            'signer'      => (string) ($detection['claim_generator'] ?? ''),
            'signed_at'   => '',
            'instance_id' => '',
            'reason_code' => '',
            'source'      => 'local',
        ], $detection);

        return true;
    }

    /**
     * Persist source-provenance detection meta for an attachment. The single
     * writer for these keys (provisional local scan AND authoritative API
     * verification both route here).
     *
     * @param int                 $attachment_id Attachment post ID.
     * @param array<string,mixed> $result        Normalized detection result.
     * @param array<string,mixed> $summary       Fallback payload for provisional local scans.
     */
    public static function store_source_provenance_meta(int $attachment_id, array $result, array $summary): void
    {
        update_post_meta($attachment_id, '_encypher_source_has_c2pa', true);
        update_post_meta($attachment_id, '_encypher_source_c2pa_detected_at', current_time('mysql'));
        update_post_meta($attachment_id, '_encypher_source_c2pa_format', (string) (($result['media_type'] ?? '') !== '' ? $result['media_type'] : ($result['format'] ?? '')));
        update_post_meta($attachment_id, '_encypher_source_c2pa_valid', ! empty($result['valid']));
        update_post_meta($attachment_id, '_encypher_source_c2pa_signer', (string) ($result['signer'] ?? ''));
        update_post_meta($attachment_id, '_encypher_source_c2pa_instance_id', (string) ($result['instance_id'] ?? ''));
        update_post_meta($attachment_id, '_encypher_source_c2pa_method', (string) ($result['source'] ?? ''));

        $stored_summary = isset($result['summary']) && is_array($result['summary']) ? $result['summary'] : $summary;
        $details = isset($result['details']) && is_array($result['details']) ? $result['details'] : $summary;
        $rights = isset($result['rights']) && is_array($result['rights']) ? $result['rights'] : null;
        update_post_meta($attachment_id, '_encypher_source_c2pa_summary', wp_slash(wp_json_encode($stored_summary)));
        update_post_meta($attachment_id, '_encypher_source_c2pa_details', wp_slash(wp_json_encode($details)));
        if ($rights !== null && ! empty($rights)) {
            update_post_meta($attachment_id, '_encypher_source_c2pa_rights', wp_slash(wp_json_encode($rights)));
        } else {
            delete_post_meta($attachment_id, '_encypher_source_c2pa_rights');
        }
    }

    // =========================================================================
    // Shared media-coverage predicates (card + list filter + attachment UI)
    // =========================================================================

    /**
     * Classify a single attachment into the provenance bucket used by the
     * coverage card and the list filter.
     *
     * @return string 'carries' | 'none' | 'unscanned'
     */
    public static function source_bucket_for_attachment(int $attachment_id): string
    {
        $status = (string) get_post_meta($attachment_id, '_encypher_provenance_status', true);
        if (
            '' !== (string) get_post_meta($attachment_id, '_encypher_source_has_c2pa', true)
            || '' !== (string) get_post_meta($attachment_id, '_encypher_signed_at', true)
            || in_array($status, VipMediaDelivery::PROVENANCE_STATUSES, true)
        ) {
            return 'carries';
        }
        foreach (self::SCANNED_MARKER_KEYS as $key) {
            if ('' !== (string) get_post_meta($attachment_id, $key, true)) {
                return 'none';
            }
        }
        return 'unscanned';
    }

    /**
     * Query fragment matching imported provenance or Encypher-signed media.
     *
     * @return array<int|string,mixed>
     */
    private static function carries_provenance_meta_query(): array
    {
        return [
            'relation' => 'OR',
            ['key' => '_encypher_source_has_c2pa', 'compare' => 'EXISTS'],
            ['key' => '_encypher_signed_at', 'compare' => 'EXISTS'],
            [
                'key' => '_encypher_provenance_status',
                'value' => VipMediaDelivery::PROVENANCE_STATUSES,
                'compare' => 'IN',
            ],
        ];
    }

    /**
     * Query fragment excluding every provenance-bearing media state.
     *
     * @return array<int|string,mixed>
     */
    private static function lacks_provenance_meta_query(): array
    {
        return [
            'relation' => 'AND',
            ['key' => '_encypher_source_has_c2pa', 'compare' => 'NOT EXISTS'],
            ['key' => '_encypher_signed_at', 'compare' => 'NOT EXISTS'],
            [
                'relation' => 'OR',
                ['key' => '_encypher_provenance_status', 'compare' => 'NOT EXISTS'],
                [
                    'key' => '_encypher_provenance_status',
                    'value' => VipMediaDelivery::PROVENANCE_STATUSES,
                    'compare' => 'NOT IN',
                ],
            ],
        ];
    }

    /**
     * WP_Query meta_query fragment for a media coverage/filter bucket. Derived
     * from the same provenance predicate as {@see source_bucket_for_attachment()}
     * so the card counts, the list filter, and the per-attachment UI agree.
     *
     * @param string $bucket 'carries' | 'none' | 'unscanned'
     * @return array<int|string,mixed>
     */
    public static function media_meta_query(string $bucket): array
    {
        $scanned_exists = ['relation' => 'OR'];
        $scanned_absent = ['relation' => 'AND'];
        foreach (self::SCANNED_MARKER_KEYS as $key) {
            $scanned_exists[] = ['key' => $key, 'compare' => 'EXISTS'];
            $scanned_absent[] = ['key' => $key, 'compare' => 'NOT EXISTS'];
        }

        if ('carries' === $bucket) {
            return self::carries_provenance_meta_query();
        }

        if ('none' === $bucket) {
            return [
                'relation' => 'AND',
                self::lacks_provenance_meta_query(),
                $scanned_exists,
            ];
        }

        // unscanned
        return [
            'relation' => 'AND',
            self::lacks_provenance_meta_query(),
            $scanned_absent,
        ];
    }
}
