<?php
/**
 * Supplier Audit Logger Service
 *
 * Writes a per-supplier, per-sync-run audit report as a JSON Lines session
 * file (one line per product), then generates a CSV at the end of the sync.
 * Uses append-only file I/O so each batch (separate cron process) contributes
 * to the same session without needing shared memory.
 *
 * @category   WordPress_Plugin
 * @package    Common
 * @subpackage Services\Auditing
 * @author     miniOrange <info@miniorange.com>
 * @license    MIT/Expat
 * @link       https://plugins.miniorange.com/woocommerce-api-product-sync-with-woocommerce-rest-apis
 */

namespace MOWCPS\Common\Services\Auditing;

use MOWCPS\Common\Models\Supplier_Model;
use MOWCPS\Common\Models\Sync_Details_DAO;
use MOWCPS\Common\Models\Connection_Model;
use MOWCPS\Common\Models\Setting_Model;
use MOWCPS\Common\Constants\Constants;

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

/**
 * Class Supplier_Audit_Logger
 *
 * Handles per-supplier, per-sync-run detailed audit logging and CSV report generation.
 */
class Supplier_Audit_Logger{

    /**
     * Base directory for all audit data (inside plugin).
     */
    const AUDIT_BASE_DIR = MOWCPS_PLUGIN_DIR . 'data' . DIRECTORY_SEPARATOR . 'auditing';

    /**
     * Maximum number of completed audit CSV files kept per supplier.
     */
    const MAX_AUDIT_FILES = 3;

    /**
     * Check whether auditing is enabled for a given supplier.
     *
     * @param  int $supplier_id Supplier ID.
     * @return bool
     */
    public static function is_enabled( $supplier_id )
    {
        $supplier = Supplier_Model::get_by_id($supplier_id);
        if ( empty($supplier) ) {
            return false;
        }
        return ! empty($supplier['audit_mode']);
    }

    /**
     * Raw connection type slug from DB row and/or sync details (details take precedence).
     *
     * @param  array|null $row     Row from Connection_Model::get_by_id().
     * @param  mixed      $details Value from Sync_Details_DAO::get_connection_details().
     * @return string
     */
    private static function raw_connection_type_from_sources( $row, $details )
    {
        if ( is_array( $details ) && isset( $details['type'] ) ) {
            return (string) $details['type'];
        }
        if ( is_array( $row ) && isset( $row['type'] ) ) {
            return (string) $row['type'];
        }
        return '';
    }

    /**
     * Whether an audit product JSON line represents success (schema uses bool; tolerates int/string).
     *
     * @param  array $row Decoded product line.
     * @return bool
     */
    public static function audit_product_row_is_success( array $row )
    {
        if ( ! array_key_exists( 'success', $row ) ) {
            return false;
        }
        $v = $row['success'];
        if ( is_bool( $v ) ) {
            return $v;
        }
        if ( is_int( $v ) ) {
            return 1 === $v;
        }
        if ( is_string( $v ) ) {
            $lv = strtolower( trim( $v ) );
            return in_array( $lv, array( '1', 'true', 'yes' ), true );
        }
        return false;
    }

    /**
     * Build audit summary fields for connection/API context (aligned with retrieve_product_data options and settings).
     *
     * @param  int $connection_id Connection ID.
     * @return array Key/value pairs merged into the audit summary row and PDF.
     */
    public static function connection_context_for_audit( $connection_id )
    {
        $connection_id = (int) $connection_id;
        if ( $connection_id <= 0 ) {
            return array();
        }

        $row     = Connection_Model::get_by_id($connection_id);
        $details = Sync_Details_DAO::get_instance()->get_connection_details($connection_id);
        $type    = self::raw_connection_type_from_sources( $row, $details );

        $batch_cfg = Setting_Model::get_by_connection_and_operation($connection_id, 'batch', array( 'configuration' => true ) );
        $batch_on  = ! empty($batch_cfg['is_active']) && '1' === (string) $batch_cfg['is_active'];
        $batch_sz  = isset($batch_cfg['configuration']['batch_size']) ? (int) $batch_cfg['configuration']['batch_size'] : 0;

        $page_cfg  = Setting_Model::get_by_connection_and_operation($connection_id, 'page', array( 'configuration' => true ) );
        $page_on   = ! empty($page_cfg['is_active']) && '1' === (string) $page_cfg['is_active'];
        $page_mode = isset($page_cfg['configuration']['type']) ? sanitize_text_field($page_cfg['configuration']['type']) : '';

        $dao = Sync_Details_DAO::get_instance();

        return array(
            'audit_connection_name'         => is_array($row) && isset($row['name']) ? sanitize_text_field($row['name']) : '',
            'audit_connection_type'         => sanitize_text_field($type),
            'audit_connection_type_label'   => sanitize_text_field(self::connection_type_label($type)),
            'audit_batch_setting_enabled'   => $batch_on,
            'audit_batch_size'              => $batch_sz,
            'audit_batch_runtime_active'    => (bool) $dao->get_batch_flag(),
            'audit_page_setting_enabled'    => $page_on,
            'audit_pagination_mode'         => $page_mode,
            'audit_page_runtime_active'     => (bool) $dao->get_page_flag(),
        );
    }

    /**
     * Append a live data-fetch snapshot to the audit session (JSONL line type audit_context).
     * Called after fetch to record per-connection pagination/batch settings for the audit report.
     *
     * @param  int   $supplier_id Supplier ID.
     * @param  int   $schedule_id Schedule ID (null/0 treated as manual).
     * @param  array $fields      Snapshot fields (audit_* keys).
     * @return void
     */
    public static function append_session_audit_context( $supplier_id, $schedule_id, array $fields )
    {
        if ( ! self::is_enabled($supplier_id) ) {
            return;
        }
        $session_path = self::get_session_path($supplier_id, $schedule_id);
        if ( ! file_exists($session_path) ) {
            return;
        }
        $row = array_merge(
            array(
            'type' => 'audit_context',
            ),
            $fields
        );
        $payload = wp_json_encode($row);
        if ( false === $payload ) {
            return;
        }
     // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Append-only audit session.
        if ( false === file_put_contents($session_path, $payload . PHP_EOL, FILE_APPEND | LOCK_EX) ) {
            return;
        }
    }

    /**
     * Identity fields stored on each audit_context line (per connection).
     *
     * @param  int $connection_id Connection ID.
     * @return array Keys: audit_connection_id, audit_connection_name, audit_connection_type, audit_connection_type_label.
     */
    public static function get_audit_connection_identity_for_context( $connection_id )
    {
        $connection_id = (int) $connection_id;
        if ( $connection_id <= 0 ) {
            return array(
                'audit_connection_id'         => 0,
                'audit_connection_name'       => '',
                'audit_connection_type'       => '',
                'audit_connection_type_label' => '',
            );
        }

        $row   = Connection_Model::get_by_id( $connection_id );
        $name  = '';
        if ( is_array( $row ) && isset( $row['name'] ) ) {
            $name = sanitize_text_field( (string) $row['name'] );
        }

        $details = Sync_Details_DAO::get_instance()->get_connection_details( $connection_id );
        $type    = self::raw_connection_type_from_sources( $row, $details );

        return array(
            'audit_connection_id'         => $connection_id,
            'audit_connection_name'       => $name,
            'audit_connection_type'       => sanitize_text_field( $type ),
            'audit_connection_type_label' => sanitize_text_field( self::connection_type_label( $type ) ),
        );
    }

    /**
     * Normalize one audit_context row for PDF/summary (no JSON "type" key).
     *
     * @param  array $r Raw audit_context line.
     * @return array
     */
    private static function normalize_audit_context_snapshot_for_summary( array $r )
    {
        $cid = isset( $r['audit_connection_id'] ) ? (int) $r['audit_connection_id'] : 0;

        return array(
            'audit_connection_id'           => $cid,
            'audit_connection_name'         => isset( $r['audit_connection_name'] ) ? sanitize_text_field( (string) $r['audit_connection_name'] ) : '',
            'audit_connection_type_label'   => isset( $r['audit_connection_type_label'] ) ? sanitize_text_field( (string) $r['audit_connection_type_label'] ) : '',
            'audit_batch_setting_enabled'   => ! empty( $r['audit_batch_setting_enabled'] ),
            'audit_batch_size'              => (int) ( $r['audit_batch_size'] ?? 0 ),
            'audit_batch_runtime_active'    => ! empty( $r['audit_batch_runtime_active'] ),
            'audit_page_setting_enabled'    => ! empty( $r['audit_page_setting_enabled'] ),
            'audit_pagination_mode'         => isset( $r['audit_pagination_mode'] ) ? sanitize_text_field( (string) $r['audit_pagination_mode'] ) : '',
            'audit_page_runtime_active'     => ! empty( $r['audit_page_runtime_active'] ),
        );
    }

    /**
     * One snapshot per connection: last audit_context line wins for that connection (no cross-connection max merge).
     *
     * @param  array $rows Decoded audit_context rows.
     * @return array List of normalized snapshot arrays.
     */
    private static function build_connection_snapshots_from_audit_context_rows( array $rows )
    {
        if ( empty( $rows ) ) {
            return array();
        }

        $by_id   = array();
        $by_name = array();
        $legacy  = null;

        foreach ( $rows as $r ) {
            $cid = isset( $r['audit_connection_id'] ) ? (int) $r['audit_connection_id'] : 0;
            if ( $cid > 0 ) {
                $by_id[ $cid ] = $r;
            } elseif ( ! empty( $r['audit_connection_name'] ) ) {
                $nk                   = md5( (string) $r['audit_connection_name'] );
                $by_name[ $nk ]       = $r;
            } else {
                $legacy = $r;
            }
        }

        ksort( $by_id, SORT_NUMERIC );

        $out = array();
        foreach ( $by_id as $line ) {
            $out[] = self::normalize_audit_context_snapshot_for_summary( $line );
        }
        foreach ( $by_name as $line ) {
            $out[] = self::normalize_audit_context_snapshot_for_summary( $line );
        }
        if ( null !== $legacy ) {
            $out[] = self::normalize_audit_context_snapshot_for_summary( $legacy );
        }

        return $out;
    }

    /**
     * Human-readable connection type for audit PDF.
     *
     * @param  string $type Raw type slug.
     * @return string
     */
    private static function connection_type_label( $type )
    {
        switch ( $type ) {
            case Constants::API_CONNECTION:
            case Constants::CONNECTION_TYPE_API:
                return 'API';
            case Constants::FTP_CONNECTION:
            case Constants::CONNECTION_TYPE_FTP:
                return 'FTP';
            case Constants::CONNECTION_TYPE_FILE:
                return 'File';
            default:
                return $type ? (string) $type : '';
        }
    }

    /**
     * Start (or reset) a sync audit session.
     *
     * Creates a new JSONL session file with a header row.
     * If a session file already exists (e.g., stale from a previous crashed run)
     * it is removed and a fresh one is created.
     *
     * @param  int   $supplier_id Supplier ID.
     * @param  int   $schedule_id Schedule ID (null for manual syncs).
     * @param  array $meta        Additional metadata: supplier_name, batch_enabled, etc.
     * @return void
     */
    public static function start_sync( $supplier_id, $schedule_id, $meta = array() )
    {
        if ( ! self::is_enabled($supplier_id) ) {
            return;
        }

        self::reset_runtime_audit_context();

        $session_path = self::get_session_path($supplier_id, $schedule_id);

        // Create audit directory if it doesn't exist.
        $dir = self::get_supplier_audit_dir($supplier_id);
        if ( ! wp_mkdir_p($dir) ) {
            return;
        }

        // Remove any stale session file.
        if ( file_exists($session_path) ) {
            wp_delete_file($session_path);
        }

        $header = array(
        'type'          => 'header',
        'supplier_id'   => (int) $supplier_id,
        'supplier_name' => isset($meta['supplier_name']) ? sanitize_text_field($meta['supplier_name']) : '',
        'schedule_id'   => null !== $schedule_id ? (int) $schedule_id : null,
        'start_time'    => gmdate('Y-m-d H:i:s') . ' UTC',
        'start_ts'      => time(),
        'batch_enabled' => ! empty($meta['batch_enabled']),
        'batch_size'    => isset($meta['batch_size']) ? (int) $meta['batch_size'] : 0,
        'page_enabled'  => ! empty($meta['page_enabled']),
        'plugin_version' => defined('MOWCPS_VERSION') ? MOWCPS_VERSION : '',
        'site_url'       => get_bloginfo('url'),
        );

        $payload = wp_json_encode($header);
        if ( false === $payload ) {
            return;
        }
     // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Direct write for performance in sync-critical path.
        $written = file_put_contents($session_path, $payload . PHP_EOL, LOCK_EX);
        if ( false === $written ) {
            return;
        }
    }

    /**
     * Current sync loop position (set from retrieve_product_data for audit rows).
     *
     * @var int
     */
    private static $sync_loop_batch_number = 0;

    /**
     * Current sync loop page (set from retrieve_product_data for audit rows).
     *
     * @var int
     */
    private static $sync_loop_page_number = 0;

    /**
     * Active connection for the current fetch (product rows show which connection synced the item).
     *
     * @var int
     */
    private static $audit_connection_id = 0;

    /**
     * Human-readable connection name for the current fetch.
     *
     * @var string
     */
    private static $audit_connection_name = '';

    /**
     * Reset per-request sync loop / connection audit statics (call when a new session starts).
     *
     * @return void
     */
    public static function reset_runtime_audit_context()
    {
        self::$sync_loop_batch_number = 0;
        self::$sync_loop_page_number  = 0;
        self::$audit_connection_id    = 0;
        self::$audit_connection_name  = '';
    }

    /**
     * Set connection context for subsequent product audit lines (call from retrieve_product_data).
     *
     * @param int $connection_id Connection ID.
     * @return void
     */
    public static function set_audit_connection_context( $connection_id )
    {
        self::$audit_connection_id = (int) $connection_id;
        self::$audit_connection_name = '';
        if ( self::$audit_connection_id <= 0 ) {
            return;
        }
        $row = Connection_Model::get_by_id( self::$audit_connection_id );
        if ( is_array( $row ) && isset( $row['name'] ) ) {
            self::$audit_connection_name = sanitize_text_field( (string) $row['name'] );
        }
    }

    /**
     * Store batch/page for the active API fetch so product audit lines can reference them.
     *
     * @param  int $batch_number Batch number from sync handler.
     * @param  int $page_number  Page number from sync handler.
     * @return void
     */
    public static function set_sync_loop_context( $batch_number, $page_number )
    {
        self::$sync_loop_batch_number = (int) $batch_number;
        self::$sync_loop_page_number  = (int) $page_number;
    }

    /**
     * Log a single product's audit data by appending to the session file.
     *
     * This is designed to be fast: one JSON Line per product, appended with LOCK_EX.
     *
     * @param  int    $supplier_id   Supplier ID.
     * @param  int    $schedule_id   Schedule ID.
     * @param  array  $fields        Associative array of audited field keys (slim snapshot).
     * @param  string $operation     'add', 'update', or 'skip'.
     * @param  bool   $success       Whether the product was saved successfully.
     * @param  string $reason        Failure or skip reason (empty string on success).
     * @param  int    $wc_product_id WooCommerce post ID on success, 0 on failure.
     * @param  string $product_type  Structure slug: simple, variable, etc.
     * @param  array  $audit_extra   Optional: sync_role, parent_wc_id, parent_sku.
     * @return void
     */
    public static function log_product( $supplier_id, $schedule_id, $fields, $operation, $success, $reason = '', $wc_product_id = 0, $product_type = 'simple', $audit_extra = array() )
    {
        if ( ! self::is_enabled( $supplier_id ) ) {
            return;
        }

        $session_path = self::get_session_path( $supplier_id, $schedule_id );
        if ( ! file_exists($session_path) ) {
            return;
        }

        $status = $success ? $operation : 'failed';

        if ( ! is_array( $fields ) ) {
            $fields = array();
        }

        // Sanitise field values for storage (truncate long strings to 500 chars).
        $sanitised_fields = array();
        foreach ( $fields as $key => $value ) {
            $key = sanitize_key($key);
            if ( is_array($value) ) {
                $sanitised_fields[ $key ] = count($value) . ' item(s)';
            } elseif ( is_bool($value) ) {
                $sanitised_fields[ $key ] = $value ? 'yes' : 'no';
            } else {
                $str_value                = (string) $value;
                $sanitised_fields[ $key ] = strlen($str_value) > 500 ? ( substr($str_value, 0, 497) . '...' ) : $str_value;
            }
        }

        $sync_role    = isset($audit_extra['sync_role']) ? sanitize_text_field($audit_extra['sync_role']) : '';
        $parent_wc_id = isset($audit_extra['parent_wc_id']) ? (int) $audit_extra['parent_wc_id'] : 0;
        $parent_sku   = isset($audit_extra['parent_sku']) ? sanitize_text_field($audit_extra['parent_sku']) : '';
        $conn_name    = ! empty( $audit_extra['connection_name'] ) ? sanitize_text_field( $audit_extra['connection_name'] ) : self::$audit_connection_name;

        $row = array(
        'type'            => 'product',
        'product_type'    => sanitize_text_field($product_type),
        'connection_name' => $conn_name,
        'sync_role'       => $sync_role,
        'parent_wc_id'    => $parent_wc_id,
        'parent_sku'      => $parent_sku,
        'operation'       => sanitize_text_field($operation),
        'status'          => sanitize_text_field($status),
        'success'         => (bool) $success,
        'reason'          => sanitize_text_field($reason),
        'wc_product_id'   => (int) $wc_product_id,
        'fields'          => $sanitised_fields,
        );

        $payload = wp_json_encode($row);
        if (false === $payload ) {
            return;
        }
     // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Append mode for performance.
        if (false === file_put_contents($session_path, $payload . PHP_EOL, FILE_APPEND | LOCK_EX) ) {
            return;
        }
    }

    /**
     * Finalise the sync audit: append summary, generate CSV, rotate old files.
     *
     * @param  int   $supplier_id Supplier ID.
     * @param  int   $schedule_id Schedule ID.
     * @param  array $summary     Aggregated counts: total_parsed, total_success, total_failed, etc.
     * @return bool True if CSV was generated, false otherwise.
     */
    public static function finalize_sync( $supplier_id, $schedule_id, $summary = array() )
    {
        $session_path = self::get_session_path($supplier_id, $schedule_id);

        // If no session file exists, auditing was off or was never started.
        if ( ! file_exists($session_path) ) {
            return false;
        }

        // Avoid appending a second summary when recovering a session that already ended (export step failed).
        if ( ! self::session_file_trailing_line_is_summary( $session_path ) ) {
            // Append summary line.
            $summary_row = array_merge(
                array(
                'type'          => 'summary',
                'end_time'      => gmdate('Y-m-d H:i:s') . ' UTC',
                'end_ts'        => time(),
                'total_parsed'  => 0,
                'total_success' => 0,
                'total_failed'  => 0,
                'total_added'   => 0,
                'total_updated' => 0,
                'total_skipped' => 0,
                ),
                $summary
            );
            $summary_row['type'] = 'summary';

            $summary_payload = wp_json_encode($summary_row);
            if ( false !== $summary_payload ) {
             // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Append for session close.
                file_put_contents($session_path, $summary_payload . PHP_EOL, FILE_APPEND | LOCK_EX);
            }
        }

        // Validate session has a header; parse_session_file aggregates summary and collects product rows for CSV in one pass.
        $parsed = self::parse_session_file($session_path);

        if ( empty($parsed) ) {
            wp_delete_file($session_path);
            return false;
        }

        // Generate timestamp-based filename.
        $ts           = $parsed['header']['start_ts'] ?? time();
        $dt_label     = gmdate('Y-m-d_H-i-s', (int) $ts);
        $supplier_dir = self::get_supplier_audit_dir($supplier_id);
        $csv_filename = 'audit_' . $dt_label . '.csv';
        $csv_path     = $supplier_dir . DIRECTORY_SEPARATOR . $csv_filename;

        if ( function_exists( 'wp_raise_memory_limit' ) ) {
            wp_raise_memory_limit( 'admin' );
        }

        $generator = new Audit_CSV_Generator();
        $generated = $generator->generate( $session_path, $csv_path, $parsed );

        if ( $generated ) {
            wp_delete_file($session_path);
            self::rotate_audit_files($supplier_id);
        }

        return $generated;
    }

    /**
     * Count unfinished session JSONL files (sync ended before CSV export or generation failed).
     *
     * @param  int $supplier_id Supplier ID.
     * @return int
     */
    public static function count_pending_session_files( $supplier_id )
    {
        $dir = self::get_supplier_audit_dir($supplier_id);
        if ( ! is_dir($dir) ) {
            return 0;
        }
        $files = glob($dir . DIRECTORY_SEPARATOR . 'session_*.jsonl');
        return ( false === $files ) ? 0 : count($files);
    }

    /**
     * Finalize all pending session files into CSVs (e.g. after a failed export or stuck session).
     *
     * @param  int $supplier_id Supplier ID.
     * @return bool True if at least one CSV was generated.
     */
    public static function finalize_pending_sessions( $supplier_id )
    {
        // Do not require audit_mode here: recovery must run even if auditing was turned off after a failed export.
        $dir = self::get_supplier_audit_dir($supplier_id);
        if ( ! is_dir($dir) ) {
            return false;
        }
        $files = glob($dir . DIRECTORY_SEPARATOR . 'session_*.jsonl');
        if ( false === $files || empty($files) ) {
            return false;
        }
        $any = false;
        foreach ( $files as $path ) {
            $base = basename($path);
            if ( ! preg_match('/^session_\d+_(manual|\d+)\.jsonl$/', $base, $m) ) {
                continue;
            }
            $schedule_id = ( 'manual' === $m[1] ) ? null : (int) $m[1];
            if ( self::finalize_sync($supplier_id, $schedule_id, array()) ) {
                $any = true;
            }
        }
        return $any;
    }

    /**
     * List available completed audit CSV files for a supplier (newest first).
     *
     * @param  int $supplier_id Supplier ID.
     * @return array Array of file info: filename, size, created, index.
     */
    public static function get_audit_files( $supplier_id )
    {
        $dir = self::get_supplier_audit_dir($supplier_id);
        if ( ! is_dir($dir) ) {
            return array();
        }

        $pattern = $dir . DIRECTORY_SEPARATOR . 'audit_*.csv';
        $files   = glob($pattern);

        if ( false === $files || empty($files) ) {
            return array();
        }

        usort(
            $files,
            function ( $a, $b ) {
                return filemtime($b) - filemtime($a);
            }
        );

        $result = array();
        foreach ( array_slice($files, 0, self::MAX_AUDIT_FILES) as $i => $file ) {
            $result[] = array(
            'index'    => $i,
            'filename' => basename($file),
            'path'     => $file,
            'size'     => (int) filesize($file),
            'created'  => gmdate('Y-m-d H:i:s', (int) filemtime($file)) . ' UTC',
            );
        }

        return $result;
    }

    /**
     * Return the absolute path to an audit CSV by index (0 = newest).
     *
     * @param  int $supplier_id Supplier ID.
     * @param  int $file_index  0-based index (0 = most recent).
     * @return string|null Full path or null if not found.
     */
    public static function get_audit_file_path( $supplier_id, $file_index )
    {
        $files = self::get_audit_files($supplier_id);
        return isset($files[ $file_index ]) ? $files[ $file_index ]['path'] : null;
    }

    /**
     * Delete all audit CSV exports and unfinished session JSONL files for a supplier.
     *
     * @param int $supplier_id Supplier ID.
     * @return array|WP_Error Keys: deleted (int count), or error.
     */
    public static function clear_all_audit_files( $supplier_id )
    {
        $supplier_id = (int) $supplier_id;
        if ( $supplier_id < 1 ) {
            return new \WP_Error(
                'invalid_supplier',
                'Invalid supplier.',
                array( 'status' => 400 )
            );
        }

        $dir = self::get_supplier_audit_dir( $supplier_id );
        if ( ! is_dir( $dir ) ) {
            return array(
                'deleted' => 0,
                'message' => 'No audit files to clear.',
            );
        }

        $paths   = array_merge(
            glob( $dir . DIRECTORY_SEPARATOR . 'audit_*.csv' ) ?: array(),
            glob( $dir . DIRECTORY_SEPARATOR . 'session_*.jsonl' ) ?: array()
        );
        $deleted = 0;

        foreach ( $paths as $file ) {
            if ( ! is_string( $file ) || '' === $file ) {
                continue;
            }
            if ( file_exists( $file ) ) {
                wp_delete_file( $file );
                ++$deleted;
            }
        }

        return array(
            'deleted' => $deleted,
            'message' => 'Audit files cleared.',
        );
    }

    // -------------------------------------------------------------------------
    // Private helpers
    // -------------------------------------------------------------------------

    /**
     * Whether the last non-empty line of a JSONL session is already a summary row (avoids duplicate summaries on recovery).
     *
     * @param string $session_path Absolute path.
     * @return bool
     */
    private static function session_file_trailing_line_is_summary( $session_path )
    {
        if ( ! is_string( $session_path ) || '' === $session_path || ! is_readable( $session_path ) ) {
            return false;
        }
        $size = (int) @filesize( $session_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Optional stat.
        if ( $size < 20 ) {
            return false;
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Read tail only.
        $fh = fopen( $session_path, 'rb' );
        if ( false === $fh ) {
            return false;
        }
        $read = (int) min( 65536, $size );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fseek -- Tail read.
        fseek( $fh, $size - $read );
        $tail = fread( $fh, $read );
        fclose( $fh );
        if ( false === $tail || '' === $tail ) {
            return false;
        }
        $lines = preg_split( "/\r\n|\n|\r/", $tail );
        if ( ! is_array( $lines ) ) {
            return false;
        }
        for ( $i = count( $lines ) - 1; $i >= 0; $i-- ) {
            $line = trim( (string) $lines[ $i ] );
            if ( '' === $line ) {
                continue;
            }
            $row = json_decode( $line, true );
            return is_array( $row ) && isset( $row['type'] ) && 'summary' === $row['type'];
        }
        return false;
    }

    /**
     * Parse a JSONL session file into header + summary without loading all product rows into memory.
     *
     * Product lines are counted and aggregated in one streaming pass. Product rows are collected for CSV export (single read).
     * Public so Audit_CSV_Generator can reuse the same aggregated summary and connection snapshots.
     *
     * @param  string $session_path Full file path.
     * @return array Keys: header, summary, products (list of product line arrays), session_path, total_product_rows.
     */
    public static function parse_session_file( $session_path )
    {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Line-by-line stream of large JSONL.
        $fh = fopen( $session_path, 'rb' );
        if ( false === $fh ) {
            return array();
        }

        $header             = array();
        $summary            = array();
        $audit_context_rows = array();

        $product_line_count = 0;
        $product_rows       = array();
        $count_success      = 0;
        $count_failed       = 0;
        $total_added        = 0;
        $total_updated      = 0;
        $total_skipped      = 0;

        while ( ( $line = fgets( $fh ) ) !== false ) {
            $line = trim( $line );
            if ( '' === $line ) {
                continue;
            }
            $row = json_decode( $line, true );
            if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $row ) || empty( $row['type'] ) ) {
                continue;
            }
            switch ( $row['type'] ) {
                case 'header':
                    $header = $row;
                    break;
                case 'product':
                    ++$product_line_count;
                    $product_rows[] = $row;
                    if ( self::audit_product_row_is_success( $row ) ) {
                        ++$count_success;
                        $op = strtolower( (string) ( $row['operation'] ?? '' ) );
                        if ( 'add' === $op ) {
                            ++$total_added;
                        } elseif ( 'update' === $op ) {
                            ++$total_updated;
                        } elseif ( 'skip' === $op ) {
                            ++$total_skipped;
                        }
                    } else {
                        ++$count_failed;
                    }
                    break;
                case 'audit_context':
                    $audit_context_rows[] = $row;
                    break;
                case 'summary':
                    $summary = $row;
                    break;
            }
        }
        fclose( $fh );

        if ( empty( $header ) ) {
            return array();
        }

        // Full summary from streamed counts when the summary line is missing or empty.
        if ( empty( $summary ) || 0 === (int) ( $summary['total_parsed'] ?? 0 ) ) {
            $summary['total_parsed']  = $product_line_count;
            $summary['total_success'] = $count_success;
            $summary['total_failed']  = $count_failed;
        }

        $summary['total_added']   = $total_added;
        $summary['total_updated'] = $total_updated;
        $summary['total_skipped'] = $total_skipped;

        if ( ! empty( $audit_context_rows ) ) {
            $summary['audit_connection_snapshots'] = self::build_connection_snapshots_from_audit_context_rows( $audit_context_rows );
            $last_line                             = $audit_context_rows[ count( $audit_context_rows ) - 1 ];
            $last_norm                             = self::normalize_audit_context_snapshot_for_summary( $last_line );
            foreach ( $last_norm as $ctx_key => $ctx_val ) {
                $summary[ $ctx_key ] = $ctx_val;
            }
        }

        $start_ts = isset( $header['start_ts'] ) ? (int) $header['start_ts'] : 0;
        $end_ts   = isset( $summary['end_ts'] ) ? (int) $summary['end_ts'] : time();
        $duration = $end_ts - $start_ts;

        $summary['duration_seconds'] = $duration;
        $summary['duration_human']   = self::format_duration( $duration );

        return array(
            'header'             => $header,
            'summary'            => $summary,
            'products'           => $product_rows,
            'session_path'       => $session_path,
            'total_product_rows' => $product_line_count,
        );
    }

    /**
     * Format seconds into a human-readable duration string.
     *
     * @param  int $seconds Total seconds.
     * @return string E.g. "2h 5m 30s".
     */
    private static function format_duration( $seconds )
    {
        $seconds = max(0, (int) $seconds);
        $h       = (int) ( $seconds / 3600 );
        $m       = (int) ( ( $seconds % 3600 ) / 60 );
        $s       = $seconds % 60;

        $parts = array();
        if ($h > 0 ) {
            $parts[] = $h . 'h';
        }
        if ($m > 0 ) {
            $parts[] = $m . 'm';
        }
        $parts[] = $s . 's';

        return implode( ' ', $parts );
    }

    /**
     * Delete all audit CSV files beyond MAX_AUDIT_FILES for a supplier.
     *
     * @param  int $supplier_id Supplier ID.
     * @return void
     */
    private static function rotate_audit_files( $supplier_id )
    {
        $dir = self::get_supplier_audit_dir($supplier_id);
        if ( ! is_dir($dir) ) {
            return;
        }

        $files = glob($dir . DIRECTORY_SEPARATOR . 'audit_*.csv');
        if ( false === $files || count($files) <= self::MAX_AUDIT_FILES ) {
            return;
        }

        usort(
            $files,
            function ( $a, $b ) {
                return filemtime($b) - filemtime($a);
            }
        );

        foreach ( array_slice($files, self::MAX_AUDIT_FILES) as $old_file ) {
            wp_delete_file($old_file);
        }
    }

    /**
     * Return the absolute path to the active session file for a supplier sync.
     *
     * @param  int      $supplier_id Supplier ID.
     * @param  int|null $schedule_id Schedule ID.
     * @return string
     */
    public static function get_session_path( $supplier_id, $schedule_id )
    {
        $dir    = self::get_supplier_audit_dir($supplier_id);
        $sid_str = self::schedule_id_to_session_segment($schedule_id);
        return $dir . DIRECTORY_SEPARATOR . 'session_' . (int) $supplier_id . '_' . $sid_str . '.jsonl';
    }

    /**
     * Map schedule id to session filename segment.
     * Treats null, 0, false, and empty string as "manual" so start/finalize always use the same file.
     *
     * @param  int|string|null|false $schedule_id Schedule id from sync context.
     * @return string Filename segment after the supplier id (e.g. manual or numeric schedule id).
     */
    private static function schedule_id_to_session_segment( $schedule_id )
    {
        if (null === $schedule_id || false === $schedule_id || '' === $schedule_id ) {
            return 'manual';
        }
        $n = (int) $schedule_id;
        if (0 === $n ) {
            return 'manual';
        }
        return (string) $n;
    }

    /**
     * Return the directory path for a supplier's audit files.
     *
     * @param  int $supplier_id Supplier ID.
     * @return string
     */
    public static function get_supplier_audit_dir( $supplier_id )
    {
        return wp_normalize_path(self::AUDIT_BASE_DIR . DIRECTORY_SEPARATOR . (int) $supplier_id);
    }

    /**
     * True when Common set_product_fields was invoked from Premium's wrapper (final audit is logged there).
     *
     * @return bool
     */
    /**
     * Resolve variable product role and parent reference for audit columns.
     *
     * @param  object $mo_product Product handler.
     * @return array { sync_role: string, parent_wc_id: int, parent_sku: string }
     */
    public static function get_sync_role_meta( $mo_product )
    {
        if ( ! is_object($mo_product) ) {
            return array(
                'sync_role'    => 'simple',
                'parent_wc_id' => 0,
                'parent_sku'   => '',
            );
        }
        $structure = isset($mo_product->structure) ? (string) $mo_product->structure : '';
        if (Constants::VARIABLE !== $structure ) {
            return array(
                'sync_role'    => 'simple',
                'parent_wc_id' => 0,
                'parent_sku'   => '',
            );
        }
        $parent_id = isset($mo_product->parent_product_id) ? (int) $mo_product->parent_product_id : 0;
        if ($parent_id > 0 ) {
            $parent_sku = '';
            if (function_exists('wc_get_product') ) {
                $p = wc_get_product($parent_id);
                if ($p && is_object($p) ) {
                    $parent_sku = (string) $p->get_sku();
                }
            }
            return array(
                'sync_role'    => 'variation',
                'parent_wc_id' => $parent_id,
                'parent_sku'   => $parent_sku,
            );
        }
        return array(
            'sync_role'    => 'variable_parent',
            'parent_wc_id' => 0,
            'parent_sku'   => '',
        );
    }

    /**
     * Build a compact, readable field snapshot for audit (not full object dump).
     *
     * @param  object $mo_product Product handler.
     * @return array
     */
    public static function build_product_audit_fields( $mo_product )
    {
        if ( ! is_object($mo_product) ) {
            return array();
        }
        $out = array();

        if ( isset($mo_product->unique_identifier) ) {
            $uid = $mo_product->unique_identifier;
            if (is_array($uid) ) {
                $parts = array();
                foreach ( $uid as $uk => $uv ) {
                    $parts[] = sanitize_key((string) $uk) . ': ' . ( is_scalar($uv) ? (string) $uv : wp_json_encode($uv) );
                }
                $out['unique_identifier'] = implode('; ', $parts);
            } else {
                $out['unique_identifier'] = (string) $uid;
            }
        }

        $scalar_keys = array( 'sku', 'regular_price', 'sale_price', 'stock_quantity', 'product_status' );
        foreach ( $scalar_keys as $sk ) {
            if (isset($mo_product->$sk) && null !== $mo_product->$sk && '' !== $mo_product->$sk ) {
                $out[ $sk ] = is_scalar($mo_product->$sk) ? $mo_product->$sk : wp_json_encode($mo_product->$sk);
            }
        }

        $out['batch_number'] = self::$sync_loop_batch_number;
        $out['page_number']  = self::$sync_loop_page_number;

        if ( isset($mo_product->attribute) && ! empty($mo_product->attribute) ) {
            $attr_str = self::format_attributes_for_audit($mo_product->attribute);
            if ( '' !== $attr_str ) {
                $out['attributes'] = $attr_str;
            }
        }

        return $out;
    }

    /**
     * Format WC/API attribute data as "Label: value; Label: value".
     *
     * @param  mixed $attribute Raw attribute structure from MO product.
     * @return string
     */
    public static function format_attributes_for_audit( $attribute )
    {
        if ( empty($attribute) ) {
            return '';
        }
        if ( is_string($attribute) ) {
            return sanitize_text_field($attribute);
        }
        if ( ! is_array($attribute) ) {
            return '';
        }
        $pairs = array();

        foreach ( $attribute as $key => $item ) {
            if (is_scalar($item) && null !== $item && '' !== $item ) {
                $pairs[] = sanitize_key((string) $key) . ': ' . sanitize_text_field((string) $item);
                continue;
            }
            if ( is_array($item) ) {
                $label = '';
                $val   = '';
                if ( isset($item['name']) ) {
                    $label = (string) $item['name'];
                } elseif ( isset($item['label']) ) {
                    $label = (string) $item['label'];
                } else {
                    $label = is_string($key) ? $key : '';
                }
                if ( isset($item['value']) ) {
                    $val = is_array($item['value']) ? implode(', ', $item['value']) : (string) $item['value'];
                } elseif (isset($item['options']) && is_array($item['options']) ) {
                    $val = implode(', ', $item['options']);
                }
                if ( '' !== $val ) {
                    $pairs[] = sanitize_text_field($label ? $label : $key) . ': ' . sanitize_text_field($val);
                }
            }
        }

        return implode('; ', $pairs);
    }

    /**
     * Back-compat alias for build_product_audit_fields().
     *
     * @param  object $mo_product Product handler.
     * @return array
     */
    public static function extract_mo_product_fields_for_audit( $mo_product )
    {
        return self::build_product_audit_fields($mo_product);
    }

    /**
     * Log audit from Common Sync_Utils::set_product_fields return value (standard / non-Premium path only).
     *
     * @param  object     $mowcps_product Product handler.
     * @param  string     $operation      Operation: add, update, or skip.
     * @param  array|bool $result                            Return value from set_product_fields.
     * @param  bool       $skip_success_audit_premium_path When true, success rows are logged only by Premium (caller wraps Common).
     * @return void
     */
    public static function log_from_common_set_product_fields( $mowcps_product, $operation, $result, $skip_success_audit_premium_path = false )
    {
        if ( ! is_object($mowcps_product) ) {
            return;
        }

        $supplier_id = Sync_Details_DAO::get_instance()->get_supplier_id();
        $schedule_id = Sync_Details_DAO::get_instance()->get_schedule_id();
        if ( ! self::is_enabled( $supplier_id ) ) {
            return;
        }

        $ptype       = isset($mowcps_product->structure) ? (string) $mowcps_product->structure : 'simple';
        $fields      = self::extract_mo_product_fields_for_audit($mowcps_product);
        $audit_extra = self::get_sync_role_meta($mowcps_product);

        // Duplicate add skip: always log (Premium wraps Common; duplicate happens inside Common).
        if ( is_array($result) && ! empty($result['audit_duplicate_add']) && isset($result['id']) ) {
            self::log_product( $supplier_id, $schedule_id, $fields, 'skip', true, 'Product already exists (add skipped)', (int) $result['id'], $ptype, $audit_extra );
            return;
        }

        // Save failure: always log (including when Premium calls Common).
        if ( false === $result ) {
            self::log_product( $supplier_id, $schedule_id, $fields, $operation, false, 'Product save failed', 0, $ptype, $audit_extra );
            return;
        }

        // Success path: Premium logs one consolidated row after its own pipeline.
        if ( $skip_success_audit_premium_path ) {
            return;
        }

        if ( is_array($result) && isset($result['id']) ) {
            self::log_product( $supplier_id, $schedule_id, $fields, $operation, true, '', (int) $result['id'], $ptype, $audit_extra);
        }
    }

    /**
     * Log audit after Premium Sync_Utils::set_product_fields completes (full field snapshot including premium-only data).
     *
     * @param  object $mo_product            Product handler.
     * @param  string $operation             Operation: add or update.
     * @param  array  $product_created_array Result array with id and obj.
     * @return void
     */
    public static function log_from_premium_set_product_fields( $mo_product, $operation, $product_created_array )
    {
        if ( ! is_object($mo_product) || ! is_array($product_created_array) ) {
            return;
        }
        // Common already logged "skip" for duplicate add; do not add a second success row.
        if ( ! empty($product_created_array['audit_duplicate_add']) ) {
            return;
        }
        $supplier_id = Sync_Details_DAO::get_instance()->get_supplier_id();
        $schedule_id = Sync_Details_DAO::get_instance()->get_schedule_id();
        if ( ! self::is_enabled( $supplier_id ) ) {
            return;
        }
        $wc_id       = isset($product_created_array['id']) ? (int) $product_created_array['id'] : 0;
        $ptype       = isset($mo_product->structure) ? (string) $mo_product->structure : 'simple';
        $fields      = self::extract_mo_product_fields_for_audit( $mo_product );
        $audit_extra = self::get_sync_role_meta( $mo_product );
        if ($wc_id > 0 ) {
            self::log_product( $supplier_id, $schedule_id, $fields, $operation, true, '', $wc_id, $ptype, $audit_extra );
        }
    }

    /**
     * Log when MO_Product::save() returns early (e.g. updates disabled) without going through set_product_fields.
     *
     * @param  object $handler MO product handler ($this from save()).
     * @param  int    $wc_id   WooCommerce product ID.
     * @param  string $reason  Human-readable reason.
     * @return void
     */
    public static function log_handler_save_short_circuit( $handler, $wc_id, $reason )
    {
        if ( ! is_object( $handler ) ) {
            return;
        }
        $supplier_id = Sync_Details_DAO::get_instance()->get_supplier_id();
        $schedule_id = Sync_Details_DAO::get_instance()->get_schedule_id();
        if ( ! self::is_enabled( $supplier_id ) ) {
            return;
        }
        $fields      = self::extract_mo_product_fields_for_audit( $handler );
        $ptype       = isset($handler->structure) ? (string) $handler->structure : 'simple';
        $audit_extra = self::get_sync_role_meta( $handler );
        self::log_product( $supplier_id, $schedule_id, $fields, 'skip', true, sanitize_text_field($reason), (int) $wc_id, $ptype, $audit_extra );
    }
}
