<?php

namespace SerpIQ\Gsc;

use SerpIQ\Helpers\Options;

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

final class Sync {
	private static ?Sync $instance = null;

	public static function instance(): Sync {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	public function run_last_3_days() {
		$end = gmdate( 'Y-m-d', strtotime( '-1 day' ) );
		$start = gmdate( 'Y-m-d', strtotime( '-3 day' ) );
		return $this->run_range( $start, $end, 'sync' );
	}

	/**
	 * Sync last 7 days in one request (same lock and logging as run_last_3_days).
	 *
	 * @return array<string,mixed>|\WP_Error
	 */
	public function run_last_7_days() {
		$end = gmdate( 'Y-m-d', strtotime( '-1 day' ) );
		$start = gmdate( 'Y-m-d', strtotime( '-7 day' ) );
		return $this->run_range( $start, $end, 'sync' );
	}

	/**
	 * Sync last 30 days in one request (same lock and logging as run_last_3_days).
	 * May take longer and require higher PHP limits on some hosting.
	 *
	 * @return array<string,mixed>|\WP_Error
	 */
	public function run_last_30_days() {
		$end = gmdate( 'Y-m-d', strtotime( '-1 day' ) );
		$start = gmdate( 'Y-m-d', strtotime( '-30 day' ) );
		return $this->run_range( $start, $end, 'sync' );
	}

	/**
	 * Sync last 90 days in one request (experimental; may timeout on limited hosting).
	 *
	 * @return array<string,mixed>|\WP_Error
	 */
	public function run_last_90_days() {
		$end = gmdate( 'Y-m-d', strtotime( '-1 day' ) );
		$start = gmdate( 'Y-m-d', strtotime( '-90 day' ) );
		return $this->run_range( $start, $end, 'sync' );
	}

	/**
	 * Sync last 365 days in one request (experimental; may timeout on limited hosting).
	 *
	 * @return array<string,mixed>|\WP_Error
	 */
	public function run_last_365_days() {
		$end = gmdate( 'Y-m-d', strtotime( '-1 day' ) );
		$start = gmdate( 'Y-m-d', strtotime( '-365 day' ) );
		return $this->run_range( $start, $end, 'sync' );
	}

	/**
	 * Sync last ~16 months (485 days) in one request (experimental; may timeout on limited hosting).
	 *
	 * @return array<string,mixed>|\WP_Error
	 */
	public function run_last_485_days() {
		$end = gmdate( 'Y-m-d', strtotime( '-1 day' ) );
		$start = gmdate( 'Y-m-d', strtotime( '-485 day' ) );
		return $this->run_range( $start, $end, 'sync' );
	}

	/**
	 * Returns date coverage of imported data.
	 *
	 * @return array{earliest:string|null,latest:string|null}
	 */
	public function get_date_coverage(): array {
		// M5: request-scope memo only. Hot import paths do not re-read coverage in the same PHP request.
		static $request_cache = null;
		if ( null !== $request_cache ) {
			return $request_cache;
		}

		global $wpdb;

		$table = $wpdb->prefix . 'serpiq_pages';
		$earliest = null;
		$latest   = null;
		if ( preg_match( '/^[a-zA-Z0-9_]+$/', $table ) ) {
			$earliest = $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(date) FROM %i', $table ) );
			$latest   = $wpdb->get_var( $wpdb->prepare( 'SELECT MAX(date) FROM %i', $table ) );
		}

		$earliest = $earliest ? (string) $earliest : null;
		$latest   = $latest ? (string) $latest : null;

		$request_cache = array(
			'earliest' => $earliest,
			'latest'   => $latest,
		);

		return $request_cache;
	}

	/**
	 * Run a sync over a date range.
	 *
	 * @param string $start_date
	 * @param string $end_date
	 * @param string $job_type  Logical job label for diagnostics (sync, backfill_batch, etc.).
	 *
	 * @return array<string,mixed>|\WP_Error
	 */
	public function run_range( string $start_date, string $end_date, string $job_type = 'sync' ) {
		if ( get_transient( 'serpiq_sync_lock' ) ) {
			return new \WP_Error( 'serpiq_sync_locked', __( 'A sync is already running.', 'seotune-search-insights' ) );
		}

		set_transient( 'serpiq_sync_lock', 1, 30 * MINUTE_IN_SECONDS );

		$time_start = microtime( true );
		$rows_imported = 0;
		$phase_stats = array();
		$status = 'success';
		$error_message = null;

		try {
			$property = (string) get_option( Options::google_property_key(), '' );
			if ( '' === $property ) {
				$detected = Property_Detector::instance()->detect_and_store();
				if ( is_wp_error( $detected ) ) {
					$this->throw_safe_sync_exception_from_wp_error( $detected, 'property_detect' );
				}
				$property = (string) $detected;
			}

			$client = OAuth::instance()->client();
			if ( is_wp_error( $client ) ) {
				$this->throw_safe_sync_exception_from_wp_error( $client, 'oauth_client' );
			}

			$service = null;
			if ( class_exists( '\Google_Service_SearchConsole' ) ) {
				$service = new \Google_Service_SearchConsole( $client );
			} elseif ( class_exists( '\Google_Service_Webmasters' ) ) {
				$service = new \Google_Service_Webmasters( $client );
			} else {
				throw new \Exception( __( 'Search Console service class not found.', 'seotune-search-insights' ) );
			}

			// Queries.
			$phase_start = microtime( true );
			$import_q    = $this->sync_queries( $service, $property, $start_date, $end_date );
			$phase_stats['queries'] = array(
				'rows' => $import_q,
				'ms'   => (int) round( ( microtime( true ) - $phase_start ) * 1000 ),
			);
			$rows_imported += $import_q;

			// Pages.
			$phase_start = microtime( true );
			$import_p    = $this->sync_pages( $service, $property, $start_date, $end_date );
			$phase_stats['pages'] = array(
				'rows' => $import_p,
				'ms'   => (int) round( ( microtime( true ) - $phase_start ) * 1000 ),
			);
			$rows_imported += $import_p;

			// Totals.
			$phase_start = microtime( true );
			$import_t    = $this->sync_totals( $service, $property, $start_date, $end_date );
			$phase_stats['totals'] = array(
				'rows' => $import_t,
				'ms'   => (int) round( ( microtime( true ) - $phase_start ) * 1000 ),
			);
			$rows_imported += $import_t;

			update_option( Options::last_sync_at_key(), gmdate( 'c' ), false );
		} catch ( \Throwable $e ) {
			$status = 'error';
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
				error_log( 'Seotune Search Insights sync: ' . $e->getMessage() );
			}
			$error_message = sanitize_text_field( wp_strip_all_tags( $e->getMessage() ) );
			if ( strlen( $error_message ) > 500 ) {
				$error_message = substr( $error_message, 0, 497 ) . '...';
			}
		}

		$duration_ms = (int) round( ( microtime( true ) - $time_start ) * 1000 );
		$this->log_sync( $start_date, $end_date, $rows_imported, $status, $error_message, $duration_ms, $phase_stats, $job_type );

		delete_transient( 'serpiq_sync_lock' );

		if ( 'error' === $status ) {
			return new \WP_Error( 'serpiq_sync_failed', $error_message ? $error_message : __( 'Sync failed.', 'seotune-search-insights' ) );
		}

		return array(
			'rowsImported' => $rows_imported,
			'startDate'    => $start_date,
			'endDate'      => $end_date,
			'durationMs'   => $duration_ms,
		);
	}

	public function log_sync( string $start_date, string $end_date, int $rows_imported, string $status, ?string $error_message, int $duration_ms, array $phase_stats = array(), string $job_type = 'sync' ): void {
		global $wpdb;

		$table = $wpdb->prefix . 'serpiq_sync_log';

		// If sync completed successfully and there is no error message,
		// store phase-level profiling data as JSON in the error_message column for diagnostics.
		if ( 'success' === $status && ( null === $error_message || '' === $error_message ) && ! empty( $phase_stats ) ) {
			// Use wp_json_encode for safety; fall back to plain serialize if needed.
			$encoded = wp_json_encode( $phase_stats );
			if ( false !== $encoded ) {
				$error_message = $encoded;
			}
		}
		$wpdb->insert(
			$table,
			array(
				'created_at'      => current_time( 'mysql', true ),
				'job_type'        => $job_type,
				'sync_start_date' => $start_date,
				'sync_end_date'   => $end_date,
				'rows_imported'   => $rows_imported,
				'status'          => $status,
				'error_message'   => $error_message,
				'duration_ms'     => $duration_ms,
			),
			array( '%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d' )
		);
	}

	private function sync_queries( $service, string $property, string $start_date, string $end_date ): int {
		$dimensions = array( 'query', 'page', 'date', 'country', 'device' );
		$rows = $this->query_search_analytics( $service, $property, $start_date, $end_date, $dimensions );
		if ( is_wp_error( $rows ) ) {
			$this->throw_safe_sync_exception_from_wp_error( $rows, 'sync_queries' );
		}

		global $wpdb;
		$table = $wpdb->prefix . 'serpiq_queries';
		$imported   = 0;
		$batch      = array();
		$batch_size = $this->get_db_batch_size();

		foreach ( $rows as $row ) {
			$keys     = isset( $row['keys'] ) && is_array( $row['keys'] ) ? $row['keys'] : array();
			$query    = (string) ( $keys[0] ?? '' );
			$page_url = (string) ( $keys[1] ?? '' );
			$date     = (string) ( $keys[2] ?? '' );
			$country  = (string) ( $keys[3] ?? '' );
			$device   = (string) ( $keys[4] ?? '' );

			$row_key = md5( $date . '|' . $query . '|' . $page_url . '|' . $country . '|' . $device );

			$batch[] = array(
				'row_key'     => $row_key,
				'query'       => $query,
				'page_url'    => $page_url,
				'date'        => $date,
				'country'     => $country ? $country : null,
				'device'      => $device ? $device : null,
				'clicks'      => (int) ( $row['clicks'] ?? 0 ),
				'impressions' => (int) ( $row['impressions'] ?? 0 ),
				'ctr'         => (float) ( $row['ctr'] ?? 0 ),
				'position'    => (float) ( $row['position'] ?? 0 ),
			);

			if ( count( $batch ) >= $batch_size ) {
				$imported += $this->insert_queries_batch( $table, $batch );
				$batch = array();
			}
		}

		if ( ! empty( $batch ) ) {
			$imported += $this->insert_queries_batch( $table, $batch );
		}

		return $imported;
	}

	private function sync_pages( $service, string $property, string $start_date, string $end_date ): int {
		$dimensions = array( 'page', 'date', 'country', 'device' );
		$rows = $this->query_search_analytics( $service, $property, $start_date, $end_date, $dimensions );
		if ( is_wp_error( $rows ) ) {
			$this->throw_safe_sync_exception_from_wp_error( $rows, 'sync_pages' );
		}

		global $wpdb;
		$table = $wpdb->prefix . 'serpiq_pages';
		$imported   = 0;
		$batch      = array();
		$batch_size = $this->get_db_batch_size();

		foreach ( $rows as $row ) {
			$keys     = isset( $row['keys'] ) && is_array( $row['keys'] ) ? $row['keys'] : array();
			$page_url = (string) ( $keys[0] ?? '' );
			$date     = (string) ( $keys[1] ?? '' );
			$country  = (string) ( $keys[2] ?? '' );
			$device   = (string) ( $keys[3] ?? '' );

			$row_key = md5( $date . '|' . $page_url . '|' . $country . '|' . $device );

			$batch[] = array(
				'row_key'     => $row_key,
				'page_url'    => $page_url,
				'date'        => $date,
				'country'     => $country ? $country : null,
				'device'      => $device ? $device : null,
				'clicks'      => (int) ( $row['clicks'] ?? 0 ),
				'impressions' => (int) ( $row['impressions'] ?? 0 ),
				'ctr'         => (float) ( $row['ctr'] ?? 0 ),
				'position'    => (float) ( $row['position'] ?? 0 ),
			);

			if ( count( $batch ) >= $batch_size ) {
				$imported += $this->insert_pages_batch( $table, $batch );
				$batch = array();
			}
		}

		if ( ! empty( $batch ) ) {
			$imported += $this->insert_pages_batch( $table, $batch );
		}

		return $imported;
	}

	private function sync_totals( $service, string $property, string $start_date, string $end_date ): int {
		// Use only date as dimension to get totals closer to GSC top metrics
		$dimensions = array( 'date' );
		$rows = $this->query_search_analytics( $service, $property, $start_date, $end_date, $dimensions );
		if ( is_wp_error( $rows ) ) {
			$this->throw_safe_sync_exception_from_wp_error( $rows, 'sync_totals' );
		}

		global $wpdb;
		$table = $wpdb->prefix . 'serpiq_totals';
		$imported   = 0;
		$batch      = array();
		$batch_size = $this->get_db_batch_size();

		foreach ( $rows as $row ) {
			$keys = isset( $row['keys'] ) && is_array( $row['keys'] ) ? $row['keys'] : array();
			$date = (string) ( $keys[0] ?? '' );

			if ( '' === $date ) {
				continue;
			}

			$row_key = md5( $date . '|all|all' );

			$batch[] = array(
				'row_key'     => $row_key,
				'date'        => $date,
				'clicks'      => (int) ( $row['clicks'] ?? 0 ),
				'impressions' => (int) ( $row['impressions'] ?? 0 ),
				'ctr'         => (float) ( $row['ctr'] ?? 0 ),
				'position'    => (float) ( $row['position'] ?? 0 ),
			);

			if ( count( $batch ) >= $batch_size ) {
				$imported += $this->insert_totals_batch( $table, $batch );
				$batch = array();
			}
		}

		if ( ! empty( $batch ) ) {
			$imported += $this->insert_totals_batch( $table, $batch );
		}

		return $imported;
	}

	private function insert_queries_batch( string $table, array $batch ): int {
		global $wpdb;
		if ( empty( $batch ) ) {
			return 0;
		}

		$columns = array(
			'row_key',
			'query',
			'page_url',
			'date',
			'country',
			'device',
			'clicks',
			'impressions',
			'ctr',
			'position',
		);

		$placeholders_row = array( '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%f', '%f' );
		$values_sql_parts = array();
		$params           = array();

		foreach ( $batch as $row ) {
			$values_sql_parts[] = '(' . implode( ',', $placeholders_row ) . ')';
			$params[]           = $row['row_key'];
			$params[]           = $row['query'];
			$params[]           = $row['page_url'];
			$params[]           = $row['date'];
			$params[]           = $row['country'];
			$params[]           = $row['device'];
			$params[]           = $row['clicks'];
			$params[]           = $row['impressions'];
			$params[]           = $row['ctr'];
			$params[]           = $row['position'];
		}

		$sql      = 'REPLACE INTO %i (' . implode( ',', $columns ) . ') VALUES ' . implode( ',', $values_sql_parts );
		$prepared = $wpdb->prepare( $sql, array_merge( array( $table ), $params ) );
		$result   = $wpdb->query( $prepared );

		return (int) ( $result !== false ? count( $batch ) : 0 );
	}

	private function insert_pages_batch( string $table, array $batch ): int {
		global $wpdb;
		if ( empty( $batch ) ) {
			return 0;
		}

		$columns = array(
			'row_key',
			'page_url',
			'date',
			'country',
			'device',
			'clicks',
			'impressions',
			'ctr',
			'position',
		);

		$placeholders_row = array( '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%f', '%f' );
		$values_sql_parts = array();
		$params           = array();

		foreach ( $batch as $row ) {
			$values_sql_parts[] = '(' . implode( ',', $placeholders_row ) . ')';
			$params[]           = $row['row_key'];
			$params[]           = $row['page_url'];
			$params[]           = $row['date'];
			$params[]           = $row['country'];
			$params[]           = $row['device'];
			$params[]           = $row['clicks'];
			$params[]           = $row['impressions'];
			$params[]           = $row['ctr'];
			$params[]           = $row['position'];
		}

		$sql      = 'REPLACE INTO %i (' . implode( ',', $columns ) . ') VALUES ' . implode( ',', $values_sql_parts );
		$prepared = $wpdb->prepare( $sql, array_merge( array( $table ), $params ) );
		$result   = $wpdb->query( $prepared );

		return (int) ( $result !== false ? count( $batch ) : 0 );
	}

	private function insert_totals_batch( string $table, array $batch ): int {
		global $wpdb;
		if ( empty( $batch ) ) {
			return 0;
		}

		$columns = array(
			'row_key',
			'date',
			'country',
			'device',
			'clicks',
			'impressions',
			'ctr',
			'position',
		);

		$placeholders_row = array( '%s', '%s', '%s', '%s', '%d', '%d', '%f', '%f' );
		$values_sql_parts = array();
		$params           = array();

		foreach ( $batch as $row ) {
			$values_sql_parts[] = '(' . implode( ',', $placeholders_row ) . ')';
			$params[]           = $row['row_key'];
			$params[]           = $row['date'];
			$params[]           = null;
			$params[]           = null;
			$params[]           = $row['clicks'];
			$params[]           = $row['impressions'];
			$params[]           = $row['ctr'];
			$params[]           = $row['position'];
		}

		$sql      = 'REPLACE INTO %i (' . implode( ',', $columns ) . ') VALUES ' . implode( ',', $values_sql_parts );
		$prepared = $wpdb->prepare( $sql, array_merge( array( $table ), $params ) );
		$result   = $wpdb->query( $prepared );

		return (int) ( $result !== false ? count( $batch ) : 0 );
	}

	/**
	 * Returns the configured DB batch size (rows per multi-value REPLACE),
	 * clamped to a safe range. Default is 500.
	 */
	private function get_db_batch_size(): int {
		$val = (int) get_option( \SerpIQ\Helpers\Options::db_batch_size_key(), 1000 );
		if ( $val <= 0 ) {
			$val = 1000;
		}
		if ( $val < 100 ) {
			$val = 100;
		} elseif ( $val > 2000 ) {
			$val = 2000;
		}
		return $val;
	}

	/**
	 * Log WP_Error details when WP_DEBUG; throw a generic exception (avoids raw API text in logs/UI).
	 *
	 * @throws \Exception
	 */
	private function throw_safe_sync_exception_from_wp_error( \WP_Error $error, string $log_context ): void {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG && function_exists( 'error_log' ) ) {
			error_log(
				sprintf(
					'Seotune Search Insights [%s] %s: %s',
					$log_context,
					$error->get_error_code(),
					$error->get_error_message()
				)
			);
		}
		throw new \Exception( __( 'A Search Console request failed. Check Settings or try again.', 'seotune-search-insights' ) );
	}

	/**
	 * @return array<int,array<string,mixed>>|\WP_Error
	 */
	private function query_search_analytics( $service, string $property, string $start_date, string $end_date, array $dimensions ) {
		if ( ! isset( $service->searchanalytics ) ) {
			return new \WP_Error( 'serpiq_missing_searchanalytics', __( 'Search analytics endpoint not available.', 'seotune-search-insights' ) );
		}

		if ( ! class_exists( '\Google_Service_SearchConsole_SearchAnalyticsQueryRequest' ) && ! class_exists( '\Google_Service_Webmasters_SearchAnalyticsQueryRequest' ) ) {
			return new \WP_Error( 'serpiq_missing_query_request', __( 'Search analytics request class not found.', 'seotune-search-insights' ) );
		}

		$request = class_exists( '\Google_Service_SearchConsole_SearchAnalyticsQueryRequest' )
			? new \Google_Service_SearchConsole_SearchAnalyticsQueryRequest()
			: new \Google_Service_Webmasters_SearchAnalyticsQueryRequest();

		$request->setStartDate( $start_date );
		$request->setEndDate( $end_date );
		$request->setDimensions( $dimensions );
		$request->setRowLimit( 25000 );

		// Align request defaults with GSC UI as much as possible.
		// - Search type: Web (unless the UI comparison is different)
		// - Aggregation: auto
		// - Data state: include fresh data only for very recent end dates
		if ( method_exists( $request, 'setSearchType' ) ) {
			$request->setSearchType( 'web' );
		}
		if ( method_exists( $request, 'setAggregationType' ) ) {
			$request->setAggregationType( 'auto' );
		}
		if ( method_exists( $request, 'setDataState' ) ) {
			$recent_threshold = gmdate( 'Y-m-d', strtotime( '-3 day' ) );
			$data_state = ( $end_date >= $recent_threshold ) ? 'all' : 'final';
			$request->setDataState( $data_state );
		}

		$all       = array();
		$start_row = 0;
		$max_loops = 50;
		$max_retries = 3;

		for ( $i = 0; $i < $max_loops; $i++ ) {
			$request->setStartRow( $start_row );

			$response = null;
			$attempt  = 0;
			while ( $attempt < $max_retries ) {
				$attempt++;
				try {
					$response = $service->searchanalytics->query( $property, $request );
					// Success: break retry loop.
					break;
				} catch ( \Throwable $e ) {
					// On last attempt, return error.
					if ( $attempt >= $max_retries ) {
						return new \WP_Error(
							'serpiq_gsc_query_failed',
							sprintf(
								/* translators: 1: number of attempts, 2: error message */
								__( 'Search Console query failed after %1$d attempts: %2$s', 'seotune-search-insights' ),
								$attempt,
								$e->getMessage()
							)
						);
					}

					// Simple exponential backoff: 1s, 2s, 4s.
					$delay = pow( 2, $attempt - 1 );
					sleep( $delay );
					// Then retry.
				}
			}

			$rows = array();
			if ( is_object( $response ) && method_exists( $response, 'getRows' ) ) {
				$rows = $response->getRows();
			} elseif ( is_object( $response ) && isset( $response->rows ) ) {
				$rows = $response->rows;
			}

			if ( empty( $rows ) ) {
				break;
			}

			foreach ( $rows as $row ) {
				$all[] = $this->normalize_row( $row );
			}

			$count = is_countable( $rows ) ? count( $rows ) : 0;
			if ( $count < 25000 ) {
				break;
			}
			$start_row += 25000;
		}

		return $all;
	}

	/**
	 * @return array{keys:array<int,string>,clicks:float|int,impressions:float|int,ctr:float|int,position:float|int}
	 */
	private function normalize_row( $row ): array {
		if ( is_object( $row ) ) {
			$keys = method_exists( $row, 'getKeys' ) ? (array) $row->getKeys() : ( isset( $row->keys ) ? (array) $row->keys : array() );
			$clicks = method_exists( $row, 'getClicks' ) ? $row->getClicks() : ( $row->clicks ?? 0 );
			$impressions = method_exists( $row, 'getImpressions' ) ? $row->getImpressions() : ( $row->impressions ?? 0 );
			$ctr = method_exists( $row, 'getCtr' ) ? $row->getCtr() : ( $row->ctr ?? 0 );
			$position = method_exists( $row, 'getPosition' ) ? $row->getPosition() : ( $row->position ?? 0 );
			return array(
				'keys'        => array_map( 'strval', $keys ),
				'clicks'      => $clicks,
				'impressions' => $impressions,
				'ctr'         => $ctr,
				'position'    => $position,
			);
		}

		if ( is_array( $row ) ) {
			return array(
				'keys'        => isset( $row['keys'] ) ? array_map( 'strval', (array) $row['keys'] ) : array(),
				'clicks'      => $row['clicks'] ?? 0,
				'impressions' => $row['impressions'] ?? 0,
				'ctr'         => $row['ctr'] ?? 0,
				'position'    => $row['position'] ?? 0,
			);
		}

		return array(
			'keys'        => array(),
			'clicks'      => 0,
			'impressions' => 0,
			'ctr'         => 0,
			'position'    => 0,
		);
	}
}

