<?php
/**
 * FileIntegrityTable — Per-file hash state and status table.
 *
 * Key domain methods:
 *   upsert_file()          — insert or update a file record from a scan
 *   approve_change()       — mark a file change as reviewed/trusted
 *   get_integrity_summary()— stat snapshot for dashboard cards
 *   get_changes()          — all non-clean, non-quarantined records
 *   get_suspicious()       — records flagged with malware patterns
 *
 * @package UltraguardPro\modules\filechecker\db
 * @since   6.5.0
 */

namespace UltraguardPro\modules\filechecker\db;

use UltraguardPro\db\BaseTable;

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

class FileIntegrityTable extends BaseTable {

    protected $table_name  = 'ultraguard_file_integrity';
    protected $cache_group = 'ultraguard_file_integrity';

    // ── BaseTable contract ─────────────────────────────────────────────────────

    public function get_defaults(): array {
        return [
            'id'              => 0,
            'file_path'       => '',
            'file_hash'       => '',
            'file_size'       => 0,
            'file_type'       => 'unknown',
            'status'          => 'clean',
            'change_type'     => null,
            'previous_hash'   => null,
            'quarantine_path' => null,
            'is_core_file'    => 0,
            'is_approved'     => 0,
            'approved_by'     => null,
            'last_checked_at' => '',
            'created_at'      => '',
            'updated_at'      => '',
        ];
    }

    protected function validate_data( $data ) {
        if ( empty( $data['file_path'] ) ) { return false; }

        $valid_statuses = [ 'clean', 'modified', 'added', 'deleted', 'suspicious', 'quarantined', 'approved' ];
        if ( isset( $data['status'] ) && ! in_array( $data['status'], $valid_statuses, true ) ) {
            $data['status'] = 'clean';
        }

        return $data;
    }

    protected function sanitize_data( $data ): array {
        $out = [];
        foreach ( $data as $k => $v ) {
            $out[ $k ] = match ( $k ) {
                'file_path',
                'quarantine_path'  => is_null( $v ) ? null : sanitize_textarea_field( (string) $v ),
                'file_size'        => (int) $v,
                'is_core_file',
                'is_approved'      => (int) (bool) $v,
                'approved_by'      => is_null( $v ) ? null : absint( $v ),
                'change_type'      => is_null( $v ) ? null : substr( sanitize_text_field( (string) $v ), 0, 80 ),
                'previous_hash'    => is_null( $v ) ? null : sanitize_text_field( (string) $v ),
                default            => is_string( $v ) ? sanitize_text_field( $v ) : $v,
            };
        }
        return $out;
    }

    protected function get_searchable_fields(): array {
        return [ 'file_path', 'file_hash', 'status', 'change_type' ];
    }

    // ── Domain methods ─────────────────────────────────────────────────────────

    /**
     * Insert or update a file record produced by a scan.
     *
     * @param  string $path        Absolute file path.
     * @param  string $hash        Current file hash.
     * @param  int    $size        File size in bytes.
     * @param  string $status      'clean' | 'modified' | 'added' | 'suspicious'
     * @param  string $prev_hash   Previous hash (empty for new files).
     * @param  string $change_type Short description of the change.
     * @param  bool   $is_core     Whether this is a WP core file.
     * @return int|false  Row ID on success, false on failure.
     */
    public function upsert_file(
        string $path,
        string $hash,
        int    $size,
        string $status      = 'clean',
        string $prev_hash   = '',
        string $change_type = '',
        bool   $is_core     = false
    ): int|false {
        $existing = $this->get_row( [ 'file_path' => $path ] );

        // If the file was previously approved and is now modified again,
        // lift the approval flag so the admin must review again.
        $is_approved = 0;
        if ( $existing && ! empty( $existing['is_approved'] ) && $status === 'clean' ) {
            $is_approved = 1; // keep approval for still-clean files
        }

        $data = [
            'file_path'       => $path,
            'file_hash'       => $hash,
            'file_size'       => $size,
            'file_type'       => strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ) ?: 'unknown',
            'status'          => $status,
            'change_type'     => $change_type ?: null,
            'previous_hash'   => $prev_hash   ?: null,
            'is_core_file'    => $is_core ? 1 : 0,
            'is_approved'     => $is_approved,
            'last_checked_at' => current_time( 'mysql', true ),
        ];

        if ( $existing && ! empty( $existing['id'] ) ) {
            $this->update( (int) $existing['id'], $data );
            return (int) $existing['id'];
        }

        return $this->insert( $data );
    }

    /**
     * Mark a file change as reviewed and trusted by an admin.
     *
     * @param  int $id  Row ID.
     * @return bool
     */
    public function approve_change( int $id ): bool {
        return $this->update( $id, [
            'is_approved' => 1,
            'status'      => 'approved',
            'approved_by' => get_current_user_id() ?: null,
        ] );
    }

    /**
     * Mark a file as quarantined and store the quarantine path.
     *
     * @param  int    $id              Row ID.
     * @param  string $quarantine_path Absolute path in quarantine dir.
     * @return bool
     */
    public function mark_quarantined( int $id, string $quarantine_path ): bool {
        return $this->update( $id, [
            'status'          => 'quarantined',
            'quarantine_path' => $quarantine_path,
        ] );
    }

    /**
     * Reset all non-clean records to approved/clean (new baseline).
     *
     * @return int  Number of rows affected.
     */
    public function reset_baseline(): int {
        return $this->update_where(
            [ 'is_approved' => 1, 'status' => 'clean' ],
            [ 'status !=' => 'quarantined' ]
        );
    }

    // ── Aggregation shortcuts ──────────────────────────────────────────────────

    /**
     * Flat summary of file counts per status for dashboard stat-cards.
     *
     * @return array{clean:int, modified:int, added:int, deleted:int, suspicious:int, quarantined:int, total:int}
     */
    public function get_integrity_summary(): array {
        return [
            'clean'       => $this->count( [ 'status' => 'clean' ] ),
            'modified'    => $this->count( [ 'status' => 'modified' ] ),
            'added'       => $this->count( [ 'status' => 'added' ] ),
            'deleted'     => $this->count( [ 'status' => 'deleted' ] ),
            'suspicious'  => $this->count( [ 'status' => 'suspicious' ] ),
            'quarantined' => $this->count( [ 'status' => 'quarantined' ] ),
            'total'       => $this->count(),
        ];
    }

    /**
     * Fetch all non-clean, non-quarantined records (active changes).
     *
     * @param  int $limit
     * @return array
     */
    public function get_changes( int $limit = 500 ): array {
        return $this->get_by(
            [ 'status IN' => [ 'modified', 'added', 'suspicious' ] ],
            $limit,
            'last_checked_at',
            'DESC'
        );
    }

    /**
     * Fetch records flagged with malware patterns.
     *
     * @param  int $limit
     * @return array
     */
    public function get_suspicious( int $limit = 200 ): array {
        return $this->get_by( [ 'status' => 'suspicious' ], $limit, 'last_checked_at', 'DESC' );
    }

    /**
     * Fetch records changed since the given datetime.
     *
     * @param  string $datetime  MySQL datetime string.
     * @param  int    $limit
     * @return array
     */
    public function get_changes_since( string $datetime, int $limit = 1000 ): array {
        return $this->get_by(
            [ 'updated_at >=' => $datetime, 'status !=' => 'clean' ],
            $limit,
            'updated_at',
            'DESC'
        );
    }

    // ── Bulk-scan helpers ──────────────────────────────────────────────────────

    /**
     * Load all known file records into a flat map keyed by file_path.
     *
     * Used by FilecheckerEngine to preload the entire baseline once before a
     * scan loop, eliminating the O(N) per-file get_row() queries that cause
     * PHP cron timeouts on sites with thousands of files.
     *
     * The returned map is intentionally not cached in the object-cache because:
     *   1. It is only called once per scan run from within a cron/AJAX context.
     *   2. Its sole consumer (FilecheckerEngine) immediately iterates over it
     *      and discards it, so caching would waste memory with no read benefit.
     *
     * @return array<string, array>  [ absolute_file_path => row_as_ARRAY_A ]
     */
    public function get_file_map(): array {
        $rows = $this->get_all( 'id', 'ASC', false ); // skip cache — we want fresh data
        if ( empty( $rows ) ) {
            return [];
        }
        $map = [];
        foreach ( $rows as $row ) {
            if ( ! empty( $row['file_path'] ) ) {
                $map[ $row['file_path'] ] = $row;
            }
        }
        return $map;
    }

    /**
     * Insert or update a file record when the caller already has the existing row.
     *
     * This is a performance-optimised variant of upsert_file() for use inside
     * scan loops where get_file_map() has already been called.  It avoids the
     * extra get_row() DB query that the standard upsert_file() performs.
     *
     * @param  string     $path        Absolute file path.
     * @param  string     $hash        Current file hash.
     * @param  int        $size        File size in bytes.
     * @param  string     $status      'clean' | 'modified' | 'added' | 'suspicious'
     * @param  string     $prev_hash   Previous hash (empty for new files).
     * @param  string     $change_type Short description of the change.
     * @param  bool       $is_core     Whether this is a WP core file.
     * @param  array|null $existing    Pre-loaded row from get_file_map(), or null.
     * @return int|false  Row ID on success, false on failure.
     */
    public function upsert_file_fast(
        string  $path,
        string  $hash,
        int     $size,
        string  $status      = 'clean',
        string  $prev_hash   = '',
        string  $change_type = '',
        bool    $is_core     = false,
        ?array  $existing    = null
    ): int|false {
        // If the caller did not supply a preloaded row, fall back to the
        // standard upsert (which will do its own get_row lookup).
        if ( $existing === null ) {
            return $this->upsert_file( $path, $hash, $size, $status, $prev_hash, $change_type, $is_core );
        }

        $is_approved = 0;
        if ( ! empty( $existing['is_approved'] ) && $status === 'clean' ) {
            $is_approved = 1;
        }

        $data = [
            'file_path'       => $path,
            'file_hash'       => $hash,
            'file_size'       => $size,
            'file_type'       => strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ) ?: 'unknown',
            'status'          => $status,
            'change_type'     => $change_type ?: null,
            'previous_hash'   => $prev_hash   ?: null,
            'is_core_file'    => $is_core ? 1 : 0,
            'is_approved'     => $is_approved,
            'last_checked_at' => current_time( 'mysql', true ),
        ];

        if ( ! empty( $existing['id'] ) ) {
            $this->update( (int) $existing['id'], $data );
            return (int) $existing['id'];
        }

        return $this->insert( $data );
    }
}
