<?php

namespace SerpIQ\Api\Controllers;

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

/**
 * Decay SEO: identify pages whose organic performance is declining
 * by comparing a current period vs a previous period, using data
 * already imported from Google Search Console into serpiq_pages.
 *
 * D1: GSC-only aggregation.
 * D2: Decay Score (0–100) + probable cause classification.
 * D3: WordPress integration (post resolution, freshness, edit link).
 */
final class Decay_Controller {

	/** Probable cause keys returned by classify_probable_cause(). */
	const CAUSE_POSITION_DROP   = 'position_drop';
	const CAUSE_CTR_DROP        = 'ctr_drop';
	const CAUSE_IMPRESSIONS_DROP = 'impressions_drop';
	const CAUSE_CLICKS_DROP     = 'clicks_drop';

	/** Human-readable labels for probable causes (for API/frontend). */
	const CAUSE_LABELS = array(
		self::CAUSE_POSITION_DROP    => 'Position drop',
		self::CAUSE_CTR_DROP         => 'CTR drop',
		self::CAUSE_IMPRESSIONS_DROP => 'Impressions drop',
		self::CAUSE_CLICKS_DROP      => 'Clicks drop',
	);
	private static ?Decay_Controller $instance = null;

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

	public function register_routes(): void {
		register_rest_route(
			'serpiq/v1',
			'/decay',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'get_decay' ),
				'permission_callback' => array( \SerpIQ\Api\Rest_Permission::class, 'admin_rest_request' ),
				'args'                => array(
					'period' => array(
						'required'          => false,
						'type'              => 'integer',
						'default'           => 28,
						'sanitize_callback' => 'absint',
						'enum'              => array( 28, 90 ),
					),
					'end' => array(
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
						'validate_callback' => array( $this, 'validate_date_arg' ),
					),
					'min_impressions_prev' => array(
						'required'          => false,
						'type'              => 'integer',
						'default'           => 20,
						'sanitize_callback' => 'absint',
					),
					'min_clicks_prev' => array(
						'required'          => false,
						'type'              => 'integer',
						'default'           => 0,
						'sanitize_callback' => 'absint',
					),
				),
			)
		);
	}

	public function validate_date_arg( mixed $value, \WP_REST_Request $request, string $param ): bool {
		unset( $request, $param );
		$v = is_scalar( $value ) ? (string) $value : '';
		$v = trim( $v );
		if ( '' === $v ) {
			return true;
		}
		return (bool) preg_match( '/^\d{4}-\d{2}-\d{2}$/', $v );
	}

	/**
	 * Returns pages with negative deltas between current and previous period.
	 *
	 * Query args:
	 * - period: 28 or 90 (days). Default 28.
	 * - end: optional end date (Y-m-d). Default: yesterday.
	 * - min_impressions_prev: minimum impressions in previous period to include a page (default: 20).
	 * - min_clicks_prev: minimum clicks in previous period (default: 0).
	 */
	public function get_decay( \WP_REST_Request $request ): \WP_REST_Response {
		global $wpdb;

		$period = absint( $request->get_param( 'period' ) );
		if ( ! in_array( $period, array( 28, 90 ), true ) ) {
			$period = 28;
		}

		$end_param = (string) $request->get_param( 'end' );
		$end_date = $this->sanitize_date( $end_param );
		if ( ! $end_date ) {
			$end_date = gmdate( 'Y-m-d', strtotime( '-1 day' ) );
		}

		$end_ts = strtotime( $end_date . ' 00:00:00 UTC' );
		if ( false === $end_ts ) {
			$end_ts = strtotime( '-1 day' );
			$end_date = gmdate( 'Y-m-d', $end_ts );
		}

		$current_start_ts = $end_ts - ( $period - 1 ) * DAY_IN_SECONDS;
		$current_start = gmdate( 'Y-m-d', $current_start_ts );

		$prev_end_ts = $current_start_ts - DAY_IN_SECONDS;
		$prev_start_ts = $prev_end_ts - ( $period - 1 ) * DAY_IN_SECONDS;
		$prev_end = gmdate( 'Y-m-d', $prev_end_ts );
		$prev_start = gmdate( 'Y-m-d', $prev_start_ts );

		$min_impr_prev = absint( $request->get_param( 'min_impressions_prev' ) );
		if ( $min_impr_prev <= 0 ) {
			$min_impr_prev = 20;
		}
		$min_clicks_prev = absint( $request->get_param( 'min_clicks_prev' ) );
		if ( $min_clicks_prev < 0 ) {
			$min_clicks_prev = 0;
		}

		$table_pages = $wpdb->prefix . 'serpiq_pages';

		// Aggregate current period.
		$sql_current = "
			SELECT
				page_url,
				COALESCE(SUM(clicks),0) AS clicks,
				COALESCE(SUM(impressions),0) AS impressions,
				CASE WHEN COALESCE(SUM(impressions),0) > 0 THEN (COALESCE(SUM(clicks),0) / COALESCE(SUM(impressions),0)) ELSE 0 END AS ctr,
				CASE WHEN COALESCE(SUM(impressions),0) > 0 THEN (COALESCE(SUM(position * impressions),0) / COALESCE(SUM(impressions),0)) ELSE 0 END AS position
			FROM %i
			WHERE date BETWEEN %s AND %s
			GROUP BY page_url
		";
		$current_rows = $wpdb->get_results(
			$wpdb->prepare( $sql_current, array_merge( array( $table_pages ), array( $current_start, $end_date ) ) ),
			ARRAY_A
		);

		// Aggregate previous period.
		$sql_prev = "
			SELECT
				page_url,
				COALESCE(SUM(clicks),0) AS clicks,
				COALESCE(SUM(impressions),0) AS impressions,
				CASE WHEN COALESCE(SUM(impressions),0) > 0 THEN (COALESCE(SUM(clicks),0) / COALESCE(SUM(impressions),0)) ELSE 0 END AS ctr,
				CASE WHEN COALESCE(SUM(impressions),0) > 0 THEN (COALESCE(SUM(position * impressions),0) / COALESCE(SUM(impressions),0)) ELSE 0 END AS position
			FROM %i
			WHERE date BETWEEN %s AND %s
			GROUP BY page_url
		";
		$prev_rows = $wpdb->get_results(
			$wpdb->prepare( $sql_prev, array_merge( array( $table_pages ), array( $prev_start, $prev_end ) ) ),
			ARRAY_A
		);

		$current_map = array();
		foreach ( (array) $current_rows as $row ) {
			if ( empty( $row['page_url'] ) ) {
				continue;
			}
			$current_map[ (string) $row['page_url'] ] = $row;
		}

		$prev_map = array();
		foreach ( (array) $prev_rows as $row ) {
			if ( empty( $row['page_url'] ) ) {
				continue;
			}
			$prev_map[ (string) $row['page_url'] ] = $row;
		}

		$all_urls = array_unique( array_merge( array_keys( $current_map ), array_keys( $prev_map ) ) );

		$items = array();
		$total_clicks_loss = 0;
		$total_impressions_loss = 0;
		$pages_in_decay = 0;

		foreach ( $all_urls as $url ) {
			$curr = $current_map[ $url ] ?? array();
			$prev = $prev_map[ $url ] ?? array();

			$c_clicks = (int) ( $curr['clicks'] ?? 0 );
			$c_impr   = (int) ( $curr['impressions'] ?? 0 );
			$c_ctr    = (float) ( $curr['ctr'] ?? 0 );
			$c_pos    = (float) ( $curr['position'] ?? 0 );

			$p_clicks = (int) ( $prev['clicks'] ?? 0 );
			$p_impr   = (int) ( $prev['impressions'] ?? 0 );
			$p_ctr    = (float) ( $prev['ctr'] ?? 0 );
			$p_pos    = (float) ( $prev['position'] ?? 0 );

			// Apply minimal thresholds on previous period to avoid noise.
			if ( $p_impr < $min_impr_prev || $p_clicks < $min_clicks_prev ) {
				continue;
			}

			$delta_clicks = $c_clicks - $p_clicks;
			$delta_impr   = $c_impr - $p_impr;
			$delta_ctr    = $c_ctr - $p_ctr;
			$delta_pos    = $c_pos - $p_pos;

			$is_decay = ( $delta_clicks < 0 ) || ( $delta_impr < 0 ) || ( $delta_pos > 0 ) || ( $delta_ctr < 0 );
			if ( ! $is_decay ) {
				continue;
			}

			if ( $delta_clicks < 0 ) {
				$total_clicks_loss += abs( $delta_clicks );
			}
			if ( $delta_impr < 0 ) {
				$total_impressions_loss += abs( $delta_impr );
			}
			$pages_in_decay++;

			$decay_score = $this->compute_decay_score(
				$p_clicks,
				$p_impr,
				$p_ctr,
				$p_pos,
				$delta_clicks,
				$delta_impr,
				$delta_ctr,
				$delta_pos
			);

			$probable_cause = $this->classify_probable_cause(
				$delta_clicks,
				$delta_impr,
				$delta_ctr,
				$delta_pos,
				$p_clicks,
				$p_impr
			);

			$items[] = array(
				'page_url'               => $url,
				'clicks_current'         => $c_clicks,
				'clicks_previous'        => $p_clicks,
				'impressions_current'    => $c_impr,
				'impressions_previous'   => $p_impr,
				'ctr_current'            => $c_ctr,
				'ctr_previous'            => $p_ctr,
				'position_current'       => $c_pos,
				'position_previous'       => $p_pos,
				'delta_clicks'           => $delta_clicks,
				'delta_impressions'      => $delta_impr,
				'delta_ctr'              => $delta_ctr,
				'delta_position'         => $delta_pos,
				'decay_score'            => $decay_score,
				'probable_cause'         => $probable_cause,
				'probable_cause_label'   => self::CAUSE_LABELS[ $probable_cause ] ?? $probable_cause,
			);
		}

		// Sort by decay score descending (worst decay first).
		usort(
			$items,
			static function ( $a, $b ) {
				$sa = (float) ( $a['decay_score'] ?? 0 );
				$sb = (float) ( $b['decay_score'] ?? 0 );
				return $sb <=> $sa;
			}
		);

		// D3: attach WordPress post data and freshness for each item.
		$reference_date = $end_date;
		foreach ( $items as &$item ) {
			$wp = $this->get_wp_data_for_url( (string) ( $item['page_url'] ?? '' ), $reference_date );
			$item['wp_post_id']       = $wp['wp_post_id'];
			$item['wp_edit_link']     = $wp['wp_edit_link'];
			$item['wp_post_modified'] = $wp['wp_post_modified'];
			$item['wp_post_date']     = $wp['wp_post_date'];
			$item['wp_post_type']     = $wp['wp_post_type'];
			$item['wp_post_title']    = $wp['wp_post_title'];
			$item['freshness_days']   = $wp['freshness_days'];
		}
		unset( $item );

		$response = array(
			'periodDays' => $period,
			'currentRange' => array(
				'start' => $current_start,
				'end'   => $end_date,
			),
			'previousRange' => array(
				'start' => $prev_start,
				'end'   => $prev_end,
			),
			'overview' => array(
				'pagesInDecay'        => $pages_in_decay,
				'clicksLost'          => $total_clicks_loss,
				'impressionsLost'     => $total_impressions_loss,
				'minImpressionsPrev'  => $min_impr_prev,
				'minClicksPrev'       => $min_clicks_prev,
			),
			'items' => $items,
		);

		return new \WP_REST_Response( $response, 200 );
	}

	/**
	 * Computes a 0–100 Decay Score (higher = worse decay).
	 *
	 * Formula: weighted sum of
	 * - Relative click loss (vs previous clicks), capped
	 * - Relative impression loss (vs previous impressions), capped
	 * - Position worsening (absolute position gain), capped
	 * - CTR drop (absolute CTR delta), capped
	 * Total is capped at 100.
	 *
	 * @param int   $p_clicks   Previous period clicks.
	 * @param int   $p_impr     Previous period impressions.
	 * @param float $p_ctr      Previous period CTR (0–1).
	 * @param float $p_pos      Previous period avg position.
	 * @param int   $delta_clicks   Delta clicks (negative = loss).
	 * @param int   $delta_impr    Delta impressions (negative = loss).
	 * @param float $delta_ctr     Delta CTR (negative = drop).
	 * @param float $delta_pos     Delta position (positive = worse).
	 * @return float 0–100
	 */
	private function compute_decay_score(
		int $p_clicks,
		int $p_impr,
		float $p_ctr,
		float $p_pos,
		int $delta_clicks,
		int $delta_impr,
		float $delta_ctr,
		float $delta_pos
	): float {
		$score = 0.0;

		// Relative click loss: up to 35 points. 100% loss = 35.
		if ( $p_clicks > 0 && $delta_clicks < 0 ) {
			$rel = min( 1.0, (float) abs( $delta_clicks ) / (float) $p_clicks );
			$score += $rel * 35;
		}

		// Relative impression loss: up to 35 points.
		if ( $p_impr > 0 && $delta_impr < 0 ) {
			$rel = min( 1.0, (float) abs( $delta_impr ) / (float) $p_impr );
			$score += $rel * 35;
		}

		// Position worsening: up to 20 points. +4 positions = 20.
		if ( $delta_pos > 0 ) {
			$score += min( 20.0, $delta_pos * 5.0 );
		}

		// CTR drop: up to 15 points. CTR is in 0–1; -0.15 = 15.
		if ( $delta_ctr < 0 ) {
			$score += min( 15.0, (float) abs( $delta_ctr ) * 100.0 );
		}

		return min( 100.0, round( $score, 1 ) );
	}

	/**
	 * Classifies the primary probable cause of decay from deltas.
	 *
	 * Priority: position drop > impressions drop > clicks drop > CTR drop.
	 * When position clearly worsened, that's the main cause; when visibility
	 * (impressions) dropped most, we label impressions drop; etc.
	 *
	 * @return string One of CAUSE_* constants.
	 */
	private function classify_probable_cause(
		int $delta_clicks,
		int $delta_impr,
		float $delta_ctr,
		float $delta_pos,
		int $p_clicks,
		int $p_impr
	): string {
		// Strong position worsening (e.g. +1 or more) → ranking drop.
		if ( $delta_pos >= 0.5 ) {
			return self::CAUSE_POSITION_DROP;
		}

		// Significant impression loss (relative) and we had visibility before.
		if ( $p_impr > 0 && $delta_impr < 0 ) {
			$rel_impr = (float) abs( $delta_impr ) / (float) $p_impr;
			if ( $rel_impr >= 0.15 ) {
				return self::CAUSE_IMPRESSIONS_DROP;
			}
		}

		// Significant click loss (relative).
		if ( $p_clicks > 0 && $delta_clicks < 0 ) {
			$rel_clicks = (float) abs( $delta_clicks ) / (float) $p_clicks;
			if ( $rel_clicks >= 0.15 ) {
				return self::CAUSE_CLICKS_DROP;
			}
		}

		// CTR dropped (snippet/title relevance) when position didn't worsen much.
		if ( $delta_ctr < 0 ) {
			return self::CAUSE_CTR_DROP;
		}

		// Fallback by dominant negative delta.
		if ( $delta_impr < 0 ) {
			return self::CAUSE_IMPRESSIONS_DROP;
		}
		if ( $delta_clicks < 0 ) {
			return self::CAUSE_CLICKS_DROP;
		}
		return self::CAUSE_POSITION_DROP;
	}

	/**
	 * Resolves a page URL to WordPress post and returns post data + freshness.
	 *
	 * D3: used to show edit link, post type, last modified date, and freshness
	 * (days since last update as of reference_date) for decay items.
	 *
	 * @param string $url             Full page URL (e.g. from GSC).
	 * @param string $reference_date  Reference date Y-m-d for freshness (e.g. end of analysis period).
	 * @return array{ wp_post_id: int|null, wp_edit_link: string|null, wp_post_modified: string|null, wp_post_date: string|null, wp_post_type: string|null, wp_post_title: string|null, freshness_days: int|null }
	 */
	private function get_wp_data_for_url( string $url, string $reference_date ): array {
		$empty = array(
			'wp_post_id'       => null,
			'wp_edit_link'     => null,
			'wp_post_modified' => null,
			'wp_post_date'     => null,
			'wp_post_type'     => null,
			'wp_post_title'    => null,
			'freshness_days'   => null,
		);

		$url = trim( $url );
		if ( '' === $url ) {
			return $empty;
		}

		if ( ! function_exists( 'url_to_postid' ) ) {
			return $empty;
		}

		$normalized = trailingslashit( $url );
		$post_id    = url_to_postid( $normalized );
		if ( $post_id <= 0 ) {
			return $empty;
		}

		$post = get_post( $post_id );
		if ( ! $post || ! ( $post instanceof \WP_Post ) ) {
			return $empty;
		}

		$modified = ! empty( $post->post_modified ) ? $post->post_modified : $post->post_date;
		$modified_date = $modified ? gmdate( 'Y-m-d', strtotime( $modified ) ) : null;
		$published_date = ! empty( $post->post_date ) ? gmdate( 'Y-m-d', strtotime( $post->post_date ) ) : null;

		// Freshness: days from post_modified to reference_date (how many days old the content was at reference_date).
		$freshness_days = null;
		if ( $modified_date && preg_match( '/^\d{4}-\d{2}-\d{2}$/', $reference_date ) ) {
			$ref_ts  = strtotime( $reference_date . ' 00:00:00 UTC' );
			$mod_ts  = strtotime( $modified_date . ' 00:00:00 UTC' );
			if ( false !== $ref_ts && false !== $mod_ts ) {
				$days = (int) floor( ( $ref_ts - $mod_ts ) / DAY_IN_SECONDS );
				$freshness_days = max( 0, $days );
			}
		}

		$edit_link = null;
		if ( current_user_can( 'edit_post', $post_id ) ) {
			$edit_link = get_edit_post_link( $post_id, 'raw' );
		}

		return array(
			'wp_post_id'       => $post_id,
			'wp_edit_link'     => $edit_link ?: null,
			'wp_post_modified' => $modified_date,
			'wp_post_date'     => $published_date,
			'wp_post_type'     => $post->post_type ?? null,
			'wp_post_title'    => $post->post_title ?? null,
			'freshness_days'   => $freshness_days,
		);
	}

	private function sanitize_date( string $date ): ?string {
		if ( '' === $date ) {
			return null;
		}
		return preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date ) ? $date : null;
	}
}

