<?php

namespace SerpIQ\Api\Controllers;

use SerpIQ\Links\Internal_Opportunities;

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

/**
 * REST: Internal Linking Opportunities (list + basic detail).
 *
 * Milestone: expose Internal_Opportunities backend model.
 */
final class Internal_Linking_Controller {
	private static ?Internal_Linking_Controller $instance = null;

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

	public function register_routes(): void {
		register_rest_route(
			'serpiq/v1',
			'/internal-linking',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'list_items' ),
				'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' => array( 'required' => false, 'type' => 'integer', 'sanitize_callback' => 'absint' ),
					'min_clicks_total'=> array( 'required' => false, 'type' => 'integer', 'sanitize_callback' => 'absint' ),
					'min_age_days'    => array( 'required' => false, 'type' => 'integer', 'sanitize_callback' => 'absint' ),
					'post_type'       => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_key' ),
					'level'           => array( 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_key' ),
					'page'            => array( 'required' => false, 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint' ),
					'per_page'        => array( 'required' => false, 'type' => 'integer', 'default' => 20, 'sanitize_callback' => 'absint' ),
				),
			)
		);

		register_rest_route(
			'serpiq/v1',
			'/internal-linking/detail',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'detail' ),
				'permission_callback' => array( \SerpIQ\Api\Rest_Permission::class, 'admin_rest_request' ),
				'args'                => array(
					'post_id' => array(
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					),
					'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' ) ),
				),
			)
		);
	}

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

	/**
	 * List internal linking opportunities (overview + paginated items).
	 */
	public function list_items( \WP_REST_Request $request ): \WP_REST_Response {
		$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' ) );
		}

		$args = array(
			'min_impressions'  => absint( $request->get_param( 'min_impressions' ) ),
			'min_clicks_total' => absint( $request->get_param( 'min_clicks_total' ) ),
			'min_age_days'     => absint( $request->get_param( 'min_age_days' ) ),
		);

		$result = Internal_Opportunities::instance()->compute( $end_date, $period, $args );

		$items    = isset( $result['items'] ) && is_array( $result['items'] ) ? $result['items'] : array();
		$overview = isset( $result['overview'] ) && is_array( $result['overview'] ) ? $result['overview'] : array();

		// Optional filters at API level.
		$level_filter = sanitize_key( (string) $request->get_param( 'level' ) );
		if ( '' !== $level_filter ) {
			$items = array_values(
				array_filter(
					$items,
					static function ( $row ) use ( $level_filter ) {
						return isset( $row['opportunity_level'] ) && $row['opportunity_level'] === $level_filter;
					}
				)
			);
		}

		$post_type_filter = (string) $request->get_param( 'post_type' );
		if ( '' !== $post_type_filter ) {
			$items = array_values(
				array_filter(
					$items,
					static function ( $row ) use ( $post_type_filter ) {
						return isset( $row['post_type'] ) && $row['post_type'] === $post_type_filter;
					}
				)
			);
		}

		// Pagination.
		$page     = max( 1, absint( $request->get_param( 'page' ) ) );
		$per_page = absint( $request->get_param( 'per_page' ) );
		if ( $per_page <= 0 ) {
			$per_page = 20;
		} elseif ( $per_page > 100 ) {
			$per_page = 100;
		}

		$total      = count( $items );
		$total_page = $per_page > 0 ? (int) ceil( $total / $per_page ) : 1;
		$offset     = ( $page - 1 ) * $per_page;
		if ( $offset < 0 ) {
			$offset = 0;
		}

		$paged_items = array_slice( $items, $offset, $per_page );

		$overview['total']      = $total;
		$overview['page']       = $page;
		$overview['perPage']    = $per_page;
		$overview['totalPages'] = $total_page;

		return new \WP_REST_Response(
			array(
				'overview' => $overview,
				'items'    => $paged_items,
			),
			200
		);
	}

	/**
	 * Basic detail for a single post (reuses compute and filters by post_id).
	 */
	public function detail( \WP_REST_Request $request ): \WP_REST_Response {
		$post_id = (int) $request->get_param( 'post_id' );
		if ( $post_id <= 0 ) {
			return new \WP_REST_Response(
				array(
					'error' => 'Invalid post_id',
				),
				400
			);
		}

		$period = (int) $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' ) );
		}

		$result = Internal_Opportunities::instance()->compute( $end_date, $period, array() );
		$items  = isset( $result['items'] ) && is_array( $result['items'] ) ? $result['items'] : array();

		$found = null;
		foreach ( $items as $row ) {
			if ( isset( $row['post_id'] ) && (int) $row['post_id'] === $post_id ) {
				$found = $row;
				break;
			}
		}

		if ( null === $found ) {
			return new \WP_REST_Response(
				array(
					'error' => 'Post not found in Internal Linking Opportunities for this period.',
				),
				404
			);
		}

		$overview = isset( $result['overview'] ) && is_array( $result['overview'] ) ? $result['overview'] : array();

		$extras   = $this->get_inbound_links_for_post( $post_id );
		$sources  = $this->get_suggested_sources_for_post( $post_id, (string) ( $found['page_url'] ?? '' ), $overview );

		return new \WP_REST_Response(
			array(
				'item'             => $found,
				'overview'         => $overview,
				'inbound_links'    => $extras,
				'suggested_sources'=> $sources,
			),
			200
		);
	}

	/**
	 * Fetch inbound internal links for a given post from serpiq_links.
	 *
	 * @param int $post_id
	 * @return array<int,array<string,mixed>>
	 */
	private function get_inbound_links_for_post( int $post_id ): array {
		global $wpdb;

		if ( $post_id <= 0 ) {
			return array();
		}

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

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT from_post_id, from_url, to_url, anchor_text FROM %i WHERE to_post_id = %d',
				$table,
				$post_id
			),
			ARRAY_A
		);
		if ( ! is_array( $rows ) ) {
			return array();
		}

		$result = array();
		foreach ( $rows as $row ) {
			$from_id = (int) ( $row['from_post_id'] ?? 0 );
			$title   = '';
			if ( $from_id > 0 ) {
				$post = get_post( $from_id );
				if ( $post && $post instanceof \WP_Post ) {
					$title = $post->post_title;
				}
			}
			$result[] = array(
				'from_post_id' => $from_id,
				'from_title'   => $title,
				'from_url'     => (string) ( $row['from_url'] ?? '' ),
				'to_url'       => (string) ( $row['to_url'] ?? '' ),
				'anchor_text'  => (string) ( $row['anchor_text'] ?? '' ),
			);
		}

		return $result;
	}

	/**
	 * Suggest potential source pages from which to add internal links
	 * to the given target post.
	 *
	 * Basic heuristic:
	 * - Prefer pages sharing categories/taxonomies with the target.
	 * - Exclude pages that already link to the target.
	 * - Rank by simple combination of taxonomy match + organic potential.
	 *
	 * @param int   $post_id  Target post ID.
	 * @param string $target_url Target URL.
	 * @param array $overview Overview from Internal_Opportunities::compute() (for date range).
	 * @return array<int,array<string,mixed>>
	 */
	private function get_suggested_sources_for_post( int $post_id, string $target_url, array $overview ): array {
		global $wpdb;

		if ( $post_id <= 0 || '' === $target_url ) {
			return array();
		}

		$post = get_post( $post_id );
		if ( ! $post || ! ( $post instanceof \WP_Post ) || 'publish' !== $post->post_status ) {
			return array();
		}

		// Determine taxonomies (categories/tags) of target.
		$cat_ids = array();
		$categories = get_the_terms( $post_id, 'category' );
		if ( is_array( $categories ) ) {
			foreach ( $categories as $term ) {
				if ( $term instanceof \WP_Term ) {
					$cat_ids[] = (int) $term->term_id;
				}
			}
		}

		$tag_ids = array();
		$tags = get_the_terms( $post_id, 'post_tag' );
		if ( is_array( $tags ) ) {
			foreach ( $tags as $term ) {
				if ( $term instanceof \WP_Term ) {
					$tag_ids[] = (int) $term->term_id;
				}
			}
		}

		// Exclude existing sources that already link to this post.
		$table_links = $wpdb->prefix . 'serpiq_links';
		$exclude_ids = array( $post_id );
		$existing    = $wpdb->get_col(
			$wpdb->prepare(
				'SELECT DISTINCT from_post_id FROM %i WHERE to_post_id = %d',
				$table_links,
				$post_id
			)
		);
		if ( is_array( $existing ) ) {
			foreach ( $existing as $id ) {
				$id = (int) $id;
				if ( $id > 0 ) {
					$exclude_ids[] = $id;
				}
			}
		}

		// Build candidate query.
		$post_types = get_post_types(
			array(
				'public' => true,
			),
			'names'
		);
		unset( $post_types['attachment'] );

		$tax_query = array();
		if ( ! empty( $cat_ids ) || ! empty( $tag_ids ) ) {
			$tax_query['relation'] = 'OR';
			if ( ! empty( $cat_ids ) ) {
				$tax_query[] = array(
					'taxonomy' => 'category',
					'field'    => 'term_id',
					'terms'    => $cat_ids,
				);
			}
			if ( ! empty( $tag_ids ) ) {
				$tax_query[] = array(
					'taxonomy' => 'post_tag',
					'field'    => 'term_id',
					'terms'    => $tag_ids,
				);
			}
		}

		$query_args = array(
			'post_type'      => $post_types,
			'post_status'    => 'publish',
			'posts_per_page' => 50,
			'post__not_in'   => $exclude_ids,
			'no_found_rows'  => true,
		);
		if ( ! empty( $tax_query ) ) {
			$query_args['tax_query'] = $tax_query;
		}

		// Bounded suggestion query (50 posts, exclude list from plugin link table); tax_query + post__not_in are required for relevance.
		// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query,WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
		$q = new \WP_Query( $query_args );
		if ( ! $q->have_posts() ) {
			return array();
		}

		$candidates = array();
		$urls       = array();

		foreach ( $q->posts as $source_post ) {
			if ( ! $source_post instanceof \WP_Post ) {
				continue;
			}
			$source_id  = (int) $source_post->ID;
			$source_url = get_permalink( $source_id );
			if ( ! $source_url ) {
				continue;
			}
			$candidates[ $source_id ] = array(
				'post' => $source_post,
				'url'  => $source_url,
			);
			$urls[] = $source_url;
		}

		if ( empty( $candidates ) ) {
			return array();
		}

		// Fetch GSC data (clicks/impressions) for candidate source URLs.
		$table_pages = $wpdb->prefix . 'serpiq_pages';

		$current_range = isset( $overview['currentRange'] ) && is_array( $overview['currentRange'] ) ? $overview['currentRange'] : array();
		$curr_start    = isset( $current_range['start'] ) ? (string) $current_range['start'] : '';
		$curr_end      = isset( $current_range['end'] ) ? (string) $current_range['end'] : '';
		if ( '' === $curr_start || '' === $curr_end ) {
			$curr_end   = gmdate( 'Y-m-d', strtotime( '-1 day' ) );
			$curr_start = gmdate( 'Y-m-d', strtotime( '-28 day' ) );
		}

		$placeholders = implode( ',', array_fill( 0, count( $urls ), '%s' ) );
		$sql_sources  = "
			SELECT
				page_url,
				COALESCE(SUM(clicks),0) AS clicks,
				COALESCE(SUM(impressions),0) AS impressions
			FROM %i
			WHERE date BETWEEN %s AND %s
			  AND page_url IN ({$placeholders})
			GROUP BY page_url
		";
		$params = array_merge( array( $table_pages, $curr_start, $curr_end ), $urls );
		$rows   = $wpdb->get_results( $wpdb->prepare( $sql_sources, $params ), ARRAY_A );

		$gsc_map = array();
		foreach ( (array) $rows as $row ) {
			$gsc_map[ (string) ( $row['page_url'] ?? '' ) ] = array(
				'clicks'      => (int) ( $row['clicks'] ?? 0 ),
				'impressions' => (int) ( $row['impressions'] ?? 0 ),
			);
		}

		// Build scored suggestions.
		$suggestions = array();
		foreach ( $candidates as $source_id => $data ) {
			$source_post = $data['post'];
			$source_url  = $data['url'];

			$clicks      = isset( $gsc_map[ $source_url ]['clicks'] ) ? $gsc_map[ $source_url ]['clicks'] : 0;
			$impressions = isset( $gsc_map[ $source_url ]['impressions'] ) ? $gsc_map[ $source_url ]['impressions'] : 0;

			// Taxonomy match score: 1 if shares category, 0.7 if only tags, 0.3 otherwise.
			$tax_score = 0.3;

			$src_cats = get_the_terms( $source_id, 'category' );
			$src_cat_ids = array();
			if ( is_array( $src_cats ) ) {
				foreach ( $src_cats as $term ) {
					if ( $term instanceof \WP_Term ) {
						$src_cat_ids[] = (int) $term->term_id;
					}
				}
			}
			if ( ! empty( $cat_ids ) && array_intersect( $cat_ids, $src_cat_ids ) ) {
				$tax_score = 1.0;
			} else {
				$src_tags = get_the_terms( $source_id, 'post_tag' );
				$src_tag_ids = array();
				if ( is_array( $src_tags ) ) {
					foreach ( $src_tags as $term ) {
						if ( $term instanceof \WP_Term ) {
							$src_tag_ids[] = (int) $term->term_id;
						}
					}
				}
				if ( ! empty( $tag_ids ) && array_intersect( $tag_ids, $src_tag_ids ) ) {
					$tax_score = 0.7;
				}
			}

			// Traffic score from clicks.
			$traffic_raw   = log( 1 + max( 0, $clicks ) );
			$traffic_denom = log( 1 + 1000 );
			$traffic_score = ( $traffic_denom > 0 ) ? max( 0.0, min( 1.0, $traffic_raw / $traffic_denom ) ) : 0.0;

			$final_score = 0.6 * $tax_score + 0.4 * $traffic_score;

			$reason = 'Relevant article on the same site.';
			if ( $tax_score >= 1.0 && $traffic_score >= 0.5 ) {
				$reason = 'Same category and good organic traffic.';
			} elseif ( $tax_score >= 1.0 ) {
				$reason = 'Same category/topic as the target page.';
			} elseif ( $tax_score >= 0.7 ) {
				$reason = 'Shares related tags with the target page.';
			} elseif ( $traffic_score >= 0.5 ) {
				$reason = 'High-traffic article that can pass internal authority.';
			}

			$suggestions[] = array(
				'source_post_id' => $source_id,
				'source_title'   => $source_post->post_title,
				'source_url'     => $source_url,
				'clicks'         => $clicks,
				'impressions'    => $impressions,
				'score'          => $final_score,
				'reason'         => $reason,
			);
		}

		if ( empty( $suggestions ) ) {
			return array();
		}

		usort(
			$suggestions,
			static function ( $a, $b ) {
				$sa = (float) ( $a['score'] ?? 0 );
				$sb = (float) ( $b['score'] ?? 0 );
				return $sb <=> $sa;
			}
		);

		return array_slice( $suggestions, 0, 10 );
	}

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

