<?php
/**
 * Cast Conductor Proprietary License v5
 * SPDX-License-Identifier: LicenseRef-CastConductor-Proprietary-v5
 * 
 * Copyright (c) 2025 CastConductor.com. All Rights Reserved.
 * See LICENSE and EULA-v5.2.md for full terms.
 */

/**
 * CastConductor Analytics Ingestion
 * 
 * Handles validation and storage of incoming analytics events from Roku devices.
 * Implements rate limiting, event validation, and session tracking.
 * 
 * @package CastConductor
 * @subpackage Analytics
 * @since 5.8.0
 */

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

class CC_Analytics_Ingestion {

    /**
     * Maximum events per batch
     */
    const MAX_BATCH_SIZE = 100;

    /**
     * Rate limit: max events per minute per device
     */
    const RATE_LIMIT_PER_MINUTE = 100;

    /**
     * Single instance
     */
    private static $instance = null;

    /**
     * Database instance
     */
    private $db;

    /**
     * Get singleton instance
     */
    public static function instance() {
        if (null === self::$instance) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Constructor
     */
    private function __construct() {
        $this->db = CC_Analytics_Database::instance();
    }

    /**
     * Process a batch of events from a Roku device
     * 
     * @param array $batch Batch data with 'events' array
     * @param string $device_hash Device ID hash for rate limiting
     * @return array|WP_Error Processing result
     */
    public function process_batch($batch, $device_hash) {
        // Validate batch structure
        if (!isset($batch['events']) || !is_array($batch['events'])) {
            return new WP_Error(
                'invalid_batch',
                'Batch must contain an events array',
                ['status' => 400]
            );
        }
        
        $events = $batch['events'];
        
        // Enforce max batch size
        if (count($events) > self::MAX_BATCH_SIZE) {
            return new WP_Error(
                'batch_too_large',
                'Maximum batch size is ' . self::MAX_BATCH_SIZE . ' events',
                ['status' => 400]
            );
        }
        
        // Check rate limit
        $rate_check = $this->check_rate_limit($device_hash, count($events));
        if (is_wp_error($rate_check)) {
            return $rate_check;
        }
        
        $processed = 0;
        $errors = [];
        
        foreach ($events as $index => $event) {
            $result = $this->process_event($event);
            
            if (is_wp_error($result)) {
                $errors[] = [
                    'index' => $index,
                    'error' => $result->get_error_message(),
                ];
            } else {
                $processed++;
            }
        }
        
        return [
            'success'   => true,
            'processed' => $processed,
            'errors'    => $errors,
        ];
    }

    /**
     * Process a single event
     * 
     * @param array $event Event data
     * @return int|WP_Error Event ID or error
     */
    public function process_event($event) {
        // Validate required fields
        $validation = $this->validate_event($event);
        if (is_wp_error($validation)) {
            return $validation;
        }
        
        // Update session last_activity_at to keep it "alive"
        // This ensures long-running sessions aren't marked as stale
        if (!empty($event['session_id'])) {
            $this->db->touch_session($event['session_id']);
        }
        
        // Check if this content type should be collected
        if ($this->should_skip_event($event)) {
            // Return success but don't store - event was intentionally skipped
            return 0;
        }
        
        // Normalize event data
        $normalized = $this->normalize_event($event);
        
        // Handle special event types (some handlers expand to multiple events)
        $this->handle_special_events($normalized);
        
        // Skip storing the raw batch event - the handler already expanded it
        // into individual impression events with proper types
        if ($event['event_type'] === 'impression_batch') {
            return 0; // Return success - data was processed by handler
        }
        
        // Store the event
        $event_id = $this->db->insert_event($normalized);
        
        if (!$event_id) {
            return new WP_Error(
                'storage_failed',
                'Failed to store event',
                ['status' => 500]
            );
        }
        
        // Dispatch to integrations (if configured)
        do_action('cc_analytics_event_stored', $normalized, $event_id);
        
        return $event_id;
    }

    /**
     * Check if an event should be skipped based on collection settings
     * 
     * @param array $event Event data
     * @return bool True if event should be skipped (not stored)
     */
    private function should_skip_event($event) {
        $event_type = $event['event_type'] ?? '';
        
        // Only filter impression events (container_impression, promo_impression)
        if (!in_array($event_type, ['container_impression', 'promo_impression'], true)) {
            return false;
        }
        
        // Extract content block type from properties
        $properties = $event['properties'] ?? [];
        if (is_string($properties)) {
            $properties = json_decode($properties, true) ?? [];
        }
        
        $block_type = $properties['content_block_type'] ?? '';
        
        // Check if this type should be collected
        if (!CC_Analytics_Helpers::should_collect_type($block_type)) {
            return true; // Skip this event
        }
        
        return false;
    }

    /**
     * Validate event structure and required fields
     * 
     * @param array $event Event data
     * @return true|WP_Error True if valid, error otherwise
     */
    private function validate_event($event) {
        // Required fields
        $required = ['event_type', 'session_id', 'device_id_hash', 'timestamp'];
        
        foreach ($required as $field) {
            if (empty($event[$field])) {
                return new WP_Error(
                    'missing_field',
                    "Missing required field: {$field}",
                    ['status' => 400]
                );
            }
        }
        
        // Validate event type
        if (!CC_Analytics_Helpers::is_valid_event_type($event['event_type'])) {
            return new WP_Error(
                'invalid_event_type',
                "Unknown event type: {$event['event_type']}",
                ['status' => 400]
            );
        }
        
        // Validate timestamp format
        $timestamp = strtotime($event['timestamp']);
        if ($timestamp === false) {
            return new WP_Error(
                'invalid_timestamp',
                'Invalid timestamp format',
                ['status' => 400]
            );
        }
        
        // Reject timestamps too far in the future (allow 5 min clock drift)
        if ($timestamp > time() + 300) {
            return new WP_Error(
                'future_timestamp',
                'Timestamp is in the future',
                ['status' => 400]
            );
        }
        
        // Reject timestamps too old (more than 7 days)
        if ($timestamp < time() - (7 * DAY_IN_SECONDS)) {
            return new WP_Error(
                'stale_timestamp',
                'Timestamp is too old',
                ['status' => 400]
            );
        }
        
        return true;
    }

    /**
     * Normalize event data for storage
     * 
     * @param array $event Raw event data
     * @return array Normalized event
     */
    private function normalize_event($event) {
        return [
            'event_type'     => sanitize_text_field($event['event_type']),
            'session_id'     => sanitize_text_field($event['session_id']),
            'device_id_hash' => sanitize_text_field($event['device_id_hash']),
            'timestamp'      => CC_Analytics_Helpers::iso_to_mysql($event['timestamp']),
            'properties'     => $this->sanitize_properties($event['properties'] ?? []),
            'context'        => $this->sanitize_context($event['context'] ?? []),
        ];
    }

    /**
     * Sanitize event properties
     * 
     * @param array $properties Raw properties
     * @return array Sanitized properties
     */
    private function sanitize_properties($properties) {
        if (!is_array($properties)) {
            return [];
        }
        
        $sanitized = [];
        
        foreach ($properties as $key => $value) {
            $key = sanitize_key($key);
            
            if (is_string($value)) {
                $sanitized[$key] = sanitize_text_field($value);
            } elseif (is_numeric($value)) {
                $sanitized[$key] = $value;
            } elseif (is_bool($value)) {
                $sanitized[$key] = $value;
            } elseif (is_array($value)) {
                // Handle nested arrays - check if it's an array of objects or flat array
                if (!empty($value) && isset($value[0]) && is_array($value[0])) {
                    // Array of objects (like impressions array) - recursively sanitize each
                    $sanitized[$key] = array_map([$this, 'sanitize_properties'], $value);
                } else {
                    // Flat array of scalar values
                    $sanitized[$key] = array_map(function($v) {
                        if (is_string($v)) return sanitize_text_field($v);
                        if (is_numeric($v) || is_bool($v)) return $v;
                        return null;
                    }, $value);
                }
            }
        }
        
        return $sanitized;
    }

    /**
     * Sanitize context data
     * 
     * @param array $context Raw context
     * @return array Sanitized context
     */
    private function sanitize_context($context) {
        if (!is_array($context)) {
            return [];
        }
        
        $sanitized = [];
        
        // Handle geo context
        if (isset($context['geo']) && is_array($context['geo'])) {
            $sanitized['geo'] = [
                'country'  => isset($context['geo']['country']) 
                    ? sanitize_text_field(substr($context['geo']['country'], 0, 2)) 
                    : null,
                'city'     => isset($context['geo']['city']) 
                    ? sanitize_text_field($context['geo']['city']) 
                    : null,
                'dma_code' => isset($context['geo']['dma_code']) 
                    ? sanitize_text_field($context['geo']['dma_code']) 
                    : null,
                'zip_code' => isset($context['geo']['zip_code']) 
                    ? sanitize_text_field($context['geo']['zip_code']) 
                    : null,
                'timezone' => isset($context['geo']['timezone']) 
                    ? sanitize_text_field($context['geo']['timezone']) 
                    : null,
            ];
        }
        
        // Handle device context
        if (isset($context['device']) && is_array($context['device'])) {
            $sanitized['device'] = [
                'model'      => isset($context['device']['model']) 
                    ? sanitize_text_field($context['device']['model']) 
                    : null,
                'os_version' => isset($context['device']['os_version']) 
                    ? sanitize_text_field($context['device']['os_version']) 
                    : null,
            ];
        }
        
        return $sanitized;
    }

    /**
     * Handle special event types that need additional processing
     * 
     * @param array $event Normalized event
     */
    private function handle_special_events($event) {
        switch ($event['event_type']) {
            case 'session_start':
                $this->handle_session_start($event);
                break;
                
            case 'session_geo_update':
                $this->handle_session_geo_update($event);
                break;
                
            case 'session_scene_update':
                $this->handle_session_scene_update($event);
                break;
                
            case 'scene_view':
                $this->handle_scene_view($event);
                break;
                
            case 'session_end':
                $this->handle_session_end($event);
                break;
                
            case 'session_restoration':
                $this->handle_session_restoration($event);
                break;
                
            case 'qr_scan':
                $this->handle_qr_scan($event);
                break;
                
            case 'impression_batch':
                $this->handle_impression_batch($event);
                break;
        }
    }

    /**
     * Handle session_start event
     * 
     * When a new session starts for a device, we automatically end any previous
     * active sessions for that same device. A device can only have one active
     * session at a time - if the app was force-closed without sending session_end,
     * this ensures stale sessions don't persist.
     * 
     * @param array $event Event data
     */
    private function handle_session_start($event) {
        global $wpdb;
        
        $device_id_hash = $event['device_id_hash'] ?? null;
        $session_id = $event['session_id'] ?? null;
        
        // End any previous active sessions for this device
        // (A device can only have one active session at a time)
        if (!empty($device_id_hash) && !empty($session_id)) {
            $sessions_table = CC_Analytics_Database::table(CC_Analytics_Database::TABLE_SESSIONS);
            
            // Find and end any sessions that are still "active" (no ended_at) for this device
            $wpdb->query($wpdb->prepare(
                "UPDATE {$sessions_table} 
                 SET ended_at = %s,
                     duration_seconds = TIMESTAMPDIFF(SECOND, started_at, %s)
                 WHERE device_id_hash = %s 
                 AND session_id != %s
                 AND ended_at IS NULL",
                $event['timestamp'],
                $event['timestamp'],
                $device_id_hash,
                $session_id
            ));
        }
        
        $geo = $event['context']['geo'] ?? [];
        $device = $event['context']['device'] ?? [];
        
        $this->db->upsert_session([
            'session_id'     => $event['session_id'],
            'device_id_hash' => $event['device_id_hash'],
            'started_at'     => $event['timestamp'],
            'is_restoration' => false,
            'country_code'   => $geo['country'] ?? null,
            'city'           => $geo['city'] ?? null,
            'dma_code'       => $geo['dma_code'] ?? null,
            'zip_code'       => $geo['zip_code'] ?? null,
            'timezone'       => $geo['timezone'] ?? null,
            'device_model'   => $device['model'] ?? null,
            'os_version'     => $device['os_version'] ?? null,
        ]);
    }

    /**
     * Handle session_geo_update event
     * Updates an existing session with location data (called after LocationTask returns)
     * 
     * @param array $event Event data
     */
    private function handle_session_geo_update($event) {
        global $wpdb;
        
        $props = $event['properties'] ?? [];
        $session_id = $event['session_id'] ?? null;
        
        if (empty($session_id)) {
            return;
        }
        
        $table = CC_Analytics_Database::table(CC_Analytics_Database::TABLE_SESSIONS);
        
        // Build update data, only update fields that have values
        $update_data = [];
        $update_formats = [];
        
        if (!empty($props['city'])) {
            $update_data['city'] = $props['city'];
            $update_formats[] = '%s';
        }
        if (!empty($props['zip_code'])) {
            $update_data['zip_code'] = $props['zip_code'];
            $update_formats[] = '%s';
        }
        if (!empty($props['timezone'])) {
            $update_data['timezone'] = $props['timezone'];
            $update_formats[] = '%s';
        }
        if (!empty($props['country'])) {
            $update_data['country_code'] = $props['country'];
            $update_formats[] = '%s';
        }
        
        if (!empty($update_data)) {
            $wpdb->update(
                $table,
                $update_data,
                ['session_id' => $session_id],
                $update_formats,
                ['%s']
            );
        }
    }

    /**
     * Handle session_scene_update event
     * Updates an existing session with the current scene (called when scene changes)
     * 
     * @param array $event Event data
     */
    private function handle_session_scene_update($event) {
        global $wpdb;
        
        $props = $event['properties'] ?? [];
        $session_id = $event['session_id'] ?? null;
        
        if (empty($session_id)) {
            return;
        }
        
        $scene_id = $props['scene_id'] ?? 0;
        $scene_name = $props['scene_name'] ?? '';
        
        if (empty($scene_id)) {
            return;
        }
        
        $table = CC_Analytics_Database::table(CC_Analytics_Database::TABLE_SESSIONS);
        
        // Update session with current scene and increment scene count
        $wpdb->query($wpdb->prepare(
            "UPDATE {$table} 
             SET current_scene_id = %d, 
                 current_scene_name = %s,
                 scene_count = scene_count + 1
             WHERE session_id = %s",
            $scene_id,
            $scene_name,
            $session_id
        ));
    }

    /**
     * Handle scene_view event
     * Tracks that a user viewed a scene (for Scene Views metric)
     * 
     * @param array $event Event data
     */
    private function handle_scene_view($event) {
        // Scene views are stored as raw events and aggregated
        // No special processing needed - just let the event be stored
        // The aggregation job will count scene_view events by scene_id
    }

    /**
     * Handle session_end event
     * 
     * @param array $event Event data
     */
    private function handle_session_end($event) {
        $props = $event['properties'] ?? [];
        
        // Duration can be in properties.duration_seconds OR top-level session_duration (Roku sends the latter)
        $duration = $props['duration_seconds'] 
            ?? $event['session_duration'] 
            ?? 0;
        
        $this->db->upsert_session([
            'session_id'       => $event['session_id'],
            'ended_at'         => $event['timestamp'],
            'duration_seconds' => (int) $duration,
            'scene_count'      => $props['scene_count'] ?? 0,
        ]);
        
        // Update device lifetime view seconds
        if (!empty($event['device_id_hash']) && $duration > 0) {
            $this->db->update_device_lifetime_stats(
                $event['device_id_hash'],
                (int) $duration,
                0 // impressions counted separately
            );
        }
    }

    /**
     * Handle session_restoration event (logged but not counted as new session)
     * 
     * @param array $event Event data
     */
    private function handle_session_restoration($event) {
        $geo = $event['context']['geo'] ?? [];
        $device = $event['context']['device'] ?? [];
        
        $this->db->upsert_session([
            'session_id'     => $event['session_id'],
            'device_id_hash' => $event['device_id_hash'],
            'started_at'     => $event['timestamp'],
            'is_restoration' => true,
            'country_code'   => $geo['country'] ?? null,
            'city'           => $geo['city'] ?? null,
            'dma_code'       => $geo['dma_code'] ?? null,
            'zip_code'       => $geo['zip_code'] ?? null,
            'timezone'       => $geo['timezone'] ?? null,
            'device_model'   => $device['model'] ?? null,
            'os_version'     => $device['os_version'] ?? null,
        ]);
    }

    /**
     * Handle QR scan event
     * 
     * @param array $event Event data
     */
    private function handle_qr_scan($event) {
        global $wpdb;
        
        $props = $event['properties'] ?? [];
        
        $table = CC_Analytics_Database::table(CC_Analytics_Database::TABLE_QR_SCANS);
        
        $wpdb->insert(
            $table,
            [
                'scan_id'         => 'qr_' . bin2hex(random_bytes(16)),
                'session_id'      => $event['session_id'],
                'promo_id'        => $props['promo_id'] ?? null,
                'sponsor_id'      => $props['sponsor_id'] ?? null,
                'destination_url' => $props['destination_url'] ?? null,
                'utm_source'      => $props['utm_source'] ?? 'castconductor',
                'utm_medium'      => $props['utm_medium'] ?? 'roku',
                'utm_campaign'    => $props['utm_campaign'] ?? null,
                'utm_content'     => $props['utm_content'] ?? null,
                'scanned_at'      => $event['timestamp'],
            ],
            ['%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s']
        );
    }

    /**
     * Handle impression_batch event
     * 
     * Batched impressions from Roku devices contain multiple impression counts
     * in a single event. We expand these into individual events with count field
     * for proper aggregation while maintaining storage efficiency.
     * 
     * @param array $event Event data with properties.impressions array
     */
    private function handle_impression_batch($event) {
        $props = $event['properties'] ?? [];
        $impressions = $props['impressions'] ?? [];
        
        if (empty($impressions) || !is_array($impressions)) {
            return;
        }
        
        // Check collection settings before storing
        foreach ($impressions as $impression) {
            $block_type = $impression['content_block_type'] ?? '';
            
            // Skip if this block type is disabled in collection settings
            if (!CC_Analytics_Helpers::should_collect_type($block_type)) {
                continue;
            }
            
            $count = (int) ($impression['count'] ?? 1);
            $is_promo = $impression['is_promo'] ?? false;
            
            // Build properties for storage
            $stored_props = [
                'content_block_id'   => $impression['content_block_id'] ?? 0,
                'content_block_type' => $block_type,
                'container_id'       => $impression['container_id'] ?? 0,
                'count'              => $count,
            ];
            
            // Add content item tracking (individual sponsor/promo/shoutout)
            if (!empty($impression['content_item_id'])) {
                $stored_props['content_item_id'] = (int) $impression['content_item_id'];
            }
            if (!empty($impression['content_item_title'])) {
                $stored_props['content_item_title'] = sanitize_text_field($impression['content_item_title']);
            }
            
            // Add sponsor/promo IDs if present
            if (!empty($impression['sponsor_id'])) {
                $stored_props['sponsor_id'] = (int) $impression['sponsor_id'];
            }
            if (!empty($impression['promo_id'])) {
                $stored_props['promo_id'] = (int) $impression['promo_id'];
            }
            
            // Determine event type based on is_promo flag
            $event_type = $is_promo ? 'promo_impression' : 'container_impression';
            
            // Create an expanded event for storage
            // The count field allows aggregation to sum counts rather than count rows
            $expanded_event = [
                'event_type'     => $event_type,
                'session_id'     => $event['session_id'],
                'device_id_hash' => $event['device_id_hash'],
                'timestamp'      => $event['timestamp'],
                'properties'     => $stored_props, // Pass as array - insert_event will JSON encode
                'created_at'     => CC_Analytics_Helpers::now_utc(),
            ];
            
            // Insert directly without going through process_event to avoid recursion
            $this->db->insert_event($expanded_event);
        }
        
        // Update device impression count
        $total_impressions = 0;
        foreach ($impressions as $impression) {
            $total_impressions += (int) ($impression['count'] ?? 1);
        }
        
        if ($total_impressions > 0 && !empty($event['device_id_hash'])) {
            $this->db->update_device_lifetime_stats(
                $event['device_id_hash'],
                0, // view_seconds - not applicable here
                $total_impressions
            );
        }
    }

    /**
     * Check rate limit for a device
     * 
     * @param string $device_hash Device ID hash
     * @param int $event_count Number of events in batch
     * @return true|WP_Error True if within limit, error if exceeded
     */
    private function check_rate_limit($device_hash, $event_count) {
        $rate_key = 'cc_analytics_rate_' . $device_hash;
        $current_count = (int) get_transient($rate_key);
        
        if ($current_count + $event_count > self::RATE_LIMIT_PER_MINUTE) {
            return new WP_Error(
                'rate_limited',
                'Rate limit exceeded. Maximum ' . self::RATE_LIMIT_PER_MINUTE . ' events per minute.',
                ['status' => 429]
            );
        }
        
        // Update rate counter
        set_transient($rate_key, $current_count + $event_count, 60);
        
        return true;
    }
}
