<?php

namespace AIOSEO\Plugin\Pro\SeoAnalysis;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use AIOSEO\Plugin\Common\SeoAnalysis as CommonSeoAnalysis;
use AIOSEO\Plugin\Pro\Models;
use AIOSEO\Plugin\Common\Models as CommonModels;
/**
 * Handles the SEO validation.
 *
 * @since 4.8.6
 */
class SeoAnalysis extends CommonSeoAnalysis\SeoAnalysis {
	/**
	 * The available groups.
	 *
	 * @since 4.8.9
	 *
	 * @var array
	 */
	private $groupsAvailable = [ 'basic', 'advanced', 'performance' ];

	/**
	 * The Basic codes by severity.
	 *
	 * @since 4.8.6
	 *
	 * @var array
	 */
	public $basicCodesStatus = [
		'passed'  => [
			[
				'code'         => 'title-ok',
				'capabilities' => []
			],
			[
				'code'         => 'description-ok',
				'capabilities' => []
			],
			[
				'code'         => 'h1-ok',
				'capabilities' => []
			],
			[
				'code'         => 'subheading-ok',
				'capabilities' => []
			],
			[
				'code'         => 'image-ok',
				'capabilities' => [ 'upload_files' ]
			],
			[
				'code'         => 'links-ratio-ok',
				'capabilities' => []
			],
			[
				'code'         => 'thumbnail-ok',
				'capabilities' => [ 'upload_files' ]
			],
			[
				'code'         => 'content-length-ok',
				'capabilities' => []
			],
			[
				'code'         => 'keyword-cannibalization-ok',
				'capabilities' => [ 'aioseo_page_general_settings' ]
			],
			[
				'code'         => 'first-paragraph-ok',
				'capabilities' => []
			],
			[
				'code'         => 'title-focus-keyword-ok',
				'capabilities' => []
			],
			[
				'code'         => 'description-focus-keyword-ok',
				'capabilities' => []
			],
			[
				'code'         => 'url-focus-keyword-ok',
				'capabilities' => []
			],
			[
				'code'         => 'url-length-ok',
				'capabilities' => []
			],
			[
				'code'         => 'product-schema-ok',
				'capabilities' => [ 'aioseo_page_schema_settings' ]
			]
		],
		'warning' => [
			[
				'code'         => 'title-too-short',
				'capabilities' => []
			],
			[
				'code'         => 'description-too-short',
				'capabilities' => []
			],
			[
				'code'         => 'subheading-missing',
				'capabilities' => []
			],
			[
				'code'         => 'subheading-missing-focus-keyword',
				'capabilities' => [ 'aioseo_page_analysis' ]
			],
			[
				'code'         => 'thumbnail-missing',
				'capabilities' => [ 'upload_files' ]
			],
			[
				'code'         => 'content-length-too-short',
				'capabilities' => []
			],
			[
				'code'         => 'url-length-too-long',
				'capabilities' => []
			]
		],
		'error'   => [
			[
				'code'         => 'title-missing',
				'capabilities' => []
			],
			[
				'code'         => 'title-too-long',
				'capabilities' => []
			],
			[
				'code'         => 'description-missing',
				'capabilities' => []
			],
			[
				'code'         => 'description-too-long',
				'capabilities' => []
			],
			[
				'code'         => 'h1-missing',
				'capabilities' => []
			],
			[
				'code'         => 'h1-too-many',
				'capabilities' => []
			],
			[
				'code'         => 'h1-missing-focus-keyword',
				'capabilities' => [ 'aioseo_page_analysis' ]
			],
			[
				'code'         => 'image-missing',
				'capabilities' => [ 'upload_files' ]
			],
			[
				'code'         => 'image-missing-alt',
				'capabilities' => [ 'upload_files' ]
			],
			[
				'code'         => 'internal-links-missing',
				'capabilities' => []
			],
			[
				'code'         => 'internal-links-too-few',
				'capabilities' => []
			],
			[
				'code'         => 'keyword-cannibalization',
				'capabilities' => [ 'aioseo_page_general_settings' ]
			],
			[
				'code'         => 'first-paragraph-missing-focus-keyword',
				'capabilities' => [ 'aioseo_page_analysis' ]
			],
			[
				'code'         => 'title-missing-focus-keyword',
				'capabilities' => [ 'aioseo_page_analysis' ]
			],
			[
				'code'         => 'description-missing-focus-keyword',
				'capabilities' => [ 'aioseo_page_analysis' ]
			],
			[
				'code'         => 'url-missing-focus-keyword',
				'capabilities' => [ 'aioseo_page_analysis' ]
			],
			[
				'code'         => 'product-schema-missing',
				'capabilities' => [ 'aioseo_page_schema_settings' ]
			]
		]
	];

	/**
	 * The Advanced codes by severity.
	 *
	 * @since 4.8.6
	 *
	 * @var array
	 */
	public $advancedCodesStatus = [
		'passed'  => [
			[
				'code'         => 'noindex-ok',
				'capabilities' => [ 'aioseo_page_advanced_settings' ]
			],
			[
				'code'         => 'ogp-ok',
				'capabilities' => [ 'aioseo_page_social_settings' ]
			],
			[
				'code'         => 'schema-ok',
				'capabilities' => [ 'aioseo_page_schema_settings' ]
			],
			[
				'code'         => 'canonical-ok',
				'capabilities' => [ 'aioseo_page_advanced_settings' ]
			],
			[
				'code'         => 'author-bio-ok',
				'capabilities' => [ 'edit_users' ]
			],
			[
				'code'         => 'main-keyword-ok',
				'capabilities' => [ 'aioseo_page_general_settings' ]
			],
			[
				'code'         => 'stale-content-ok',
				'capabilities' => []
			]
		],
		'warning' => [
			[
				'code'         => 'noindex',
				'capabilities' => [ 'aioseo_page_advanced_settings' ]
			],
			[
				'code'         => 'canonical-missing',
				'capabilities' => [ 'aioseo_page_advanced_settings' ]
			],
			[
				'code'         => 'author-bio-missing',
				'capabilities' => [ 'edit_users' ]
			],
			[
				'code'         => 'main-keyword-missing',
				'capabilities' => [ 'aioseo_page_analysis' ]
			],
			[
				'code'         => 'stale-content-too-old',
				'capabilities' => []
			]
		],
		'error'   => [
			[
				'code'         => 'ogp-missing',
				'capabilities' => [ 'aioseo_page_social_settings' ]
			],
			[
				'code'         => 'ogp-duplicates',
				'capabilities' => [ 'aioseo_page_social_settings' ]
			],
			[
				'code'         => 'schema-missing',
				'capabilities' => [ 'aioseo_page_schema_settings' ]
			]
		]
	];

	/**
	 * The Performance codes by severity.
	 *
	 * @since 4.8.6
	 *
	 * @var array
	 */
	public $performanceCodesStatus = [
		'passed'  => [
			[
				'code'         => 'requests',
				'capabilities' => []
			]
		],
		'warning' => [],
		'error'   => []
	];

	/**
	 * Constructor.
	 *
	 * @since 4.8.6
	 */
	public function __construct() {
		// Initialize the post and term scans.
		new ActionScheduler\Post();
		new ActionScheduler\Term();
	}

	/**
	 * Returns the data for Vue.
	 *
	 * @since 4.8.6
	 *
	 * @return array The data for Vue.
	 */
	public function getVueData() {
		return parent::getVueData();
	}

	/**
	 * Analyze the post.
	 *
	 * @since 4.8.6
	 *
	 * @param  int   $objectId The object ID.
	 * @return array
	 */
	public function analyzePost( $objectId ) {
		$post       = get_post( $objectId );
		$content    = $post && 'publish' !== $post->post_status ? apply_filters( 'the_content', $post->post_content ) : '';
		$isScraping = $post && 'publish' === $post->post_status;
		$pageParser = new PageParser( get_permalink( $objectId ), $content, $isScraping );
		if ( ! $pageParser->hasDocument() ) {
			return [
				'results' => [
					'basic'    => [],
					'advanced' => []
				],
			];
		}

		$results = [
			'basic'    => ( new Checkers\Post\Basic( $objectId, $pageParser ) )->get(),
			'advanced' => ( new Checkers\Post\Advanced( $objectId, $pageParser ) )->get()
		];

		return [
			'results' => $results,
		];
	}

	/**
	 * Analyze the term.
	 *
	 * @since 4.8.6
	 *
	 * @param  int   $objectId The object ID.
	 * @return array
	 */
	public function analyzeTerm( $objectId ) {
		$term = get_term( $objectId );
		$results = [
			'results' => [
				'basic'    => [],
				'advanced' => []
			],
		];

		if ( is_wp_error( $term ) || ! $term || empty( $term->taxonomy ) ) {
			return $results;
		}

		$publicTaxonomies = array_diff( aioseo()->helpers->getPublicTaxonomies( true ), [ 'product_attributes' ] );
		if ( ! in_array( $term->taxonomy, $publicTaxonomies, true ) ) {
			return $results;
		}

		$termLink = get_term_link( $term, $term->taxonomy );
		if ( is_wp_error( $termLink ) || ! $termLink ) {
			return $results;
		}

		$pageParser = new PageParser( $termLink );
		if ( ! $pageParser->hasDocument() ) {
			return $results;
		}

		$results = [
			'basic'    => ( new Checkers\Term\Basic( $objectId, $pageParser, $term->taxonomy ) )->get(),
			'advanced' => ( new Checkers\Term\Advanced( $objectId, $pageParser, $term->taxonomy ) )->get()
		];

		return [
			'results' => $results,
		];
	}

	/**
	 * Get all codes.
	 *
	 * @since 4.8.6
	 *
	 * @return array The list of codes.
	 */
	public function getAllCodes() {
		$codes = [];

		foreach ( $this->groupsAvailable as $type ) {
			$statusArray = $this->{$type . 'CodesStatus'};
			foreach ( $this->getStatusAvailable() as $severity ) {
				if ( ! empty( $statusArray[ $severity ] ) ) {
					foreach ( $statusArray[ $severity ] as $item ) {
						$codes[] = $item['code'];
					}
				}
			}
		}

		return $codes;
	}

	/**
	 * Get the codes by status.
	 *
	 * @since 4.8.6
	 *
	 * @param  string $status The status code (passed, warning, error).
	 * @return array          The list of codes.
	 */
	public function getCodesByStatus( $status ) {
		if ( ! in_array( $status, $this->getStatusAvailable(), true ) ) {
			return [];
		}

		$codes = [];
		foreach ( $this->groupsAvailable as $type ) {
			$statusArray = $this->{$type . 'CodesStatus'};
			if ( ! empty( $statusArray[ $status ] ) ) {
				foreach ( $statusArray[ $status ] as $item ) {
					$codes[] = $item['code'];
				}
			}
		}

		return $codes;
	}

	/**
	 * Get the group by code and status.
	 *
	 * @since 4.8.6
	 *
	 * @param  string      $code   The code.
	 * @param  string      $status The status code (passed, warning, error).
	 * @return string|null         The group name
	 */
	public function getGroupByCodeAndStatus( $code, $status ) {
		if ( ! in_array( $status, $this->getStatusAvailable(), true ) ) {
			return null;
		}

		foreach ( $this->groupsAvailable as $type ) {
			$statusArray = $this->{$type . 'CodesStatus'};
			if ( ! empty( $statusArray[ $status ] ) ) {
				foreach ( $statusArray[ $status ] as $item ) {
					if ( $item['code'] === $code ) {
						return $type;
					}
				}
			}
		}

		return null;
	}

	/**
	 * Get the available status.
	 *
	 * @since 4.8.6
	 *
	 * @return array
	 */
	private function getStatusAvailable() {
		return [ 'passed', 'warning', 'error' ];
	}

	/**
	 * Clear the homepage results.
	 *
	 * @since 4.8.6
	 *
	 * @param  int    $objectId   The object ID.
	 * @param  string $objectType The object type.
	 */
	public function clearHomepageResults( $objectId, $objectType ) {
		if ( ! aioseo()->helpers->isStaticHomePage( $objectId ) ) {
			return;
		}

		Models\Issue::deleteAll( $objectId, $objectType );

		CommonModels\SeoAnalyzerResult::deleteByUrl( null );

		aioseo()->core->cache->delete( 'analyze_site_code' );
		aioseo()->core->cache->delete( 'analyze_site_body' );
	}

	/**
	 * Get all codes sorted by group and status.
	 *
	 * @since 4.8.6
	 *
	 * @param  string|null $status The status code (passed, warning, error).
	 * @return array
	 */
	public function getAllCodesSortedByGroupAndStatus( $status = null ) {
		if ( ! empty( $status ) && ! in_array( $status, $this->getStatusAvailable(), true ) ) {
			return [];
		}

		$result = [];
		foreach ( $this->groupsAvailable as $type ) {
			foreach ( $this->{$type . 'CodesStatus'} as $st => $group ) {
				if ( ! empty( $status ) && $st !== $status ) {
					continue;
				}

				foreach ( $group as $key => $item ) {
					$result[ $type ][ $st ][ $item['code'] ] = $key;
				}
			}
		}

		return $result;
	}

	/**
	 * Get the objects scan percent.
	 *
	 * @since 4.8.6
	 *
	 * @return int The percent completed as an integer.
	 */
	public function getObjectsScanPercent() {
		$posts = $this->getPostsScanPercent();
		$terms = $this->getTermsScanPercent();

		// We need to divide by 2 because we are averaging the two percentages.
		return round( ( $posts + $terms ) / 2 );
	}

	/**
	 * Get the posts scan percent.
	 *
	 * @since 4.8.6
	 *
	 * @return int The percent completed as an integer.
	 */
	private function getPostsScanPercent() {
		$publicPostTypes    = aioseo()->helpers->getScannablePostTypes();
		$publicPostStatuses = aioseo()->helpers->getPublicPostStatuses( true );

		$aioseoPostsTableName = aioseo()->core->db->prefix . 'aioseo_posts';
		$postsTableName       = aioseo()->core->db->prefix . 'posts';

		$implodedPostTypes    = aioseo()->helpers->implodeWhereIn( $publicPostTypes, true );
		$implodedPostStatuses = aioseo()->helpers->implodeWhereIn( $publicPostStatuses, true );

		$totals = aioseo()->core->db->execute(
			aioseo()->core->db->db->prepare(
				"SELECT (
					SELECT count(*)
					FROM {$postsTableName}
					WHERE post_type IN ( $implodedPostTypes )
						AND post_status IN ( $implodedPostStatuses )
				) as totalPosts,
				(
					SELECT count(*)
					FROM {$postsTableName} as p
					LEFT JOIN {$aioseoPostsTableName} as ap ON ap.post_id = p.ID
					WHERE p.post_type IN ( $implodedPostTypes )
						AND p.post_status IN ( $implodedPostStatuses )
						AND ( ap.post_id IS NULL
							OR ap.seo_analyzer_scan_date IS NOT NULL
						)
				) as scannedPosts
				FROM {$postsTableName}
				LIMIT 1"
			),
			true
		)->result();

		if ( ! is_object( $totals[0] ) || 1 > $totals[0]->totalPosts ) {
			return 100;
		}

		return round( 100 * ( $totals[0]->scannedPosts / $totals[0]->totalPosts ) );
	}

	/**
	 * Get the terms scan percent.
	 *
	 * @since 4.8.6
	 *
	 * @return int The percent completed as an integer.
	 */
	private function getTermsScanPercent() {
		$publicTaxonomies = array_diff( aioseo()->helpers->getPublicTaxonomies( true ), [ 'product_attributes' ] );

		$aioseoTermsTableName   = aioseo()->core->db->prefix . 'aioseo_terms';
		$termsTableName         = aioseo()->core->db->prefix . 'terms';
		$termsTaxonomyTableName = aioseo()->core->db->prefix . 'term_taxonomy';

		$implodedPublicTaxonomies = aioseo()->helpers->implodeWhereIn( $publicTaxonomies, true );

		$totals = aioseo()->core->db->execute(
			aioseo()->core->db->db->prepare(
				"SELECT (
					SELECT count(*)
					FROM {$termsTableName} as t
					INNER JOIN {$termsTaxonomyTableName} as tt ON tt.term_id = t.term_id
					WHERE tt.taxonomy IN ( $implodedPublicTaxonomies )
				) as totalTerms,
				(
					SELECT count(*)
					FROM {$termsTableName} as t
					INNER JOIN {$termsTaxonomyTableName} as tt ON tt.term_id = t.term_id
					LEFT JOIN {$aioseoTermsTableName} as at ON at.term_id = t.term_id
					WHERE tt.taxonomy IN ( $implodedPublicTaxonomies )
						AND ( at.term_id IS NULL
							OR at.seo_analyzer_scan_date IS NOT NULL
						)
				) as scannedTerms
				FROM {$termsTableName}
				LIMIT 1"
			),
			true
		)->result();

		if ( ! is_object( $totals[0] ) || 1 > $totals[0]->totalTerms ) {
			return 100;
		}

		return round( 100 * ( $totals[0]->scannedTerms / $totals[0]->totalTerms ) );
	}

	/**
	 * Enqueues a post page to be scanned by the SEO Analyzer.
	 *
	 * @since 4.8.7
	 *
	 * @param  int  $postId The post id.
	 * @return void
	 */
	public function enqueuePostToScan( $postId ) {
		$postType           = get_post_type( (int) $postId );
		$postTypesToExclude = apply_filters( 'aioseo_seo_analyzer_scan_post_types_to_exclude', [ 'scheduled-action', 'revision', 'attachment' ] );
		if (
			in_array( $postType, $postTypesToExclude, true ) ||
			! aioseo()->helpers->isPostTypePublic( $postType )
		) {
			return;
		}

		$aioseoPost = CommonModels\Post::getPost( $postId );
		$aioseoPost->seo_analyzer_scan_date = null;
		$aioseoPost->save();

		aioseo()->core->cache->delete( 'as_seo_analysis_post_idle' );

		// Delete issues
		Models\Issue::deleteAll( $postId, 'post' );
	}

	/**
	 * Enqueues a term to be scanned by the SEO Analyzer.
	 *
	 * @since 4.9.7
	 *
	 * @param  int  $termId The term id.
	 * @return void
	 */
	public function enqueueTermToScan( $termId ) {
		$term = aioseo()->helpers->getTerm( $termId );
		if ( ! $term ) {
			return;
		}

		$taxonomiesToExclude = apply_filters( 'aioseo_seo_analyzer_scan_taxonomies_to_exclude', [ 'product_attributes' ] );
		if (
			in_array( $term->taxonomy, $taxonomiesToExclude, true ) ||
			! in_array( $term->taxonomy, aioseo()->helpers->getPublicTaxonomies( true ), true )
		) {
			return;
		}

		$aioseoTerm = Models\Term::getTerm( $termId );
		$aioseoTerm->seo_analyzer_scan_date = null;
		$aioseoTerm->save();

		aioseo()->core->cache->delete( 'as_seo_analysis_term_idle' );

		// Delete issues
		Models\Issue::deleteAll( $termId, 'term' );
	}

	/**
	 * Checks if the current user can fix a specific issue code.
	 *
	 * @since 4.8.9
	 *
	 * @param  string $issueCode The issue code to check.
	 * @return bool              Whether the user can fix this issue.
	 */
	public function userCanFixIssue( $issueCode ) {
		$capabilities = $this->getIssueCapabilities( $issueCode );

		if ( empty( $capabilities ) ) {
			return false;
		}

		// Check each required capability
		foreach ( $capabilities as $capability ) {
			if ( false !== strpos( $capability, 'aioseo_' ) ) {
				if ( ! aioseo()->access->hasCapability( $capability ) ) {
					return false;
				}
			} else {
				if ( ! current_user_can( $capability ) ) {
					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Reset SEO Analysis scan dates for posts, forcing a re-scan.
	 *
	 * @since 4.9.7
	 *
	 * @return void
	 */
	public function resetPostScanDates() {
		aioseo()->core->db->update( 'aioseo_posts' )
			->set( [ 'seo_analyzer_scan_date' => null ] )
			->run();
		aioseo()->core->cache->delete( 'as_seo_analysis_post_idle' );
		aioseo()->core->cache->delete( 'as_seo_analysis_post_running' );
	}

	/**
	 * Reset SEO Analysis scan dates for terms, forcing a re-scan.
	 *
	 * @since 4.9.7
	 *
	 * @return void
	 */
	public function resetTermScanDates() {
		aioseo()->core->db->update( 'aioseo_terms' )
			->set( [ 'seo_analyzer_scan_date' => null ] )
			->run();
		aioseo()->core->cache->delete( 'as_seo_analysis_term_idle' );
		aioseo()->core->cache->delete( 'as_seo_analysis_term_running' );
	}

	/**
	 * Reset SEO Analysis scan dates only for posts of a specific post type.
	 *
	 * @since 4.9.7
	 *
	 * @param  string $postType The post type slug.
	 * @return void
	 */
	public function resetPostScanDatesForPostType( $postType ) {
		aioseo()->core->db->update( 'aioseo_posts as ap' )
			->join( 'posts as p', 'ap.post_id = p.ID' )
			->where( 'p.post_type', $postType )
			->set( [ 'seo_analyzer_scan_date' => null ] )
			->run();

		aioseo()->core->cache->delete( 'as_seo_analysis_post_idle' );
		aioseo()->core->cache->delete( 'as_seo_analysis_post_running' );
	}

	/**
	 * Reset SEO Analysis scan dates only for terms belonging to a specific taxonomy.
	 *
	 * @since 4.9.7
	 *
	 * @param  string $taxonomy The taxonomy slug.
	 * @return void
	 */
	public function resetTermScanDatesForTaxonomy( $taxonomy ) {
		aioseo()->core->db->update( 'aioseo_terms as at' )
			->join( 'term_taxonomy as tt', 'at.term_id = tt.term_id' )
			->where( 'tt.taxonomy', $taxonomy )
			->set( [ 'seo_analyzer_scan_date' => null ] )
			->run();

		aioseo()->core->cache->delete( 'as_seo_analysis_term_idle' );
		aioseo()->core->cache->delete( 'as_seo_analysis_term_running' );
	}

	/**
	 * Reset all SEO Analysis scan dates, forcing a re-scan.
	 *
	 * @since 4.9.7
	 *
	 * @return void
	 */
	public function resetScanDates() {
		$this->resetPostScanDates();
		$this->resetTermScanDates();
	}

	/**
	 * Gets capabilities for a specific issue code.
	 *
	 * @since 4.8.9
	 *
	 * @param  string $issueCode The issue code.
	 * @return array             Array of capabilities or empty array.
	 */
	private function getIssueCapabilities( $issueCode ) {
		foreach ( $this->groupsAvailable as $type ) {
			$statusArray = $this->{$type . 'CodesStatus'};

			foreach ( $this->getStatusAvailable() as $severity ) {
				if ( ! empty( $statusArray[ $severity ] ) ) {
					foreach ( $statusArray[ $severity ] as $issue ) {
						if ( $issue['code'] === $issueCode ) {
							return $issue['capabilities'];
						}
					}
				}
			}
		}

		return [];
	}

	/**
	 * Gets the required capabilities for a specific issue code.
	 *
	 * @since 4.8.9
	 *
	 * @param  string $code The issue code to check capabilities for.
	 * @return array        Array of required capabilities or empty array if code not found.
	 */
	private function getCapabilitiesByCode( $code ) {
		// Search through all code status arrays
		foreach ( $this->groupsAvailable as $type ) {
			$statusArray = $this->{$type . 'CodesStatus'};

			// Search through all severity levels
			foreach ( $this->getStatusAvailable() as $severity ) {
				if ( ! empty( $statusArray[ $severity ] ) ) {
					foreach ( $statusArray[ $severity ] as $item ) {
						if ( isset( $item['code'] ) && $item['code'] === $code ) {
							return $item['capabilities'];
						}
					}
				}
			}
		}

		return [];
	}

	/**
	 * Checks if the current user has the required capabilities for an issue.
	 *
	 * @since 4.8.9
	 *
	 * @param  string $code The issue code to check capabilities for.
	 * @return bool         Whether the user has the required capabilities.
	 */
	public function userCanFixIssueByCode( $code ) {
		$capabilities = $this->getCapabilitiesByCode( $code );

		if ( empty( $capabilities ) ) {
			return true;
		}

		// Check each required capability
		foreach ( $capabilities as $capability ) {
			// For AIOSEO capabilities, use the access system
			if ( false !== strpos( $capability, 'aioseo_' ) ) {
				if ( ! aioseo()->access->hasCapability( $capability ) ) {
					return false;
				}
			} else {
				// For WordPress capabilities, use current_user_can
				if ( ! current_user_can( $capability ) ) {
					return false;
				}
			}
		}

		return true;
	}
}