<?php
if (!defined('ABSPATH')) exit;

class OpsGate_REST {
  public static function init(){
    add_action('rest_api_init', function(){
      register_rest_route('opsgate/v1','/inventory', [
        'methods'  => 'GET',
        'permission_callback' => [__CLASS__,'allow_inventory'],
        'callback' => [__CLASS__,'inventory'],
      ]);
      register_rest_route('opsgate/v1','/remediation', [
        'methods'  => 'POST',
        'permission_callback' => [__CLASS__,'allow_inventory'],
        'callback' => [__CLASS__,'remediation'],
      ]);
      register_rest_route('opsgate/v1','/admin-session', [
        'methods'  => 'POST',
        'permission_callback' => '__return_true',
        'callback' => [__CLASS__,'admin_session'],
      ]);
      register_rest_route('opsgate/v1','/error-report', [
        'methods'  => 'POST',
        'permission_callback' => '__return_true',
        'callback' => [__CLASS__,'error_report'],
      ]);
    });

    // Register PHP error capture handler on plugin init.
    // Runs only if the central OpsGate API is configured (site credential present).
    $has_credential = '' !== (string) get_option(OPSGATE_OPTION_CONNECTOR_SITE_CREDENTIAL, '');
    if ($has_credential) {
      set_error_handler([__CLASS__, 'capture_php_error'], E_ALL & ~E_DEPRECATED & ~E_STRICT);
      register_shutdown_function([__CLASS__, 'capture_shutdown_error']);
    }
  }

  public static function allow_inventory($request){
    $incoming = (string) $request->get_header('x-opsgate-site-credential');
    $expected = function_exists('opsgate_decrypt_connector_secret')
      ? (string) opsgate_decrypt_connector_secret((string) get_option(OPSGATE_OPTION_CONNECTOR_SITE_CREDENTIAL, ''))
      : '';
    return $expected !== '' && hash_equals($expected, $incoming);
  }

  private static function recent_log_lines($path, $max_bytes = 65536, $max_lines = 200){
    $path = is_string($path) ? trim($path) : '';
    if ('' === $path || !file_exists($path) || !is_readable($path)) {
      return [];
    }
    $size = filesize($path);
    $handle = @fopen($path, 'rb');
    if (!$handle) {
      return [];
    }
    if (is_int($size) && $size > $max_bytes) {
      fseek($handle, -1 * (int) $max_bytes, SEEK_END);
    }
    $raw = stream_get_contents($handle);
    fclose($handle);
    if (!is_string($raw) || '' === $raw) {
      return [];
    }
    $lines = preg_split('/\r\n|\r|\n/', $raw);
    if (!is_array($lines)) {
      return [];
    }
    $lines = array_values(array_filter(array_map('trim', $lines), static function($line){
      return '' !== $line;
    }));
    if (count($lines) > $max_lines) {
      $lines = array_slice($lines, -1 * (int) $max_lines);
    }
    return $lines;
  }

  private static function classify_log_signal($line, $source_family){
    $message = trim((string) $line);
    if ('' === $message) {
      return null;
    }
    $normalized = strtolower($message);
    $severity = '';
    $signal_key = '';
    if (false !== strpos($normalized, 'fatal') || false !== strpos($normalized, 'uncaught')) {
      $severity = 'critical';
      $signal_key = 'runtime_fatal';
    } elseif (false !== strpos($normalized, 'database error') || false !== strpos($normalized, 'wpdb')) {
      $severity = 'high';
      $signal_key = 'database_error';
    } elseif ((false !== strpos($normalized, 'payment') || false !== strpos($normalized, 'gateway')) && false !== strpos($normalized, 'error')) {
      $severity = 'high';
      $signal_key = 'payment_gateway_error';
    } elseif ((false !== strpos($normalized, 'mail') || false !== strpos($normalized, 'smtp')) && false !== strpos($normalized, 'error')) {
      $severity = 'warning';
      $signal_key = 'mail_delivery_error';
    } elseif (false !== strpos($normalized, 'exception') || false !== strpos($normalized, 'error')) {
      $severity = 'high';
      $signal_key = 'application_error';
    } elseif (false !== strpos($normalized, 'warning')) {
      $severity = 'warning';
      $signal_key = 'runtime_warning';
    } elseif (false !== strpos($normalized, 'deprecated') || false !== strpos($normalized, 'notice')) {
      $severity = 'low';
      $signal_key = 'runtime_notice';
    }
    if ('' === $signal_key) {
      return null;
    }
    return [
      'source_family' => (string) $source_family,
      'signal_key' => $signal_key,
      'severity' => $severity,
      'latest_message' => function_exists('mb_substr') ? mb_substr($message, 0, 240) : substr($message, 0, 240),
    ];
  }

  private static function collect_operational_log_intelligence(){
    $sources = [];
    $signals_map = [];
    $source_specs = [];

    if (defined('WP_CONTENT_DIR')) {
      $source_specs[] = [
        'family' => 'wordpress_debug',
        'path' => WP_CONTENT_DIR . '/debug.log',
        'label' => 'WordPress debug log',
        'kind' => 'file',
      ];

      $woocommerce_present = (defined('WP_PLUGIN_DIR') && is_dir(WP_PLUGIN_DIR . '/woocommerce')) || class_exists('WooCommerce');
      $wc_dirs = [WP_CONTENT_DIR . '/uploads/wc-logs', WP_CONTENT_DIR . '/uploads/woocommerce_logs'];
      $wc_dir_found = false;
      foreach ($wc_dirs as $wc_dir) {
        if (!is_dir($wc_dir)) {
          continue;
        }
        $wc_dir_found = true;
        $files = glob(trailingslashit($wc_dir) . '*.log*');
        if (!is_array($files) || empty($files)) {
          $source_specs[] = [
            'family' => 'woocommerce_log',
            'path' => (string) $wc_dir,
            'label' => 'WooCommerce runtime logs',
            'kind' => 'directory',
            'availability_status' => 'unavailable',
            'availability_reason' => 'no_log_files',
          ];
          continue;
        }
        usort($files, static function($left, $right){
          return (int) @filemtime((string) $right) <=> (int) @filemtime((string) $left);
        });
        foreach (array_slice($files, 0, 3) as $file) {
          $source_specs[] = [
            'family' => 'woocommerce_log',
            'path' => (string) $file,
            'label' => 'WooCommerce runtime log',
            'kind' => 'file',
          ];
        }
      }
      if ($woocommerce_present && !$wc_dir_found) {
        $source_specs[] = [
          'family' => 'woocommerce_log',
          'path' => (string) $wc_dirs[0],
          'label' => 'WooCommerce runtime logs',
          'kind' => 'directory',
          'availability_status' => 'unavailable',
          'availability_reason' => 'log_directory_missing',
        ];
      }
    }

    foreach ($source_specs as $spec) {
      $path = (string) ($spec['path'] ?? '');
      $kind = (string) ($spec['kind'] ?? 'file');
      $availability_status = (string) ($spec['availability_status'] ?? '');
      $availability_reason = (string) ($spec['availability_reason'] ?? '');
      if ('' === $path) {
        continue;
      }
      if ('' === $availability_status) {
        if (!file_exists($path)) {
          $availability_status = 'unavailable';
          $availability_reason = 'source_missing';
        } elseif (!is_readable($path)) {
          $availability_status = 'unavailable';
          $availability_reason = 'source_not_readable';
        } else {
          $availability_status = 'available';
          $availability_reason = 'source_readable';
        }
      }
      $observed_at = file_exists($path) ? @filemtime($path) : false;
      $source_entry = [
        'source_family' => (string) ($spec['family'] ?? 'log'),
        'label' => (string) ($spec['label'] ?? 'Log'),
        'source_file' => $path,
        'source_kind' => $kind,
        'availability_status' => $availability_status,
        'availability_reason' => $availability_reason,
        'latest_at_utc' => $observed_at ? gmdate('c', (int) $observed_at) : null,
      ];
      if ('available' !== $availability_status) {
        $sources[] = $source_entry;
        continue;
      }
      $lines = self::recent_log_lines($path);
      $source_entry['line_count_sampled'] = count($lines);
      $source_entry['latest_at_utc'] = $observed_at ? gmdate('c', (int) $observed_at) : gmdate('c');
      $sources[] = $source_entry;
      foreach ($lines as $line) {
        $signal = self::classify_log_signal($line, (string) ($spec['family'] ?? 'log'));
        if (!is_array($signal)) {
          continue;
        }
        $key = $signal['source_family'] . '|' . $signal['signal_key'] . '|' . $path;
        if (!isset($signals_map[$key])) {
          $signals_map[$key] = [
            'source_family' => $signal['source_family'],
            'signal_key' => $signal['signal_key'],
            'severity' => $signal['severity'],
            'event_count' => 0,
            'latest_message' => $signal['latest_message'],
            'latest_at_utc' => $observed_at ? gmdate('c', (int) $observed_at) : gmdate('c'),
            'source_file' => $path,
          ];
        }
        $signals_map[$key]['event_count']++;
        $signals_map[$key]['latest_message'] = $signal['latest_message'];
      }
    }

    $signals = array_values($signals_map);
    $available_source_count = count(array_filter($sources, static function($item){
      return 'available' === (string) ($item['availability_status'] ?? '');
    }));
    $unavailable_source_count = count($sources) - $available_source_count;
    usort($signals, static function($left, $right){
      $severity_rank = ['critical' => 0, 'high' => 1, 'warning' => 2, 'low' => 3];
      $left_rank = $severity_rank[(string) ($left['severity'] ?? 'low')] ?? 9;
      $right_rank = $severity_rank[(string) ($right['severity'] ?? 'low')] ?? 9;
      if ($left_rank !== $right_rank) {
        return $left_rank <=> $right_rank;
      }
      return (int) ($right['event_count'] ?? 0) <=> (int) ($left['event_count'] ?? 0);
    });

    return [
      'kind' => 'operational_log_intelligence',
      'generated_at_utc' => gmdate('c'),
      'summary' => [
        'signal_count' => count($signals),
        'critical_count' => count(array_filter($signals, static function($item){ return 'critical' === (string) ($item['severity'] ?? ''); })),
        'warning_count' => count(array_filter($signals, static function($item){ return in_array((string) ($item['severity'] ?? ''), ['warning', 'high'], true); })),
        'source_count' => count($sources),
        'available_source_count' => $available_source_count,
        'unavailable_source_count' => $unavailable_source_count,
      ],
      'sources' => $sources,
      'signals' => $signals,
    ];
  }

  private static function collect_configuration_validation(){
    $findings = [];
    $htaccess_path = trailingslashit(ABSPATH) . '.htaccess';
    $htaccess_exists = file_exists($htaccess_path);
    $htaccess_readable = $htaccess_exists && is_readable($htaccess_path);
    $htaccess_writable = $htaccess_exists && is_writable($htaccess_path);
    $htaccess_content = $htaccess_readable ? (string) @file_get_contents($htaccess_path) : '';
    $htaccess_review_state = 'complete';
    $htaccess_review_reason = 'checked';

    if ($htaccess_exists && !$htaccess_readable) {
      $htaccess_review_state = 'partial';
      $htaccess_review_reason = 'not_readable';
      $findings[] = [
        'finding_code' => 'htaccess_not_readable',
        'family' => 'htaccess',
        'severity' => 'warning',
        'posture' => 'review_required',
        'title' => '.htaccess could not be read',
        'summary' => 'OpsGate found the root .htaccess path, but the file was not readable during this run, so rewrite and deny rules could not be fully inspected.',
        'action_hint' => 'Restore readable access to the root configuration file or confirm host access constraints before relying on this validation result as complete.',
        'details' => ['path' => $htaccess_path],
      ];
    }

    if (!$htaccess_exists) {
      $htaccess_review_reason = 'missing_file';
      $findings[] = [
        'finding_code' => 'htaccess_missing',
        'family' => 'htaccess',
        'severity' => 'warning',
        'posture' => 'review_required',
        'title' => '.htaccess is missing',
        'summary' => 'No .htaccess file was found at the WordPress root, so server-side rewrite and deny rules could not be validated.',
        'action_hint' => 'Confirm whether this host uses Apache-compatible rewrite rules and restore the expected root configuration file if required.',
        'details' => ['path' => $htaccess_path],
      ];
    } elseif ('' !== $htaccess_content) {
      $content_normalized = strtolower($htaccess_content);
      if (false !== strpos($content_normalized, 'options +indexes')) {
        $findings[] = [
          'finding_code' => 'htaccess_directory_indexing',
          'family' => 'htaccess',
          'severity' => 'high',
          'posture' => 'review_required',
          'title' => 'Directory indexing is enabled in .htaccess',
          'summary' => 'The current .htaccess content enables directory listing, which can expose file structure and backups.',
          'action_hint' => 'Remove the indexing directive or replace it with Options -Indexes before treating this host as hardened.',
          'details' => ['path' => $htaccess_path],
        ];
      }
      if (preg_match('/php_(value|flag)\s+(auto_prepend_file|auto_append_file)/i', $htaccess_content)) {
        $findings[] = [
          'finding_code' => 'htaccess_php_bootstrap',
          'family' => 'htaccess',
          'severity' => 'critical',
          'posture' => 'review_required',
          'title' => 'Executable PHP bootstrap directive was found in .htaccess',
          'summary' => 'The root .htaccess file contains PHP bootstrap directives that can be used to inject code ahead of WordPress.',
          'action_hint' => 'Review the directive origin immediately and remove any unauthorized bootstrap instruction before the next share-out.',
          'details' => ['path' => $htaccess_path],
        ];
      }
      if (preg_match('/(base64_decode|eval\\s*\\(|shell_exec|passthru)/i', $htaccess_content)) {
        $findings[] = [
          'finding_code' => 'htaccess_suspicious_rewrite',
          'family' => 'htaccess',
          'severity' => 'critical',
          'posture' => 'review_required',
          'title' => 'Suspicious executable pattern was found in .htaccess',
          'summary' => 'The root .htaccess file contains text patterns commonly associated with malicious rewrite or execution behavior.',
          'action_hint' => 'Compare the file against known-good server rules and remove the suspicious block before relying on the site as healthy.',
          'details' => ['path' => $htaccess_path],
        ];
      }
    }

    $uploads_dir_exists = false;
    $uploads_dir_readable = false;
    $folder_review_state = 'unavailable';
    $folder_review_reason = 'wp_content_unavailable';
    if (defined('WP_CONTENT_DIR')) {
      $uploads_dir = WP_CONTENT_DIR . '/uploads';
      $uploads_dir_exists = is_dir($uploads_dir);
      $uploads_dir_readable = $uploads_dir_exists && is_readable($uploads_dir);
      $folder_review_state = $uploads_dir_exists && !$uploads_dir_readable ? 'partial' : 'complete';
      $folder_review_reason = !$uploads_dir_exists ? 'uploads_directory_missing' : ( $uploads_dir_readable ? 'checked' : 'uploads_not_readable' );
      if ($uploads_dir_exists && !$uploads_dir_readable) {
        $findings[] = [
          'finding_code' => 'uploads_directory_not_readable',
          'family' => 'folder_structure',
          'severity' => 'warning',
          'posture' => 'review_required',
          'title' => 'Uploads directory could not be read',
          'summary' => 'OpsGate found the uploads directory, but it was not readable during this run, so public file-pattern review could not complete cleanly.',
          'action_hint' => 'Restore readable access to the uploads tree or confirm host access constraints before relying on this structure review as complete.',
          'details' => ['path' => $uploads_dir],
        ];
      }
      $structure_patterns = [
        ['glob' => trailingslashit($uploads_dir) . '*.{sql,sqlite,sqlite3,bak,backup,old,orig,zip,tar,gz}', 'severity' => 'high', 'title' => 'Backup or database-style file exposed in uploads', 'code' => 'uploads_backup_file'],
        ['glob' => trailingslashit($uploads_dir) . '*/*.{sql,sqlite,sqlite3,bak,backup,old,orig,zip,tar,gz}', 'severity' => 'high', 'title' => 'Backup or database-style file exposed in uploads', 'code' => 'uploads_backup_file_nested'],
        ['glob' => trailingslashit($uploads_dir) . '*.php', 'severity' => 'critical', 'title' => 'Executable PHP file found in uploads', 'code' => 'uploads_php_file'],
        ['glob' => trailingslashit($uploads_dir) . '*/*.php', 'severity' => 'critical', 'title' => 'Executable PHP file found in uploads', 'code' => 'uploads_php_file_nested'],
      ];
      foreach ($structure_patterns as $pattern) {
        $matches = glob((string) $pattern['glob'], GLOB_BRACE);
        if (!is_array($matches)) {
          continue;
        }
        foreach (array_slice($matches, 0, 10) as $match_path) {
          $findings[] = [
            'finding_code' => (string) $pattern['code'],
            'family' => 'folder_structure',
            'severity' => (string) $pattern['severity'],
            'posture' => 'review_required',
            'title' => (string) $pattern['title'],
            'summary' => 'The uploads tree contains a file pattern that should not normally be exposed on a production WordPress host.',
            'action_hint' => 'Move the file out of the public uploads tree or remove it if it should not remain on disk.',
            'details' => ['path' => (string) $match_path],
          ];
        }
      }
      $debug_log = WP_CONTENT_DIR . '/debug.log';
      if (file_exists($debug_log)) {
        $findings[] = [
          'finding_code' => 'debug_log_present',
          'family' => 'folder_structure',
          'severity' => 'warning',
          'posture' => 'review_required',
          'title' => 'debug.log is present under wp-content',
          'summary' => 'A debug log file is present on disk, which can indicate ongoing runtime issues or leave readable traces on the host.',
          'action_hint' => 'Review whether debug logging is still required and remove or rotate the file once the underlying issue is understood.',
          'details' => ['path' => $debug_log, 'size_bytes' => @filesize($debug_log)],
        ];
      }
    }

    $critical_count = count(array_filter($findings, static function($item){
      return in_array((string) ($item['severity'] ?? ''), ['critical', 'high'], true);
    }));
    $warning_count = count(array_filter($findings, static function($item){
      return in_array((string) ($item['severity'] ?? ''), ['warning', 'medium', 'low'], true);
    }));
    $review_state = 'unavailable';
    if ('complete' === $htaccess_review_state && 'complete' === $folder_review_state) {
      $review_state = 'complete';
    } elseif ('unavailable' !== $htaccess_review_state || 'unavailable' !== $folder_review_state) {
      $review_state = 'partial';
    }

    return [
      'kind' => 'configuration_validation',
      'generated_at_utc' => gmdate('c'),
      'posture' => empty($findings) ? 'passed' : 'review_required',
      'summary' => [
        'finding_count' => count($findings),
        'critical_count' => $critical_count,
        'warning_count' => $warning_count,
        'review_state' => $review_state,
        'htaccess_review_ran' => true,
        'folder_structure_review_ran' => 'unavailable' !== $folder_review_state,
      ],
      'families' => [
        'htaccess' => [
          'ran' => true,
          'exists' => (bool) $htaccess_exists,
          'readable' => (bool) $htaccess_readable,
          'writable' => (bool) $htaccess_writable,
          'review_state' => $htaccess_review_state,
          'review_reason' => $htaccess_review_reason,
          'posture' => empty(array_filter($findings, static function($item){ return 'htaccess' === (string) ($item['family'] ?? ''); })) ? 'passed' : 'review_required',
        ],
        'folder_structure' => [
          'ran' => 'unavailable' !== $folder_review_state,
          'uploads_dir_exists' => (bool) $uploads_dir_exists,
          'uploads_dir_readable' => (bool) $uploads_dir_readable,
          'review_state' => $folder_review_state,
          'review_reason' => $folder_review_reason,
          'posture' => empty(array_filter($findings, static function($item){ return 'folder_structure' === (string) ($item['family'] ?? ''); })) ? 'passed' : 'review_required',
        ],
      ],
      'findings' => $findings,
    ];
  }

  public static function inventory($request){
    if (!function_exists('get_plugins')) {
      require_once ABSPATH . 'wp-admin/includes/plugin.php';
    }
    if (!function_exists('get_core_updates')) {
      require_once ABSPATH . 'wp-admin/includes/update.php';
    }

    $plugins = [];
    $active_plugins = get_option('active_plugins', []);
    $active_plugins = is_array($active_plugins) ? $active_plugins : [];
    $active_lookup = array_fill_keys($active_plugins, true);
    $plugin_updates = get_site_transient('update_plugins');
    $plugin_update_map = isset($plugin_updates->response) && is_array($plugin_updates->response) ? $plugin_updates->response : [];

    foreach (get_plugins() as $plugin_file => $plugin_data) {
      $update_info = isset($plugin_update_map[$plugin_file]) ? $plugin_update_map[$plugin_file] : null;
      $plugins[] = [
        'plugin_file' => (string) $plugin_file,
        'slug' => dirname((string) $plugin_file) !== '.' ? dirname((string) $plugin_file) : basename((string) $plugin_file, '.php'),
        'name' => (string) ($plugin_data['Name'] ?? $plugin_file),
        'version' => (string) ($plugin_data['Version'] ?? ''),
        'active' => isset($active_lookup[$plugin_file]),
        'update_available' => null !== $update_info,
        'update_version' => null !== $update_info && isset($update_info->new_version) ? (string) $update_info->new_version : '',
      ];
    }

    usort($plugins, static function($a, $b){
      return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
    });

    $themes = [];
    $active_theme = wp_get_theme();
    $theme_updates = get_site_transient('update_themes');
    $theme_update_map = isset($theme_updates->response) && is_array($theme_updates->response) ? $theme_updates->response : [];

    foreach (wp_get_themes() as $stylesheet => $theme) {
      $update_info = isset($theme_update_map[$stylesheet]) ? $theme_update_map[$stylesheet] : null;
      $themes[] = [
        'stylesheet' => (string) $stylesheet,
        'template' => (string) $theme->get_template(),
        'name' => (string) $theme->get('Name'),
        'version' => (string) $theme->get('Version'),
        'active' => $active_theme->get_stylesheet() === $stylesheet,
        'update_available' => null !== $update_info,
        'update_version' => null !== $update_info && isset($update_info['new_version']) ? (string) $update_info['new_version'] : '',
      ];
    }

    usort($themes, static function($a, $b){
      return strcmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
    });

    $core_version = (string) get_bloginfo('version');
    $core_update_available = false;
    $core_update_version = '';
    $core_updates = get_core_updates(array('dismissed' => false));
    if (is_array($core_updates)) {
      foreach ($core_updates as $core_update) {
        if (!is_object($core_update)) {
          continue;
        }
        $response = isset($core_update->response) ? (string) $core_update->response : '';
        $current = isset($core_update->current) ? (string) $core_update->current : '';
        if ('' !== $current && 'latest' !== $response) {
          $core_update_available = true;
          $core_update_version = $current;
          break;
        }
      }
    }

    $snapshot = [
      'kind' => 'inventory_snapshot',
      'generated_at_utc' => gmdate('c'),
      'connector' => [
        'connection_mode' => 'wp_connector',
        'connector_installation_id' => (string) get_option('opsgate_connector_installation_id', ''),
        'connector_version' => defined('OPSGATE_VERSION') ? (string) OPSGATE_VERSION : '0.10.7',
      ],
      'site' => [
        'site_url' => (string) home_url('/'),
        'home_url' => (string) home_url('/'),
        'site_name' => (string) get_bloginfo('name'),
        'site_host' => (string) (wp_parse_url(home_url('/'), PHP_URL_HOST) ?: ''),
      ],
      'wordpress' => [
        'core_version' => $core_version,
        'core_update_available' => $core_update_available,
        'core_update_version' => $core_update_version,
        'language' => (string) get_locale(),
        'is_multisite' => is_multisite(),
      ],
      'runtime' => [
        'php_version' => (string) PHP_VERSION,
        'php_sapi' => (string) PHP_SAPI,
        'memory_limit' => (string) ini_get('memory_limit'),
        'max_execution_time' => (int) ini_get('max_execution_time'),
      ],
      'plugins' => [
        'count' => count($plugins),
        'active_count' => count($active_plugins),
        'items' => $plugins,
      ],
      'themes' => [
        'count' => count($themes),
        'active_stylesheet' => (string) $active_theme->get_stylesheet(),
        'active_template' => (string) $active_theme->get_template(),
        'items' => $themes,
      ],
      'configuration_validation' => self::collect_configuration_validation(),
      'operational_log_intelligence' => self::collect_operational_log_intelligence(),
    ];

    return new WP_REST_Response(['ok' => true, 'data' => $snapshot], 200);
  }

  private static function remediation_error($status, $error_code, $message){
    return new WP_REST_Response([
      'ok' => false,
      'error_code' => (string) $error_code,
      'message' => (string) $message,
    ], (int) $status);
  }

  private static function remediation_result($payload){
    return new WP_REST_Response(array_merge(['ok' => true], is_array($payload) ? $payload : []), 200);
  }

  private static function remediation_apply_htaccess_disable_directory_indexing($request_body){
    $path = trailingslashit(ABSPATH) . '.htaccess';
    if (!file_exists($path)) {
      return self::remediation_error(409, 'htaccess_missing', 'The root .htaccess file is missing, so OpsGate cannot apply this corrective change.');
    }
    if (!is_readable($path)) {
      return self::remediation_error(409, 'htaccess_not_readable', 'The root .htaccess file is not readable on this WordPress host.');
    }
    if (!is_writable($path)) {
      return self::remediation_error(409, 'htaccess_not_writable', 'The root .htaccess file is not writable on this WordPress host.');
    }

    $original = (string) @file_get_contents($path);
    $updated = preg_replace('/^(\s*Options\s+)(?:\+)?Indexes\b.*$/im', '$1-Indexes', $original, -1, $replacement_count);
    if (!is_string($updated)) {
      return self::remediation_error(500, 'htaccess_rewrite_failed', 'OpsGate could not prepare the corrected .htaccess content.');
    }
    if (0 === (int) $replacement_count) {
      $updated = rtrim($original) . "\nOptions -Indexes\n";
    }

    if (false === @file_put_contents($path, $updated)) {
      return self::remediation_error(500, 'htaccess_write_failed', 'OpsGate could not write the updated .htaccess content.');
    }

    return self::remediation_result([
      'state' => 'applied',
      'reversible' => true,
      'before_json' => [
        'path' => $path,
        'content' => $original,
      ],
      'after_json' => [
        'path' => $path,
        'content_sha256' => hash('sha256', $updated),
      ],
      'result_json' => [
        'finding_summary' => (string) ($request_body['finding_summary'] ?? ''),
        'applied_copy' => 'OpsGate replaced directory-indexing directives in the root .htaccess file and recorded a reversible restore path.',
        'revert_copy' => 'Revert restores the previously recorded .htaccess content from canonical OpsGate remediation history.',
      ],
    ]);
  }

  private static function remediation_revert_htaccess_disable_directory_indexing($request_body){
    $before = isset($request_body['before_json']) && is_array($request_body['before_json']) ? $request_body['before_json'] : [];
    $path = isset($before['path']) ? (string) $before['path'] : (trailingslashit(ABSPATH) . '.htaccess');
    $content = isset($before['content']) ? (string) $before['content'] : null;
    if (null === $content) {
      return self::remediation_error(409, 'remediation_restore_content_missing', 'The recorded .htaccess restore content is missing from canonical remediation history.');
    }
    if (file_exists($path) && !is_writable($path)) {
      return self::remediation_error(409, 'htaccess_not_writable', 'The root .htaccess file is not writable on this WordPress host.');
    }
    if (false === @file_put_contents($path, $content)) {
      return self::remediation_error(500, 'htaccess_restore_failed', 'OpsGate could not restore the previously recorded .htaccess content.');
    }

    return self::remediation_result([
      'state' => 'reverted',
      'reversible' => false,
      'before_json' => $before,
      'after_json' => isset($request_body['after_json']) && is_array($request_body['after_json']) ? $request_body['after_json'] : [],
      'result_json' => [
        'finding_summary' => (string) ($request_body['finding_summary'] ?? ''),
        'applied_copy' => 'The prior .htaccess change was reverted to the recorded pre-change state.',
      ],
    ]);
  }

  private static function remediation_apply_debug_log_archive($request_body){
    if (!defined('WP_CONTENT_DIR')) {
      return self::remediation_error(409, 'wp_content_dir_missing', 'The WordPress content directory is not available on this host.');
    }
    $original_path = WP_CONTENT_DIR . '/debug.log';
    if (!file_exists($original_path)) {
      return self::remediation_error(409, 'debug_log_missing', 'No debug.log file is present to archive right now.');
    }
    if (!is_readable($original_path)) {
      return self::remediation_error(409, 'debug_log_not_readable', 'The current debug.log file is not readable on this WordPress host.');
    }
    $dir = dirname($original_path);
    if (!is_dir($dir) || !is_writable($dir)) {
      return self::remediation_error(409, 'debug_log_directory_not_writable', 'The debug log directory is not writable on this WordPress host.');
    }
    $archive_path = $original_path . '.opsgate-archived-' . gmdate('Ymd-His') . '.log';
    if (!@rename($original_path, $archive_path)) {
      return self::remediation_error(500, 'debug_log_archive_failed', 'OpsGate could not move the current debug log out of the public runtime path.');
    }

    return self::remediation_result([
      'state' => 'applied',
      'reversible' => true,
      'before_json' => [
        'original_path' => $original_path,
      ],
      'after_json' => [
        'archived_path' => $archive_path,
      ],
      'result_json' => [
        'finding_summary' => (string) ($request_body['finding_summary'] ?? ''),
        'applied_copy' => 'OpsGate archived the public debug log and kept the restore path in canonical remediation history.',
        'revert_copy' => 'Revert moves the archived log back to the standard debug.log path if no newer debug log is already present.',
      ],
    ]);
  }

  private static function remediation_revert_debug_log_archive($request_body){
    $before = isset($request_body['before_json']) && is_array($request_body['before_json']) ? $request_body['before_json'] : [];
    $after = isset($request_body['after_json']) && is_array($request_body['after_json']) ? $request_body['after_json'] : [];
    $original_path = isset($before['original_path']) ? (string) $before['original_path'] : (defined('WP_CONTENT_DIR') ? WP_CONTENT_DIR . '/debug.log' : '');
    $archived_path = isset($after['archived_path']) ? (string) $after['archived_path'] : '';
    if ('' === $archived_path) {
      return self::remediation_error(409, 'archived_debug_log_missing', 'The archived debug log path is missing from canonical remediation history.');
    }
    if (!file_exists($archived_path)) {
      return self::remediation_error(409, 'archived_debug_log_not_found', 'The archived debug log could not be found on this WordPress host.');
    }
    if ('' !== $original_path && file_exists($original_path)) {
      return self::remediation_error(409, 'debug_log_target_exists', 'A new debug.log file already exists, so OpsGate will not overwrite it during revert.');
    }
    if (!@rename($archived_path, $original_path)) {
      return self::remediation_error(500, 'debug_log_restore_failed', 'OpsGate could not restore the archived debug log to the standard path.');
    }

    return self::remediation_result([
      'state' => 'reverted',
      'reversible' => false,
      'before_json' => $before,
      'after_json' => $after,
      'result_json' => [
        'finding_summary' => (string) ($request_body['finding_summary'] ?? ''),
        'applied_copy' => 'The archived debug log was restored to the standard debug.log path.',
      ],
    ]);
  }

  public static function remediation($request){
    $body = $request->get_json_params() ?: [];
    $action_key = isset($body['action_key']) ? sanitize_key((string) $body['action_key']) : '';
    $remediation_action = isset($body['remediation_action']) ? sanitize_key((string) $body['remediation_action']) : 'apply';

    if ('' === $action_key || !in_array($remediation_action, ['apply', 'revert'], true)) {
      return self::remediation_error(400, 'remediation_request_invalid', 'OpsGate remediation request is incomplete.');
    }

    if ('htaccess_disable_directory_indexing' === $action_key) {
      return 'revert' === $remediation_action
        ? self::remediation_revert_htaccess_disable_directory_indexing($body)
        : self::remediation_apply_htaccess_disable_directory_indexing($body);
    }

    if ('debug_log_archive' === $action_key) {
      return 'revert' === $remediation_action
        ? self::remediation_revert_debug_log_archive($body)
        : self::remediation_apply_debug_log_archive($body);
    }

    return self::remediation_error(409, 'remediation_action_unsupported', 'This remediation action is not supported on the current WordPress connector.');
  }

  public static function admin_session($request){
    $body = $request->get_json_params() ?: [];
    $raw_token = isset($body['token']) ? (string) $body['token'] : '';

    if ('' === $raw_token || strlen($raw_token) < 32) {
      return new WP_REST_Response(['ok' => false, 'error_code' => 'token_missing', 'message' => 'Token is required.'], 400);
    }

    $mcp_base = (string) get_option('opsgate_mcp_base_url', 'http://mcp:8000');
    $site_id  = (string) get_option('opsgate_site_id', '');
    if ('' === $site_id) {
      return new WP_REST_Response(['ok' => false, 'error_code' => 'site_not_configured', 'message' => 'Site not paired.'], 409);
    }

    $api_token = function_exists('opsgate_decrypt_connector_secret')
      ? (string) opsgate_decrypt_connector_secret((string) get_option(OPSGATE_OPTION_CONNECTOR_SITE_CREDENTIAL, ''))
      : '';

    $validate_url = trailingslashit($mcp_base) . 'v1/sites/' . rawurlencode($site_id) . '/admin-session/validate';
    $response = wp_remote_post($validate_url, [
      'timeout' => 10,
      'headers' => [
        'Content-Type'    => 'application/json',
        'X-OpsGate-Token' => $api_token,
      ],
      'body' => wp_json_encode(['token' => $raw_token]),
    ]);

    if (is_wp_error($response)) {
      return new WP_REST_Response(['ok' => false, 'error_code' => 'api_unreachable', 'message' => 'Could not reach OpsGate API.'], 502);
    }

    $api_code = (int) wp_remote_retrieve_response_code($response);
    $api_body = json_decode((string) wp_remote_retrieve_body($response), true);
    $api_body = is_array($api_body) ? $api_body : [];

    if (200 !== $api_code || empty($api_body['ok'])) {
      $err = isset($api_body['error_code']) ? (string) $api_body['error_code'] : 'token_invalid';
      return new WP_REST_Response(['ok' => false, 'error_code' => $err, 'message' => 'Token rejected.'], 401);
    }

    $wp_user_id = (int) get_option('opsgate_admin_session_user_id', 0);
    if (0 === $wp_user_id) {
      $admins = get_users(['role' => 'administrator', 'number' => 1, 'fields' => 'ID']);
      $wp_user_id = !empty($admins) ? (int) $admins[0] : 0;
    }

    if (0 === $wp_user_id) {
      return new WP_REST_Response(['ok' => false, 'error_code' => 'no_admin_user', 'message' => 'No administrator account found.'], 409);
    }

    wp_set_auth_cookie($wp_user_id, false, is_ssl());
    wp_set_current_user($wp_user_id);

    $redirect = admin_url('/');
    return new WP_REST_Response(['ok' => true, 'redirect_url' => $redirect], 200);
  }

  // -------------------------------------------------------------------------
  // UI Pass 6B Part B4 — PHP error capture relay
  // -------------------------------------------------------------------------

  private static function _error_relay_transient_key(): string {
    return 'opsgate_err_relay_' . substr(md5((string) get_option(OPSGATE_OPTION_SITE_ID, 'default')), 0, 12);
  }

  public static function capture_php_error(int $errno, string $errstr, string $errfile, int $errline): bool {
    // Non-fatal: accumulate into a transient batch; relay fires on shutdown or next cron.
    $error_classes = [
      E_ERROR => 'E_ERROR', E_WARNING => 'E_WARNING', E_PARSE => 'E_PARSE',
      E_NOTICE => 'E_NOTICE', E_CORE_ERROR => 'E_CORE_ERROR',
      E_CORE_WARNING => 'E_CORE_WARNING', E_COMPILE_ERROR => 'E_COMPILE_ERROR',
      E_COMPILE_WARNING => 'E_COMPILE_WARNING', E_USER_ERROR => 'E_USER_ERROR',
      E_USER_WARNING => 'E_USER_WARNING', E_USER_NOTICE => 'E_USER_NOTICE',
      E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR',
    ];
    $class = isset($error_classes[$errno]) ? $error_classes[$errno] : 'E_UNKNOWN';
    self::_buffer_error($class, $errstr, $errfile, $errline);
    return false; // allow default handler to continue
  }

  public static function capture_shutdown_error(): void {
    $error = error_get_last();
    if (!is_array($error)) {
      self::_flush_error_buffer();
      return;
    }
    $fatal_types = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
    if (in_array((int) ($error['type'] ?? 0), $fatal_types, true)) {
      $error_classes = [
        E_ERROR => 'E_ERROR', E_PARSE => 'E_PARSE',
        E_CORE_ERROR => 'E_CORE_ERROR', E_COMPILE_ERROR => 'E_COMPILE_ERROR',
      ];
      $class = $error_classes[(int) $error['type']] ?? 'E_FATAL';
      self::_buffer_error($class, (string) ($error['message'] ?? ''), (string) ($error['file'] ?? ''), (int) ($error['line'] ?? 0));
    }
    self::_flush_error_buffer();
  }

  private static function _buffer_error(string $error_class, string $message, string $file, int $line): void {
    $key = 'opsgate_err_buf_' . substr(md5((string) get_option(OPSGATE_OPTION_SITE_ID, 'default')), 0, 12);
    $buf = get_transient($key);
    $buf = is_array($buf) ? $buf : [];
    if (count($buf) >= 50) {
      return; // cap buffer
    }
    $buf[] = [
      'error_class' => $error_class,
      'message' => function_exists('mb_substr') ? mb_substr($message, 0, 200) : substr($message, 0, 200),
      'file_path' => $file,
      'line_number' => $line,
      'occurrence_count' => 1,
      'first_seen_at' => gmdate('c'),
      'last_seen_at' => gmdate('c'),
    ];
    set_transient($key, $buf, 15 * MINUTE_IN_SECONDS);
  }

  private static function _flush_error_buffer(): void {
    $key = 'opsgate_err_buf_' . substr(md5((string) get_option(OPSGATE_OPTION_SITE_ID, 'default')), 0, 12);
    $buf = get_transient($key);
    if (!is_array($buf) || empty($buf)) {
      return;
    }
    delete_transient($key);

    // Rate limit: one relay per 15 minutes per site.
    $rate_key = self::_error_relay_transient_key();
    if (get_transient($rate_key)) {
      return;
    }
    set_transient($rate_key, 1, 15 * MINUTE_IN_SECONDS);

    // Relay to OpsGate central API via the connector site credential.
    $site_id = (string) get_option(OPSGATE_OPTION_SITE_ID, '');
    $account_id = (string) get_option(OPSGATE_OPTION_ACCOUNT_ID, '');
    if ('' === $site_id || '' === $account_id) {
      return;
    }

    $api_base = (string) get_option('opsgate_mcp_base_url', '');
    if ('' === $api_base) {
      return;
    }

    $credential = function_exists('opsgate_decrypt_connector_secret')
      ? (string) opsgate_decrypt_connector_secret((string) get_option(OPSGATE_OPTION_CONNECTOR_SITE_CREDENTIAL, ''))
      : '';
    if ('' === $credential) {
      return;
    }

    $url = rtrim($api_base, '/') . '/v1/sites/' . rawurlencode($site_id) . '/error-batches';
    wp_remote_post($url, [
      'timeout' => 5,
      'blocking' => false,
      'headers' => [
        'Content-Type' => 'application/json',
        'X-OpsGate-Site-Credential' => $credential,
      ],
      'body' => wp_json_encode([
        'account_id' => $account_id,
        'errors' => array_values($buf),
      ]),
    ]);
  }

  public static function error_report(WP_REST_Request $request): WP_REST_Response {
    // Connector-side relay endpoint: accepts a batch of PHP errors from the site's own
    // error handler and forwards them to the central OpsGate API.
    // Rate limited: one relay accepted per 15 minutes (uses the same transient as _flush_error_buffer).
    $rate_key = self::_error_relay_transient_key();
    if (get_transient($rate_key)) {
      return new WP_REST_Response(['ok' => false, 'error_code' => 'rate_limited', 'message' => 'Error batch already submitted within the last 15 minutes.'], 429);
    }

    $body = $request->get_json_params();
    $errors = isset($body['errors']) && is_array($body['errors']) ? $body['errors'] : [];
    if (empty($errors)) {
      return new WP_REST_Response(['ok' => false, 'error_code' => 'no_errors', 'message' => 'No errors provided.'], 400);
    }

    $site_id = (string) get_option(OPSGATE_OPTION_SITE_ID, '');
    $account_id = (string) get_option(OPSGATE_OPTION_ACCOUNT_ID, '');
    $api_base = (string) get_option('opsgate_mcp_base_url', '');
    $credential = function_exists('opsgate_decrypt_connector_secret')
      ? (string) opsgate_decrypt_connector_secret((string) get_option(OPSGATE_OPTION_CONNECTOR_SITE_CREDENTIAL, ''))
      : '';

    if ('' === $site_id || '' === $account_id || '' === $api_base || '' === $credential) {
      return new WP_REST_Response(['ok' => false, 'error_code' => 'not_configured', 'message' => 'OpsGate connector is not fully configured.'], 409);
    }

    set_transient($rate_key, 1, 15 * MINUTE_IN_SECONDS);

    $url = rtrim($api_base, '/') . '/v1/sites/' . rawurlencode($site_id) . '/error-batches';
    $response = wp_remote_post($url, [
      'timeout' => 8,
      'blocking' => true,
      'headers' => [
        'Content-Type' => 'application/json',
        'X-OpsGate-Site-Credential' => $credential,
      ],
      'body' => wp_json_encode([
        'account_id' => $account_id,
        'errors' => array_slice($errors, 0, 50),
      ]),
    ]);

    if (is_wp_error($response)) {
      return new WP_REST_Response(['ok' => false, 'error_code' => 'relay_failed', 'message' => 'Relay to OpsGate central failed.'], 502);
    }

    $code = (int) wp_remote_retrieve_response_code($response);
    $relay_body = json_decode((string) wp_remote_retrieve_body($response), true);
    $relay_ok = is_array($relay_body) && !empty($relay_body['ok']);

    return new WP_REST_Response(['ok' => $relay_ok, 'central_status' => $code], $relay_ok ? 200 : 502);
  }
}

