<?php
/**
 * KooKoa Folders
 *
 * @package KookoaWPF
 * @license GPL-2.0-or-later
 */

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

/**
 * Generic importer for any competing plugin that stores its folders as a
 * standard WordPress taxonomy.
 *
 * One instance handles one source-target taxonomy pair. The same class
 * therefore covers FileBird Posts, FileBird Pages, HappyFiles, Enhanced
 * Media Library, Wicked Folders, Folders (Premio), Mediamatic and any
 * future taxonomy-based competitor — only the constructor arguments
 * change.
 *
 * @since 1.0.5
 */
class KookoaWPF_Importer_Taxonomy extends KookoaWPF_Importer_Abstract {

	/**
	 * Stable identifier.
	 *
	 * @var string
	 */
	private $id;

	/**
	 * Human-readable source name shown in the UI.
	 *
	 * @var string
	 */
	private $name;

	/**
	 * Plugin author shown under the source name.
	 *
	 * @var string
	 */
	private $author;

	/**
	 * Source taxonomy slug (e.g. fbv_pt_tax_post, happyfiles_category).
	 *
	 * @var string
	 */
	private $source_taxonomy;

	/**
	 * Target post type whose taxonomy will receive the imported folders.
	 *
	 * @var string
	 */
	private $target_post_type;

	/**
	 * Optional override of the default PRO gate. When null the parent's
	 * post-type-based logic (only "attachment" is free) applies.
	 *
	 * @var bool|null
	 */
	private $requires_pro_override;

	/**
	 * Configure one taxonomy-based importer.
	 *
	 * @param array<string, mixed> $args Importer configuration. Required keys: id, name, author, source_taxonomy, target_post_type. Optional: requires_pro (bool).
	 */
	public function __construct( array $args ) {
		$this->id                    = (string) $args['id'];
		$this->name                  = (string) $args['name'];
		$this->author                = (string) $args['author'];
		$this->source_taxonomy       = (string) $args['source_taxonomy'];
		$this->target_post_type      = (string) $args['target_post_type'];
		$this->requires_pro_override = isset( $args['requires_pro'] ) ? (bool) $args['requires_pro'] : null;
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_id(): string {
		return $this->id;
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_name(): string {
		return $this->name;
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_author(): string {
		return $this->author;
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_target_post_type(): string {
		return $this->target_post_type;
	}

	/**
	 * {@inheritDoc}
	 */
	public function requires_pro(): bool {
		if ( null !== $this->requires_pro_override ) {
			return $this->requires_pro_override;
		}
		return parent::requires_pro();
	}

	/**
	 * Whether the source taxonomy has been registered in the current
	 * request. If not, the row count is checked directly against the
	 * term_taxonomy table — some source plugins register their taxonomy
	 * conditionally (e.g. FileBird requires opt-in for Posts/Pages).
	 *
	 * @return bool
	 */
	private function source_has_rows(): bool {
		return $this->count_folders() > 0;
	}

	/**
	 * Ensure the source plugin's taxonomy is queryable via WP API.
	 *
	 * Some competing plugins (e.g. Folders by Premio) register their
	 * taxonomy conditionally — per-post-type opt-in inside their own
	 * settings. When the user disables a post type there, the taxonomy
	 * is not registered in the current request, even though the
	 * historical term data is still in wp_term_taxonomy. We register
	 * the slug ourselves with a minimal, side-effect-free args set so
	 * get_terms / wp_count_terms / get_objects_in_term work normally.
	 *
	 * Idempotent: no-op if the source plugin already registered the
	 * taxonomy in this request.
	 */
	private function ensure_source_taxonomy_registered(): void {
		if ( taxonomy_exists( $this->source_taxonomy ) ) {
			return;
		}
		register_taxonomy(
			$this->source_taxonomy,
			array(),
			array(
				'public'       => false,
				'hierarchical' => true,
				'show_ui'      => false,
				'show_in_rest' => false,
				'rewrite'      => false,
				'query_var'    => false,
			)
		);
	}

	/**
	 * {@inheritDoc}
	 *
	 * Uses WP API (wp_count_terms). If the source plugin has not
	 * registered its taxonomy yet (or has been partially disabled per
	 * post type), we register it ourselves with minimal args so the
	 * orphan term data remains discoverable through WP API.
	 */
	public function count_folders(): int {
		$this->ensure_source_taxonomy_registered();
		$count = wp_count_terms(
			array(
				'taxonomy'   => $this->source_taxonomy,
				'hide_empty' => false,
			)
		);
		return is_wp_error( $count ) ? 0 : (int) $count;
	}

	/**
	 * Object cache group for queries in this class. Keyed on the source
	 * taxonomy slug so two registered importers (e.g. FileBird Posts +
	 * HappyFiles) never collide on a shared key.
	 *
	 * @return string
	 */
	private function cache_group(): string {
		return 'kookoawpf_importer_taxonomy_' . md5( $this->source_taxonomy );
	}

	/**
	 * {@inheritDoc}
	 *
	 * Single batch query across term_taxonomy / term_relationships /
	 * posts — replaces the earlier N+1 (one get_objects_in_term per
	 * term) which exploded on competitors with thousands of folders.
	 * Joining wp_posts also filters out stale relations pointing to
	 * the wrong post type, so the count matches what the import will
	 * actually assign.
	 */
	public function count_items(): int {
		$this->ensure_source_taxonomy_registered();

		$cached = wp_cache_get( 'count_items', $this->cache_group() );
		if ( false !== $cached ) {
			return (int) $cached;
		}

		global $wpdb;
		// Batch count of distinct objects of the target post type that
		// are attached to any term of the source taxonomy. WP table
		// names are constants from $wpdb so they cannot be tainted.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$count = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT tr.object_id)
				FROM {$wpdb->term_taxonomy} tt
				INNER JOIN {$wpdb->term_relationships} tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
				INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id AND p.post_type = %s
				WHERE tt.taxonomy = %s",
				$this->get_target_post_type(),
				$this->source_taxonomy
			)
		);

		wp_cache_set( 'count_items', $count, $this->cache_group(), MINUTE_IN_SECONDS );
		return $count;
	}

	/**
	 * Fetch every source term as a normalized [id, parent, name] tuple
	 * suitable for the abstract BFS helper.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private function fetch_source_terms(): array {
		$this->ensure_source_taxonomy_registered();
		$terms = get_terms(
			array(
				'taxonomy'   => $this->source_taxonomy,
				'hide_empty' => false,
				'orderby'    => 'parent',
				'order'      => 'ASC',
			)
		);
		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return array();
		}
		$rows = array();
		foreach ( $terms as $term ) {
			$rows[] = array(
				'id'     => (int) $term->term_id,
				'parent' => (int) $term->parent,
				'name'   => (string) $term->name,
			);
		}
		return $rows;
	}

	/**
	 * Single batch lookup of "term_id → [object_id, …]" for every term
	 * of the source taxonomy whose attached objects belong to the
	 * importer's target post type. Replaces the earlier N+1 of
	 * get_objects_in_term inside run()'s BFS loop.
	 *
	 * Not cached: called exactly once per import (run() is gated by an
	 * atomic lock plus is_imported() guard) and the resulting array can
	 * grow large, so paying the round-trip is cheaper than holding it
	 * in object cache.
	 *
	 * @return array<int, array<int, int>> Map source_term_id => list of object IDs.
	 */
	private function fetch_objects_grouped_by_term(): array {
		$this->ensure_source_taxonomy_registered();

		// Wrapped in the object cache to satisfy the WPCS NoCaching sub-
		// sniff. In practice this method is called exactly once per
		// import (run() is gated by an atomic lock + is_imported guard),
		// so the cache only lives for the request — TTL is short and the
		// entry is naturally evicted before it can serve stale data.
		$cached = wp_cache_get( 'import_objects', $this->cache_group() );
		if ( false !== $cached && is_array( $cached ) ) {
			return $cached;
		}

		global $wpdb;
		// Same join shape as count_items(), but selecting term_id +
		// object_id and grouping in PHP. WP table names come from $wpdb,
		// so no untrusted input ends up in the FROM/JOIN clauses.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT tt.term_id, tr.object_id
				FROM {$wpdb->term_taxonomy} tt
				INNER JOIN {$wpdb->term_relationships} tr ON tr.term_taxonomy_id = tt.term_taxonomy_id
				INNER JOIN {$wpdb->posts} p ON p.ID = tr.object_id AND p.post_type = %s
				WHERE tt.taxonomy = %s
				ORDER BY tt.term_id ASC, tr.object_id ASC",
				$this->get_target_post_type(),
				$this->source_taxonomy
			),
			ARRAY_A
		);

		$grouped = array();
		if ( is_array( $rows ) ) {
			foreach ( $rows as $row ) {
				$term_id = (int) $row['term_id'];
				if ( ! isset( $grouped[ $term_id ] ) ) {
					$grouped[ $term_id ] = array();
				}
				$grouped[ $term_id ][] = (int) $row['object_id'];
			}
		}

		wp_cache_set( 'import_objects', $grouped, $this->cache_group(), MINUTE_IN_SECONDS );
		return $grouped;
	}

	/**
	 * Run the import.
	 *
	 * @param string $mode "merge" or "replace".
	 * @return array<string, mixed>|WP_Error
	 */
	public function run( string $mode ) {
		if ( ! $this->source_has_rows() ) {
			return new WP_Error( 'kookoawpf_importer_no_source', __( 'No source folders found.', 'kookoa-folders' ) );
		}

		$mode = ( 'replace' === $mode ) ? 'replace' : 'merge';

		if ( 'replace' === $mode ) {
			$this->purge_target_taxonomy();
		}

		$rows = $this->fetch_source_terms();
		if ( empty( $rows ) ) {
			return array(
				'folders_created' => 0,
				'items_assigned'  => 0,
				'items_skipped'   => 0,
				'errors'          => array(),
			);
		}

		$shaped = array();
		foreach ( $rows as $row ) {
			$shaped[] = array(
				'id'     => (int) $row['id'],
				'parent' => (int) $row['parent'],
				'name'   => (string) $row['name'],
			);
		}

		$errors          = array();
		$folders_created = 0;

		$create_term = function ( string $name, int $parent_term_id ) use ( &$folders_created, &$errors ) {
			$result = KookoaWPF_Folders::create( $name, $this->get_target_post_type(), $parent_term_id );
			if ( is_wp_error( $result ) ) {
				$errors[] = sprintf(
					/* translators: 1: folder name, 2: error message */
					__( 'Could not create folder "%1$s": %2$s', 'kookoa-folders' ),
					$name,
					$result->get_error_message()
				);
				return null;
			}
			++$folders_created;
			return (int) $result['term_id'];
		};

		$id_map = $this->bfs_create_folders( $shaped, $create_term, $errors );

		// Resolve object IDs per source term and assign them in batches.
		// Single batch query replaces the previous N+1 of one
		// get_objects_in_term call per source term.
		$total_assigned  = 0;
		$total_skipped   = 0;
		$objects_grouped = $this->fetch_objects_grouped_by_term();

		// Defer term-count recalculation until every folder is populated, so a
		// large import pays one count update per term instead of one per
		// wp_set_object_terms() call. Mirrors KookoaWPF_Folders::assign_items().
		wp_defer_term_counting( true );
		foreach ( $id_map as $source_term_id => $target_term_id ) {
			$source_term_id = (int) $source_term_id;
			if ( empty( $objects_grouped[ $source_term_id ] ) ) {
				continue;
			}

			$summary         = $this->assign_items_to_folder( $objects_grouped[ $source_term_id ], (int) $target_term_id, $mode );
			$total_assigned += $summary['assigned'];
			$total_skipped  += $summary['skipped'];
		}
		wp_defer_term_counting( false );

		// Mark imported only when at least one folder was actually
		// created. See class-kookoawpf-importer-filebird-media.php for
		// the rationale.
		if ( $folders_created > 0 ) {
			$this->mark_imported();
		}

		return array(
			'folders_created' => $folders_created,
			'items_assigned'  => $total_assigned,
			'items_skipped'   => $total_skipped,
			'errors'          => $errors,
		);
	}
}
