<?php
/**
 * Orphaned term relationships cleanup task.
 *
 * @package DatabaseCleanup
 */

namespace DatabaseCleanup\Includes\Tasks;

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

class Task_Orphaned_Term_Relationships extends Abstract_Cleanup_Task {

	public function get_id(): string {
		return 'orphaned_term_relationships';
	}

	public function get_label(): string {
		return __( 'Orphaned term relationships', 'wppowersuite-lite' );
	}

	public function get_description(): string {
		return __( 'Delete term relationships pointing to missing posts or missing terms (post-based taxonomies only).', 'wppowersuite-lite' );
	}

	public function is_safe(): bool {
		return true;
	}

	/**
	 * Taxonomies whose object types live in wp_posts (excludes link/user taxonomies).
	 *
	 * @return list<string>
	 */
	private function post_based_taxonomies(): array {
		$post_types = array_keys( get_post_types() );
		$names      = [];

		foreach ( get_taxonomies( [], 'objects' ) as $taxonomy ) {
			$object_types = array_values( array_filter( (array) ( $taxonomy->object_type ?? [] ) ) );
			if ( $object_types === [] ) {
				continue;
			}

			$non_post = array_diff( $object_types, $post_types );
			if ( $non_post !== [] ) {
				continue;
			}

			$names[] = (string) $taxonomy->name;
		}

		return array_values( array_unique( $names ) );
	}

	/**
	 * @return array{0:string,1:list<string>}
	 */
	private function taxonomy_sql_clause(): array {
		global $wpdb;

		$taxonomies = $this->post_based_taxonomies();
		if ( $taxonomies === [] ) {
			return [ '1=0', [] ];
		}

		$placeholders = implode( ',', array_fill( 0, count( $taxonomies ), '%s' ) );

		return [
			"tt.taxonomy IN ($placeholders)",
			$taxonomies,
		];
	}

	public function scan( array $settings ): array {
		global $wpdb;

		unset( $settings );

		[ $tax_sql, $tax_params ] = $this->taxonomy_sql_clause();

		if ( $tax_params === [] ) {
			return [
				'count'           => 0,
				'estimated_bytes' => 0,
				'details'         => [
					'missing_posts' => 0,
					'missing_terms' => 0,
				],
			];
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$missing_posts = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
				INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
				LEFT JOIN {$wpdb->posts} p ON p.ID = tr.object_id
				WHERE p.ID IS NULL AND tr.object_id > 0 AND {$tax_sql}",
				...$tax_params
			)
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$missing_terms = (int) $wpdb->get_var(
			"SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
			INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
			LEFT JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
			WHERE tt.term_taxonomy_id IS NULL AND tr.object_id > 0"
		);

		$count = $missing_posts + $missing_terms;

		return [
			'count'           => $count,
			'estimated_bytes' => $this->estimate_row_bytes( $count, 64 ),
			'details'         => [
				'missing_posts' => $missing_posts,
				'missing_terms' => $missing_terms,
			],
		];
	}

	public function clean( array $settings, int $batch_size, mixed $cursor = null ): array {
		global $wpdb;

		unset( $settings );

		$phase   = is_array( $cursor ) ? (string) ( $cursor['phase'] ?? 'posts' ) : 'posts';
		$deleted = 0;
		$bytes   = 0;
		$touched_tt = [];

		[ $tax_sql, $tax_params ] = $this->taxonomy_sql_clause();

		if ( $phase === 'posts' ) {
			if ( $tax_params === [] ) {
				return [
					'deleted' => 0,
					'done'    => false,
					'cursor'  => [ 'phase' => 'terms' ],
					'bytes'   => 0,
				];
			}

			$rows = $wpdb->get_results(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				$wpdb->prepare(
					"SELECT tr.object_id, tr.term_taxonomy_id FROM {$wpdb->term_relationships} tr
					INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
					LEFT JOIN {$wpdb->posts} p ON p.ID = tr.object_id
					WHERE p.ID IS NULL AND tr.object_id > 0 AND {$tax_sql}
					ORDER BY tr.object_id ASC, tr.term_taxonomy_id ASC
					LIMIT %d",
					...array_merge( $tax_params, [ $batch_size ] )
				),
				ARRAY_A
			);

			foreach ( (array) $rows as $row ) {
				$object_id        = (int) ( $row['object_id'] ?? 0 );
				$term_taxonomy_id = (int) ( $row['term_taxonomy_id'] ?? 0 );

				if ( $object_id < 1 || $term_taxonomy_id < 1 ) {
					continue;
				}

				$result = $wpdb->delete(
					$wpdb->term_relationships,
					[
						'object_id'        => $object_id,
						'term_taxonomy_id' => $term_taxonomy_id,
					],
					[ '%d', '%d' ]
				);

				if ( $result ) {
					++$deleted;
					$bytes += 64;
					$touched_tt[ $term_taxonomy_id ] = true;
				}
			}

			$this->recount_terms( array_keys( $touched_tt ) );

			if ( count( (array) $rows ) < $batch_size ) {
				return [
					'deleted' => $deleted,
					'done'    => false,
					'cursor'  => [ 'phase' => 'terms' ],
					'bytes'   => $bytes,
				];
			}

			// No OFFSET — deleting shrinks the result set for the next batch.
			return [
				'deleted' => $deleted,
				'done'    => false,
				'cursor'  => [ 'phase' => 'posts' ],
				'bytes'   => $bytes,
			];
		}

		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT tr.object_id, tr.term_taxonomy_id FROM {$wpdb->term_relationships} tr
				INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id
				LEFT JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
				WHERE tt.term_taxonomy_id IS NULL AND tr.object_id > 0
				ORDER BY tr.object_id ASC, tr.term_taxonomy_id ASC
				LIMIT %d",
				$batch_size
			),
			ARRAY_A
		);

		foreach ( (array) $rows as $row ) {
			$object_id        = (int) ( $row['object_id'] ?? 0 );
			$term_taxonomy_id = (int) ( $row['term_taxonomy_id'] ?? 0 );

			if ( $object_id < 1 || $term_taxonomy_id < 1 ) {
				continue;
			}

			$result = $wpdb->delete(
				$wpdb->term_relationships,
				[
					'object_id'        => $object_id,
					'term_taxonomy_id' => $term_taxonomy_id,
				],
				[ '%d', '%d' ]
			);

			if ( $result ) {
				++$deleted;
				$bytes += 64;
			}
		}

		$done = count( (array) $rows ) < $batch_size;

		return [
			'deleted' => $deleted,
			'done'    => $done,
			'cursor'  => $done ? null : [ 'phase' => 'terms' ],
			'bytes'   => $bytes,
		];
	}

	/**
	 * @param list<int> $term_taxonomy_ids
	 */
	private function recount_terms( array $term_taxonomy_ids ): void {
		global $wpdb;

		foreach ( $term_taxonomy_ids as $tt_id ) {
			$tt_id = (int) $tt_id;
			if ( $tt_id <= 0 ) {
				continue;
			}

			$count = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$wpdb->term_relationships} WHERE term_taxonomy_id = %d",
					$tt_id
				)
			);

			$wpdb->update(
				$wpdb->term_taxonomy,
				[ 'count' => $count ],
				[ 'term_taxonomy_id' => $tt_id ],
				[ '%d' ],
				[ '%d' ]
			);
		}
	}
}
