<?php

namespace SerpIQ\Links;

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

/**
 * Internal Linking Opportunities: backend scoring model (M2).
 *
 * Uses:
 * - serpiq_pages (GSC data per page) for current vs previous period.
 * - serpiq_links (internal link graph) for inbound/outbound counts.
 * - WordPress posts (age, status, taxonomy).
 *
 * This class does NOT expose any REST routes; controllers can
 * call ::compute() to power APIs and UI.
 */
final class Internal_Opportunities {
	private static ?Internal_Opportunities $instance = null;

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

	private function __construct() {}

	/**
	 * Compute internal linking opportunities for the given period.
	 *
	 * @param string $end_date     End date (Y-m-d). If empty, defaults to yesterday (UTC).
	 * @param int    $period_days  Number of days in current period (28 or 90).
	 * @param array  $args         Optional filters:
	 *                             - min_impressions (int)
	 *                             - min_clicks_total (int)
	 *                             - min_age_days (int)
	 * @return array{overview:array<string,mixed>,items:array<int,array<string,mixed>>}
	 */
	public function compute( string $end_date = '', int $period_days = 28, array $args = array() ): array {
		global $wpdb;

		if ( $period_days !== 90 ) {
			$period_days = 28;
		}

		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_days - 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_days - 1 ) * DAY_IN_SECONDS;
		$prev_end      = gmdate( 'Y-m-d', $prev_end_ts );
		$prev_start    = gmdate( 'Y-m-d', $prev_start_ts );

		// Thresholds.
		$min_impressions = isset( $args['min_impressions'] ) ? (int) $args['min_impressions'] : ( ( 90 === $period_days ) ? 300 : 100 );
		$min_clicks_total = isset( $args['min_clicks_total'] ) ? (int) $args['min_clicks_total'] : 5;
		$min_age_days    = isset( $args['min_age_days'] ) ? (int) $args['min_age_days'] : 30;

		// Aggregate GSC page data for both periods.
		$table_pages = $wpdb->prefix . 'serpiq_pages';

		$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
		);

		$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 ) ) );

		// Preload link counts from serpiq_links.
		$table_links = $wpdb->prefix . 'serpiq_links';
		if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table_links ) ) {
			return array(
				'overview' => array(),
				'items'    => array(),
			);
		}

		$inbound_counts  = array();
		$outbound_counts = array();

		$in_rows = $wpdb->get_results( $wpdb->prepare( 'SELECT to_post_id, COUNT(*) AS cnt FROM %i GROUP BY to_post_id', $table_links ), ARRAY_A );
		foreach ( (array) $in_rows as $row ) {
			$post_id = (int) ( $row['to_post_id'] ?? 0 );
			if ( $post_id <= 0 ) {
				continue;
			}
			$inbound_counts[ $post_id ] = (int) $row['cnt'];
		}

		$out_rows = $wpdb->get_results( $wpdb->prepare( 'SELECT from_post_id, COUNT(*) AS cnt FROM %i GROUP BY from_post_id', $table_links ), ARRAY_A );
		foreach ( (array) $out_rows as $row ) {
			$post_id = (int) ( $row['from_post_id'] ?? 0 );
			if ( $post_id <= 0 ) {
				continue;
			}
			$outbound_counts[ $post_id ] = (int) $row['cnt'];
		}

		$candidates              = array();
		$total_impressions_involved = 0;
		$high_count              = 0;
		$medium_count            = 0;
		$light_count             = 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 );

			// Filters on GSC data.
			if ( $c_impr < $min_impressions ) {
				continue;
			}
			if ( ( $c_clicks + $p_clicks ) < $min_clicks_total ) {
				continue;
			}

			$delta_clicks     = $c_clicks - $p_clicks;
			$delta_clicks_pct = ( $p_clicks > 0 ) ? ( $delta_clicks / $p_clicks ) : 0.0;

			$delta_impr     = $c_impr - $p_impr;
			$delta_impr_pct = ( $p_impr > 0 ) ? ( $delta_impr / $p_impr ) : 0.0;

			$delta_ctr = $c_ctr - $p_ctr;
			$delta_pos = $c_pos - $p_pos;

			// Resolve to WP post.
			$wp = $this->get_wp_data_for_url( (string) $url );
			if ( null === $wp['post'] ) {
				continue;
			}
			$post = $wp['post'];
			if ( 'publish' !== $post->post_status ) {
				continue;
			}

			$age_days = $wp['age_days'];
			if ( $age_days < $min_age_days ) {
				continue;
			}

			$post_id = (int) $post->ID;
			$L_in    = $inbound_counts[ $post_id ] ?? 0;
			$L_out   = $outbound_counts[ $post_id ] ?? 0;

			// Compute normalized scores.
			$op_score    = $this->compute_organic_potential_score( $c_clicks, $c_impr );
			$pos_score   = $this->compute_position_opportunity_score( $c_pos );
			$link_gap    = $this->compute_link_gap_score( $L_in );
			$trend_score = $this->compute_trend_score( $delta_clicks_pct );

			$score = $this->compute_opportunity_score(
				$op_score,
				$pos_score,
				$link_gap,
				$trend_score
			);

			// If overall score is too low, consider it well supported / no action.
			if ( $score < 30.0 ) {
				continue;
			}

			$level = $this->classify_level( $score, $link_gap, $c_pos );
			if ( 'well_supported' === $level ) {
				continue;
			}

			$reason = $this->classify_reason(
				$op_score,
				$link_gap,
				$c_pos,
				$c_impr,
				$L_in,
				$delta_clicks_pct,
				$delta_impr_pct
			);

			if ( 'high' === $level ) {
				$high_count++;
			} elseif ( 'medium' === $level ) {
				$medium_count++;
			} elseif ( 'light' === $level ) {
				$light_count++;
			}

			$total_impressions_involved += $c_impr;

			$candidates[] = array(
				'page_url'             => $url,
				'post_id'              => $post_id,
				'post_type'            => $post->post_type,
				'post_status'          => $post->post_status,
				'post_title'           => $post->post_title,
				'post_date'            => $post->post_date_gmt ?: $post->post_date,
				'post_modified'        => $post->post_modified_gmt ?: $post->post_modified,
				'age_days'             => $age_days,
				'inbound_links'        => $L_in,
				'outbound_links'       => $L_out,
				'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_clicks_pct'     => $delta_clicks_pct,
				'delta_impressions'    => $delta_impr,
				'delta_impressions_pct'=> $delta_impr_pct,
				'delta_ctr'            => $delta_ctr,
				'delta_position'       => $delta_pos,
				'organic_potential'    => $op_score,
				'position_opportunity' => $pos_score,
				'link_gap'             => $link_gap,
				'trend_score'          => $trend_score,
				'opportunity_score'    => $score,
				'opportunity_level'    => $level,
				'opportunity_reason'   => $reason['code'],
				'opportunity_reason_label' => $reason['label'],
			);
		}

		// Sort by opportunity score descending.
		usort(
			$candidates,
			static function ( $a, $b ) {
				$sa = (float) ( $a['opportunity_score'] ?? 0 );
				$sb = (float) ( $b['opportunity_score'] ?? 0 );
				return $sb <=> $sa;
			}
		);

		$overview = array(
			'periodDays'     => $period_days,
			'currentRange'   => array(
				'start' => $current_start,
				'end'   => $end_date,
			),
			'previousRange'  => array(
				'start' => $prev_start,
				'end'   => $prev_end,
			),
			'totalCandidates'=> count( $candidates ),
			'highCount'      => $high_count,
			'mediumCount'    => $medium_count,
			'lightCount'     => $light_count,
			'impressionsInvolved' => $total_impressions_involved,
			'minImpressions' => $min_impressions,
			'minClicksTotal' => $min_clicks_total,
			'minAgeDays'     => $min_age_days,
		);

		return array(
			'overview' => $overview,
			'items'    => $candidates,
		);
	}

	/**
	 * Resolve URL to WP_Post and compute age.
	 *
	 * @return array{post:?\WP_Post,age_days:int}
	 */
	private function get_wp_data_for_url( string $url ): array {
		$url = trim( $url );
		if ( '' === $url ) {
			return array(
				'post'     => null,
				'age_days' => 0,
			);
		}

		if ( ! function_exists( 'url_to_postid' ) ) {
			return array(
				'post'     => null,
				'age_days' => 0,
			);
		}

		$post_id = url_to_postid( trailingslashit( $url ) );
		if ( $post_id <= 0 ) {
			return array(
				'post'     => null,
				'age_days' => 0,
			);
		}

		$post = get_post( $post_id );
		if ( ! $post || ! ( $post instanceof \WP_Post ) ) {
			return array(
				'post'     => null,
				'age_days' => 0,
			);
		}

		$today    = gmdate( 'Y-m-d' );
		$today_ts = strtotime( $today . ' 00:00:00 UTC' );

		$published = $post->post_date_gmt ?: $post->post_date;
		$pub_date  = $published ? gmdate( 'Y-m-d', strtotime( $published ) ) : $today;
		$pub_ts    = strtotime( $pub_date . ' 00:00:00 UTC' );

		$age_days = ( $pub_ts && $today_ts )
			? max( 0, (int) floor( ( $today_ts - $pub_ts ) / DAY_IN_SECONDS ) )
			: 0;

		return array(
			'post'     => $post,
			'age_days' => $age_days,
		);
	}

	/**
	 * Organic potential score (0–1) from clicks and impressions.
	 */
	private function compute_organic_potential_score( int $clicks, int $impressions ): float {
		$pot_raw   = log( 1 + max( 0, $impressions ) ) + 0.5 * log( 1 + max( 0, $clicks ) );
		$pot_denom = log( 1 + 10000 ); // reference high-volume page.
		if ( $pot_denom <= 0 ) {
			return 0.0;
		}
		return max( 0.0, min( 1.0, $pot_raw / $pot_denom ) );
	}

	/**
	 * Position opportunity score (0–1): highest for positions ~4–12.
	 */
	private function compute_position_opportunity_score( float $position ): float {
		if ( $position <= 0 ) {
			return 0.0;
		}
		if ( $position < 2 ) {
			// Already very strong.
			return 0.1;
		}
		if ( $position >= 4 && $position <= 12 ) {
			return 1.0;
		}
		if ( $position > 12 && $position <= 20 ) {
			// Gradual decay after 12.
			$score = 1.0 - ( ( $position - 12.0 ) / 8.0 );
			return max( 0.0, min( 1.0, $score ) );
		}
		if ( $position >= 2 && $position < 4 ) {
			return 0.4;
		}
		if ( $position > 20 && $position <= 30 ) {
			return 0.2;
		}
		return 0.0;
	}

	/**
	 * Link gap score (0–1): 1 means almost no links, 0 means well supported.
	 */
	private function compute_link_gap_score( int $inbound_links ): float {
		// Assume 10 inbound links is "good enough" baseline.
		$L_ref  = 10.0;
		$L_norm = min( 1.0, max( 0.0, $inbound_links / $L_ref ) );
		return max( 0.0, min( 1.0, 1.0 - $L_norm ) );
	}

	/**
	 * Trend score (0–1) based on positive click growth.
	 */
	private function compute_trend_score( float $delta_clicks_pct ): float {
		if ( $delta_clicks_pct <= 0 ) {
			return 0.0;
		}
		// +30% or more → 1.
		return max( 0.0, min( 1.0, $delta_clicks_pct / 0.3 ) );
	}

	/**
	 * Combine normalized scores into 0–100 opportunity score.
	 */
	private function compute_opportunity_score(
		float $op_score,
		float $pos_score,
		float $link_gap,
		float $trend_score
	): float {
		$score = 100.0 * (
			0.35 * $op_score +
			0.25 * $pos_score +
			0.30 * $link_gap +
			0.10 * $trend_score
		);

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

	/**
	 * Classify opportunity level from score and link gap.
	 *
	 * @return string One of: high, medium, light, well_supported
	 */
	private function classify_level( float $score, float $link_gap, float $position ): string {
		if ( $score < 30.0 ) {
			return 'well_supported';
		}

		// High: strong score, big link gap, and position in useful range.
		if ( $score >= 75.0 && $link_gap >= 0.5 && $position > 0 && $position <= 20 ) {
			return 'high';
		}

		if ( $score >= 50.0 ) {
			return 'medium';
		}

		return 'light';
	}

	/**
	 * Classify main reason for opportunity.
	 *
	 * @return array{code:string,label:string}
	 */
	private function classify_reason(
		float $op_score,
		float $link_gap,
		float $position_current,
		int $impressions_current,
		int $inbound_links,
		float $delta_clicks_pct,
		float $delta_impr_pct
	): array {
		$strong_visibility = $impressions_current >= 1000;
		$high_potential    = $op_score >= 0.6;
		$almost_isolated   = $inbound_links <= 1;
		$declining         = ( $delta_clicks_pct <= -0.25 ) || ( $delta_impr_pct <= -0.25 );
		$growing           = $delta_clicks_pct >= 0.2;

		// 1) Few links vs high visibility.
		if ( $strong_visibility && $high_potential && $link_gap >= 0.7 ) {
			return array(
				'code'  => 'few_links_high_visibility',
				'label' => 'Few internal links vs visibility',
			);
		}

		// 2) Promising but isolated (positions 4–15, almost no inbound).
		if ( $position_current >= 4 && $position_current <= 15 && $almost_isolated && $strong_visibility ) {
			return array(
				'code'  => 'promising_but_isolated',
				'label' => 'Promising page but almost isolated',
			);
		}

		// 3) Growing page that needs support.
		if ( $growing && $link_gap >= 0.4 ) {
			return array(
				'code'  => 'growing_needs_support',
				'label' => 'Growing page that needs support',
			);
		}

		// 4) Declining with weak support.
		if ( $declining && $link_gap >= 0.4 ) {
			return array(
				'code'  => 'declining_weak_support',
				'label' => 'Declining page with weak internal support',
			);
		}

		// 5) General gap: not enough links for its role.
		if ( $link_gap >= 0.5 ) {
			return array(
				'code'  => 'underlinked_page',
				'label' => 'Page underlinked for its potential',
			);
		}

		return array(
			'code'  => 'general_linking_opportunity',
			'label' => 'Internal linking opportunity',
		);
	}
}

