<?php
/**
 * Veilleurs du Mode Gardien — Vague 1 (3 boucliers)
 *
 * Détection RÉELLE (event_type = 'real') pour alimenter avi_security_logs :
 *   1. NAP Shield      — Intégrité Nom / Adresse / Téléphone / Site / Catégorie
 *   2. Spy Alert        — Changement de catégorie ou titre des leaders concurrents
 *   3. Sentinel Top 3   — Perte de position stratégique (Top 3 → hors Top 3)
 *
 * Complémentaire de :
 *   - cron_guardian.py (Python)  → surveillance NAP via backend, écrit dans avi_interventions
 *   - Cron_Task_Manager          → génère des événements pédagogiques (event_type = 'pedago'/'system')
 *   - Cette classe               → détection réelle côté PHP, écrit dans avi_security_logs
 *
 * @package Avision
 */

namespace Avision;

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

class Avision_Guardian_Watchers {

    /** Chemin du log dédié (relatif au répertoire du plugin). */
    private const LOG_FILE = 'logs/guardian-watchers.log';

    /**
     * Mapping signal_id → champ de la fiche Google pour le NAP Shield.
     * Aligné sur les 12 Signaux Vitaux (avi-domain-strict.mdc).
     */
    private const NAP_SIGNAL_MAP = [
        'title'    => [ 'signal_id' => 1,  'label' => 'Nom de l\'établissement' ],
        'phone'    => [ 'signal_id' => 3,  'label' => 'Numéro de téléphone' ],
        'address'  => [ 'signal_id' => 4,  'label' => 'Adresse physique' ],
        'website'  => [ 'signal_id' => 5,  'label' => 'Lien du site web' ],
        'category' => [ 'signal_id' => 2,  'label' => 'Catégorie d\'activité' ],
    ];

    // ──────────────────────────────────────────────
    // Point d'entrée
    // ──────────────────────────────────────────────

    /**
     * Exécute les 3 boucliers pour un utilisateur.
     *
     * @param int   $user_id     ID utilisateur (avi_saas_users.id)
     * @param array $profile     Ligne avi_saas_profiles (doit contenir id, google_access_token, google_location_name, last_metrics_snapshot, etc.)
     * @return array{nap: int, spy: int, sentinel: int}  Nombre d'alertes par bouclier
     */
    public function run_wave1( int $user_id, array $profile ): array {
        $results = [ 'nap' => 0, 'spy' => 0, 'sentinel' => 0 ];

        $results['nap']      = $this->check_nap_integrity( $user_id, $profile );
        $results['spy']      = $this->check_spy_alert( $user_id, $profile );
        $results['sentinel'] = $this->check_sentinel_top3( $user_id, $profile );

        return $results;
    }

    // ══════════════════════════════════════════════
    // BOUCLIER 1 — NAP Shield (Intégrité de la fiche)
    // ══════════════════════════════════════════════

    /**
     * Compare les données scellées (last_metrics_snapshot) aux données Google actuelles.
     * Détecte les modifications sur : titre, téléphone, adresse, site web, catégorie.
     *
     * @param int   $user_id
     * @param array $profile
     * @return int  Nombre d'alertes créées
     */
    public function check_nap_integrity( int $user_id, array $profile ): int {
        $profile_id  = (int) ( $profile['id'] ?? 0 );
        $token       = trim( (string) ( $profile['google_access_token'] ?? '' ) );
        $location_id = trim( (string) ( $profile['google_location_name'] ?? '' ) );

        if ( $token === '' || $location_id === '' ) {
            $this->log( 'DEBUG', "NAP Shield: skip profile={$profile_id} (token ou location manquant)" );
            return 0;
        }

        $sealed = $this->get_sealed_data( $profile );
        if ( empty( $sealed ) ) {
            $this->log( 'DEBUG', "NAP Shield: skip profile={$profile_id} (pas de snapshot scellé)" );
            return 0;
        }

        $current = $this->fetch_google_nap( $token, $location_id );
        if ( $current === null ) {
            $this->log( 'WARN', "NAP Shield: échec API Google pour profile={$profile_id}" );
            return 0;
        }

        $alerts = 0;

        // Titre (business name)
        $sealed_title  = $this->normalize( $sealed['title'] ?? '' );
        $current_title = $this->normalize( $current['title'] ?? '' );
        if ( $sealed_title !== '' && $current_title !== '' && $sealed_title !== $current_title ) {
            $this->write_real_alert( $user_id, 'title', $sealed_title, $current_title );
            $alerts++;
        }

        // Téléphone
        $sealed_phone  = $this->normalize_phone( $sealed['phone'] ?? '' );
        $current_phone = $this->normalize_phone( $current['phone'] ?? '' );
        if ( $sealed_phone !== '' && $current_phone !== '' && $sealed_phone !== $current_phone ) {
            $this->write_real_alert( $user_id, 'phone', $sealed_phone, $current_phone );
            $alerts++;
        }

        // Adresse
        $sealed_addr  = $this->normalize( $sealed['address'] ?? '' );
        $current_addr = $this->normalize( $current['address'] ?? '' );
        if ( $sealed_addr !== '' && $current_addr !== '' && $sealed_addr !== $current_addr ) {
            $this->write_real_alert( $user_id, 'address', $sealed_addr, $current_addr );
            $alerts++;
        }

        // Site web
        $sealed_web  = $this->normalize_url( $sealed['website'] ?? '' );
        $current_web = $this->normalize_url( $current['website'] ?? '' );
        if ( $sealed_web !== '' && $current_web !== '' && $sealed_web !== $current_web ) {
            $this->write_real_alert( $user_id, 'website', $sealed_web, $current_web );
            $alerts++;
        }

        // Catégorie
        $sealed_cat  = $this->normalize( $sealed['category'] ?? '' );
        $current_cat = $this->normalize( $current['category'] ?? '' );
        if ( $sealed_cat !== '' && $current_cat !== '' && $sealed_cat !== $current_cat ) {
            $this->write_real_alert( $user_id, 'category', $sealed_cat, $current_cat );
            $alerts++;
        }

        if ( $alerts > 0 ) {
            $this->log( 'ALERT', "NAP Shield: {$alerts} modification(s) détectée(s) pour profile={$profile_id}" );
        } else {
            $this->log( 'OK', "NAP Shield: profile={$profile_id} — intégrité confirmée" );
        }

        return $alerts;
    }

    /**
     * Extrait les données scellées depuis last_metrics_snapshot + colonnes profil.
     */
    private function get_sealed_data( array $profile ): array {
        $snapshot_raw = $profile['last_metrics_snapshot'] ?? '';
        $snapshot     = is_string( $snapshot_raw ) ? json_decode( $snapshot_raw, true ) : [];
        if ( ! is_array( $snapshot ) ) {
            $snapshot = [];
        }

        return [
            'title'    => $profile['business_name'] ?? '',
            'phone'    => $snapshot['phone'] ?? '',
            'address'  => $snapshot['address'] ?? '',
            'website'  => $snapshot['website'] ?? '',
            'category' => $profile['category'] ?? '',
        ];
    }

    /**
     * Récupère les données NAP actuelles depuis l'API Google Business.
     * Utilise le readMask étendu : titre, adresse, téléphone, site, catégories.
     *
     * @return array|null  null en cas d'erreur
     */
    private function fetch_google_nap( string $access_token, string $location_id ): ?array {
        if ( ! function_exists( 'wp_remote_get' ) ) {
            return null;
        }

        $location_name = ltrim( $location_id, '/' );
        $read_mask     = 'title,storefrontAddress,phoneNumbers,websiteUri,categories';
        $url           = 'https://mybusinessbusinessinformation.googleapis.com/v1/' . $location_name . '?readMask=' . urlencode( $read_mask );

        $response = wp_remote_get( $url, [
            'headers' => [
                'Authorization' => 'Bearer ' . $access_token,
                'Content-Type'  => 'application/json',
            ],
            'timeout' => 30,
        ] );

        if ( is_wp_error( $response ) ) {
            $this->log( 'ERROR', 'Google NAP API: ' . $response->get_error_message() );
            return null;
        }

        $code = wp_remote_retrieve_response_code( $response );
        if ( $code !== 200 ) {
            $this->log( 'ERROR', "Google NAP API: HTTP {$code}" );
            return null;
        }

        $body = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $body ) ) {
            return null;
        }

        $phone = '';
        if ( ! empty( $body['phoneNumbers']['primaryPhone'] ) ) {
            $phone = $body['phoneNumbers']['primaryPhone'];
        }

        $address = '';
        if ( ! empty( $body['storefrontAddress'] ) ) {
            $parts = [];
            foreach ( ( $body['storefrontAddress']['addressLines'] ?? [] ) as $line ) {
                if ( is_string( $line ) && trim( $line ) !== '' ) {
                    $parts[] = trim( $line );
                }
            }
            foreach ( [ 'locality', 'postalCode', 'administrativeArea', 'regionCode' ] as $key ) {
                $v = $body['storefrontAddress'][ $key ] ?? '';
                if ( is_string( $v ) && trim( $v ) !== '' ) {
                    $parts[] = trim( $v );
                }
            }
            $address = implode( ' ', $parts );
        }

        $category = '';
        if ( ! empty( $body['categories']['primaryCategory']['displayName'] ) ) {
            $category = $body['categories']['primaryCategory']['displayName'];
        }

        return [
            'title'    => $body['title'] ?? '',
            'phone'    => $phone,
            'address'  => $address,
            'website'  => $body['websiteUri'] ?? '',
            'category' => $category,
        ];
    }

    // ══════════════════════════════════════════════
    // BOUCLIER 2 — Spy Alert (Surveillance des concurrents)
    // ══════════════════════════════════════════════

    /**
     * Compare les données actuelles des 3 leaders (avi_saas_competitors) avec
     * un snapshot précédent pour détecter un changement de catégorie ou de titre.
     *
     * Flux :
     *   1. Lit les competitors enregistrés (baseline du dernier scan)
     *   2. Compare avec le cache snapshot précédent (google_data_cache ou meta)
     *   3. Si différence détectée → alerte real dans avi_security_logs
     *   4. Met à jour le snapshot pour le prochain cycle
     *
     * @param int   $user_id
     * @param array $profile
     * @return int  Nombre d'alertes
     */
    public function check_spy_alert( int $user_id, array $profile ): int {
        global $wpdb;

        $profile_id = (int) ( $profile['id'] ?? 0 );
        if ( $profile_id <= 0 ) {
            return 0;
        }

        $table_comp = $wpdb->prefix . 'avi_saas_competitors';

        $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_comp ) );
        if ( $table_exists !== $table_comp ) {
            $this->log( 'DEBUG', "Spy Alert: table {$table_comp} inexistante" );
            return 0;
        }

        $current_leaders = $wpdb->get_results( $wpdb->prepare(
            "SELECT competitor_name, main_category, google_place_id, current_rank
             FROM {$table_comp}
             WHERE profile_id = %d
             ORDER BY current_rank ASC
             LIMIT 3",
            $profile_id
        ), ARRAY_A );

        if ( empty( $current_leaders ) ) {
            $this->log( 'DEBUG', "Spy Alert: pas de leaders pour profile={$profile_id}" );
            return 0;
        }

        $previous_snapshot = $this->get_spy_snapshot( $profile_id );
        $alerts = 0;

        foreach ( $current_leaders as $leader ) {
            $place_id = $leader['google_place_id'] ?? '';
            $name     = trim( (string) ( $leader['competitor_name'] ?? '' ) );
            $category = trim( (string) ( $leader['main_category'] ?? '' ) );

            if ( $place_id === '' && $name === '' ) {
                continue;
            }

            $match_key = $place_id !== '' ? $place_id : $this->normalize( $name );
            $prev      = $previous_snapshot[ $match_key ] ?? null;

            if ( $prev === null ) {
                continue;
            }

            $prev_name = $this->normalize( $prev['name'] ?? '' );
            $prev_cat  = $this->normalize( $prev['category'] ?? '' );
            $curr_name = $this->normalize( $name );
            $curr_cat  = $this->normalize( $category );

            if ( $prev_cat !== '' && $curr_cat !== '' && $prev_cat !== $curr_cat ) {
                $this->write_security_log( $user_id, 'real', 2, 'blocked',
                    "Spy Alert : le concurrent « {$name} » a changé sa catégorie de « {$prev['category']} » à « {$category} ».",
                    "Spy Alert: competitor \"{$name}\" changed category from \"{$prev['category']}\" to \"{$category}\"."
                );
                $alerts++;
                $this->log( 'ALERT', "Spy Alert: concurrent \"{$name}\" changement catégorie ({$prev['category']} → {$category})" );
            }

            if ( $prev_name !== '' && $curr_name !== '' && $prev_name !== $curr_name ) {
                $this->write_security_log( $user_id, 'real', 1, 'alert',
                    "Spy Alert : le concurrent (place_id: {$place_id}) a changé son titre de « {$prev['name']} » à « {$name} ».",
                    "Spy Alert: competitor (place_id: {$place_id}) renamed from \"{$prev['name']}\" to \"{$name}\"."
                );
                $alerts++;
                $this->log( 'ALERT', "Spy Alert: concurrent renommé ({$prev['name']} → {$name})" );
            }
        }

        $this->save_spy_snapshot( $profile_id, $current_leaders );

        if ( $alerts === 0 ) {
            $this->log( 'OK', "Spy Alert: profile={$profile_id} — aucun changement concurrent détecté" );
        }

        return $alerts;
    }

    /**
     * Récupère le snapshot précédent des leaders (stocké en option WP par profil).
     *
     * @return array  Clé = place_id ou nom normalisé, Valeur = ['name' => ..., 'category' => ...]
     */
    private function get_spy_snapshot( int $profile_id ): array {
        $raw = get_option( "avi_spy_snapshot_{$profile_id}", '' );
        if ( $raw === '' || $raw === false ) {
            return [];
        }
        $data = json_decode( $raw, true );
        return is_array( $data ) ? $data : [];
    }

    /**
     * Sauvegarde le snapshot actuel des leaders pour le prochain cycle de comparaison.
     */
    private function save_spy_snapshot( int $profile_id, array $leaders ): void {
        $snapshot = [];
        foreach ( $leaders as $l ) {
            $place_id  = $l['google_place_id'] ?? '';
            $name      = trim( (string) ( $l['competitor_name'] ?? '' ) );
            $category  = trim( (string) ( $l['main_category'] ?? '' ) );
            $match_key = $place_id !== '' ? $place_id : $this->normalize( $name );
            $snapshot[ $match_key ] = [
                'name'     => $name,
                'category' => $category,
            ];
        }
        update_option( "avi_spy_snapshot_{$profile_id}", wp_json_encode( $snapshot, JSON_UNESCAPED_UNICODE ), false );
    }

    // ══════════════════════════════════════════════
    // BOUCLIER 3 — Sentinel Top 3 (Perte de position)
    // ══════════════════════════════════════════════

    /**
     * Vérifie si un mot-clé stratégique est passé de <=3 à >3
     * en comparant les deux dernières entrées de avi_keyword_history.
     *
     * @param int   $user_id
     * @param array $profile
     * @return int  Nombre d'alertes
     */
    public function check_sentinel_top3( int $user_id, array $profile ): int {
        global $wpdb;

        $profile_id = (int) ( $profile['id'] ?? 0 );
        if ( $profile_id <= 0 ) {
            return 0;
        }

        $t_history  = $wpdb->prefix . 'avi_keyword_history';
        $t_keywords = $wpdb->prefix . 'avi_saas_keywords';

        $history_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $t_history ) );
        if ( $history_exists !== $t_history ) {
            $this->log( 'DEBUG', "Sentinel: table {$t_history} inexistante" );
            return 0;
        }

        $strategic_keywords = $wpdb->get_results( $wpdb->prepare(
            "SELECT keyword FROM {$t_keywords} WHERE profile_id = %d AND is_strategic = 1",
            $profile_id
        ), ARRAY_A );

        if ( empty( $strategic_keywords ) ) {
            $strategic_keywords = $wpdb->get_results( $wpdb->prepare(
                "SELECT keyword FROM {$t_keywords} WHERE profile_id = %d ORDER BY id ASC LIMIT 3",
                $profile_id
            ), ARRAY_A );
        }

        if ( empty( $strategic_keywords ) ) {
            $this->log( 'DEBUG', "Sentinel: pas de mots-clés pour profile={$profile_id}" );
            return 0;
        }

        $alerts = 0;

        foreach ( $strategic_keywords as $kw_row ) {
            $keyword = trim( (string) ( $kw_row['keyword'] ?? '' ) );
            if ( $keyword === '' ) {
                continue;
            }

            $last_two = $wpdb->get_results( $wpdb->prepare(
                "SELECT pos_current, last_scan_date
                 FROM {$t_history}
                 WHERE user_id = %d AND keyword_text = %s AND pos_current IS NOT NULL
                 ORDER BY last_scan_date DESC
                 LIMIT 2",
                $user_id, $keyword
            ), ARRAY_A );

            if ( count( $last_two ) < 2 ) {
                continue;
            }

            $pos_now  = (int) $last_two[0]['pos_current'];
            $pos_prev = (int) $last_two[1]['pos_current'];

            if ( $pos_prev <= 3 && $pos_prev > 0 && $pos_now > 3 ) {
                $this->write_security_log( $user_id, 'real', 2, 'alert',
                    "Sentinel Top 3 : le mot-clé « {$keyword} » a perdu sa position stratégique (#{$pos_prev} → #{$pos_now}). Mission de l'Ombre recommandée.",
                    "Sentinel Top 3: keyword \"{$keyword}\" lost strategic position (#{$pos_prev} → #{$pos_now}). Shadow Mission recommended."
                );
                $alerts++;
                $this->log( 'ALERT', "Sentinel: \"{$keyword}\" perte Top 3 (#{$pos_prev} → #{$pos_now})" );
            }
        }

        if ( $alerts === 0 ) {
            $this->log( 'OK', "Sentinel: profile={$profile_id} — toutes les positions Top 3 maintenues" );
        }

        return $alerts;
    }

    // ══════════════════════════════════════════════
    // Écriture dans avi_security_logs
    // ══════════════════════════════════════════════

    /**
     * Écrit une alerte NAP Shield (détecte quel signal est touché via NAP_SIGNAL_MAP).
     */
    private function write_real_alert( int $user_id, string $field, string $old_value, string $new_value ): void {
        $signal    = self::NAP_SIGNAL_MAP[ $field ] ?? [ 'signal_id' => 1, 'label' => $field ];
        $signal_id = $signal['signal_id'];
        $label     = $signal['label'];

        $msg_fr = "NAP Shield : modification détectée sur « {$label} ». Ancienne valeur : « {$old_value} » → Nouvelle valeur : « {$new_value} ». Intervention requise.";
        $msg_en = "NAP Shield: change detected on \"{$label}\". Old: \"{$old_value}\" → New: \"{$new_value}\". Intervention required.";

        $this->write_security_log( $user_id, 'real', $signal_id, 'blocked', $msg_fr, $msg_en );
        $this->log( 'ALERT', "NAP Shield: {$label} modifié (« {$old_value} » → « {$new_value} »)" );
    }

    /**
     * Insère une ligne dans avi_security_logs.
     */
    private function write_security_log( int $user_id, string $event_type, int $signal_id, string $status, string $msg_fr, string $msg_en ): void {
        global $wpdb;
        $table = $wpdb->prefix . 'avi_security_logs';

        $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
        if ( $table_exists !== $table ) {
            $this->log( 'ERROR', "write_security_log: table {$table} inexistante" );
            return;
        }

        $message_json = wp_json_encode( [
            'fr' => $msg_fr,
            'en' => $msg_en,
        ], JSON_UNESCAPED_UNICODE );

        $wpdb->insert( $table, [
            'user_id'      => $user_id,
            'event_type'   => $event_type,
            'signal_id'    => $signal_id,
            'status'       => $status,
            'message_json' => $message_json,
            'created_at'   => current_time( 'mysql', true ),
        ], [ '%d', '%s', '%d', '%s', '%s', '%s' ] );

        if ( $wpdb->last_error ) {
            $this->log( 'ERROR', "write_security_log INSERT failed: {$wpdb->last_error}" );
        }
    }

    // ══════════════════════════════════════════════
    // Statistiques — Compteurs Centre d'Intervention
    // ══════════════════════════════════════════════

    /**
     * Récupère les compteurs d'événements (types real + system) depuis avi_security_logs.
     *
     * @param int $user_id
     * @return array{today: int, week: int, month: int, total: int}
     */
    public static function get_guardian_stats( int $user_id ): array {
        global $wpdb;
        $table = $wpdb->prefix . 'avi_security_logs';

        $defaults = [ 'today' => 0, 'week' => 0, 'month' => 0, 'total' => 0 ];

        $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
        if ( $table_exists !== $table ) {
            return $defaults;
        }

        $where_types = "AND event_type IN ('real','system')";

        // Utiliser la date "aujourd'hui" au sens WordPress (timezone du site) pour cohérence avec les inserts
        $today_local = current_time( 'Y-m-d' );
        $defaults['today'] = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE user_id = %d {$where_types} AND DATE(created_at) = %s",
            $user_id,
            $today_local
        ) );

        $ts_now = (int) current_time( 'timestamp' );
        $week_start  = date( 'Y-m-d H:i:s', $ts_now - ( 7 * 24 * 3600 ) );
        $defaults['week'] = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE user_id = %d {$where_types} AND created_at >= %s",
            $user_id,
            $week_start
        ) );

        $month_start = date( 'Y-m-d H:i:s', $ts_now - ( 30 * 24 * 3600 ) );
        $defaults['month'] = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE user_id = %d {$where_types} AND created_at >= %s",
            $user_id,
            $month_start
        ) );

        $defaults['total'] = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM {$table} WHERE user_id = %d {$where_types}",
            $user_id
        ) );

        return $defaults;
    }

    /**
     * Récupère les logs du Centre d'Intervention (event_type real + system) pour le flux Timeline.
     *
     * @param int $user_id
     * @param int $limit
     * @return array<int, array{id: int, event_type: string, signal_id: int, status: string, message_json: string|null, created_at: string}>
     */
    public static function get_guardian_logs( int $user_id, int $limit = 80 ): array {
        global $wpdb;
        $table = $wpdb->prefix . 'avi_security_logs';

        $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
        if ( $table_exists !== $table ) {
            return [];
        }

        $limit = max( 1, min( 200, $limit ) );
        $rows  = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, event_type, signal_id, status, message_json, created_at FROM {$table}
                 WHERE user_id = %d AND event_type IN ('real','system')
                 ORDER BY created_at DESC LIMIT %d",
                $user_id,
                $limit
            ),
            ARRAY_A
        );

        if ( ! is_array( $rows ) ) {
            return [];
        }

        return array_map( function ( $row ) {
            return [
                'id'          => (int) ( $row['id'] ?? 0 ),
                'event_type'  => (string) ( $row['event_type'] ?? 'system' ),
                'signal_id'   => (int) ( $row['signal_id'] ?? 0 ),
                'status'      => (string) ( $row['status'] ?? 'blocked' ),
                'message_json' => isset( $row['message_json'] ) && $row['message_json'] !== '' ? $row['message_json'] : null,
                'created_at'  => (string) ( $row['created_at'] ?? '' ),
            ];
        }, $rows );
    }

    // ══════════════════════════════════════════════
    // Helpers
    // ══════════════════════════════════════════════

    private function normalize( string $value ): string {
        return mb_strtolower( trim( $value ) );
    }

    private function normalize_phone( string $phone ): string {
        return preg_replace( '/\D/', '', $phone );
    }

    private function normalize_url( string $url ): string {
        $url = mb_strtolower( trim( $url ) );
        $url = rtrim( $url, '/' );
        $url = preg_replace( '#^https?://(www\.)?#', '', $url );
        return $url;
    }

    // ──────────────────────────────────────────────
    // Logging
    // ──────────────────────────────────────────────

    private function log( string $level, string $message ): void {
        $line = sprintf( "[%s] [%s] %s\n", gmdate( 'Y-m-d H:i:s' ), $level, $message );
        $log_path = $this->get_log_path();
        if ( $log_path ) {
            $dir = dirname( $log_path );
            if ( ! is_dir( $dir ) ) {
                @wp_mkdir_p( $dir );
            }
            @file_put_contents( $log_path, $line, FILE_APPEND | LOCK_EX );
        }
        if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
            error_log( '[AVI Watchers] [' . $level . '] ' . $message );
        }
    }

    private function get_log_path(): string {
        $plugin_dir = defined( 'AVISION_PLUGIN_DIR' ) ? AVISION_PLUGIN_DIR : ( dirname( __DIR__ ) . '/' );
        return $plugin_dir . self::LOG_FILE;
    }
}
