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

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

/**
 * Base class for migration importers that read folder structures from
 * competing media-organization plugins and recreate them as KooKoa
 * Folders terms.
 *
 * Each concrete importer represents ONE source-target pair (for example
 * "FileBird Media" → kookoawpf_folder_attachment, "FileBird Posts" →
 * kookoawpf_folder_post). Detection is data-driven: an importer is shown
 * to the user only when its source has rows in the database, regardless
 * of whether the source plugin is still active.
 *
 * Subclasses must implement count_folders() / count_items() / run().
 *
 * @since 1.0.5
 */
abstract class KookoaWPF_Importer_Abstract {

	/**
	 * Stable identifier used in option keys and AJAX requests (kebab-case,
	 * e.g. "filebird-media").
	 *
	 * @return string
	 */
	abstract public function get_id(): string;

	/**
	 * Human-readable source name shown in the UI (e.g. "FileBird Media").
	 *
	 * @return string
	 */
	abstract public function get_name(): string;

	/**
	 * Plugin author shown under the source name (e.g. "Ninja Team").
	 *
	 * @return string
	 */
	abstract public function get_author(): string;

	/**
	 * Target post type for which folders will be created (attachment,
	 * post, page, …).
	 *
	 * @return string
	 */
	abstract public function get_target_post_type(): string;

	/**
	 * Number of source folders detected. Returns 0 when the source is not
	 * installed or contains no data — in that case the card is hidden.
	 *
	 * @return int
	 */
	abstract public function count_folders(): int;

	/**
	 * Number of items (attachments / posts / pages) assigned to source
	 * folders. Cosmetic only — shown in the card description.
	 *
	 * @return int
	 */
	abstract public function count_items(): int;

	/**
	 * Run the import. Returns an associative array with at least the keys
	 * "folders_created", "items_assigned", "items_skipped" and "errors".
	 *
	 * @param string $mode Either "merge" or "replace".
	 * @return array<string, mixed>|WP_Error
	 */
	abstract public function run( string $mode );

	/**
	 * Whether this importer targets a post type that is gated behind the
	 * PRO version. When true the UI shows a "PRO" badge and disables the
	 * Import button.
	 *
	 * Default: only "attachment" is free. Subclasses may override.
	 *
	 * @return bool
	 */
	public function requires_pro(): bool {
		return 'attachment' !== $this->get_target_post_type();
	}

	/**
	 * Option key used to record successful imports.
	 *
	 * @return string
	 */
	public function imported_option_key(): string {
		return 'kookoawpf_importer_imported_' . str_replace( '-', '_', $this->get_id() );
	}

	/**
	 * Option key used to record dismissals ("Don't show again").
	 *
	 * @return string
	 */
	public function dismissed_option_key(): string {
		return 'kookoawpf_importer_dismissed_' . str_replace( '-', '_', $this->get_id() );
	}

	/**
	 * Whether the user has already imported from this source.
	 *
	 * @return bool
	 */
	public function is_imported(): bool {
		return (int) get_option( $this->imported_option_key(), 0 ) > 0;
	}

	/**
	 * Whether the user has dismissed this importer's card.
	 *
	 * @return bool
	 */
	public function is_dismissed(): bool {
		return '1' === (string) get_option( $this->dismissed_option_key(), '' );
	}

	/**
	 * Mark this importer as imported (sets unix timestamp).
	 */
	public function mark_imported(): void {
		update_option( $this->imported_option_key(), time(), false );
	}

	/**
	 * Mark this importer's card as dismissed.
	 */
	public function dismiss(): void {
		update_option( $this->dismissed_option_key(), '1', false );
	}

	/**
	 * Clear both imported and dismissed flags. Used by "Reset import
	 * detection" so previously-completed cards become visible again.
	 */
	public function reset(): void {
		delete_option( $this->imported_option_key() );
		delete_option( $this->dismissed_option_key() );
	}

	/**
	 * Whether the card should appear in the UI. True when there is data
	 * to import AND the user has not dismissed AND has not already
	 * completed the import.
	 *
	 * @return bool
	 */
	public function is_visible(): bool {
		if ( $this->is_dismissed() || $this->is_imported() ) {
			return false;
		}
		return $this->count_folders() > 0;
	}

	/**
	 * Delete every existing term under the target taxonomy. Used by
	 * Replace mode before recreating folders from the source. Scoped to a
	 * single target post type by design (Replace on FileBird Media does
	 * NOT touch Posts/Pages folders).
	 */
	protected function purge_target_taxonomy(): void {
		$taxonomy = KookoaWPF_Folders::get_taxonomy( $this->get_target_post_type() );
		$term_ids = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'fields'     => 'ids',
				'hide_empty' => false,
			)
		);

		if ( is_wp_error( $term_ids ) || empty( $term_ids ) ) {
			return;
		}

		foreach ( $term_ids as $term_id ) {
			wp_delete_term( (int) $term_id, $taxonomy );
		}
	}

	/**
	 * Generic BFS folder creation. Receives a list of source rows shaped
	 * as { id, parent, name } and a callable that creates one folder and
	 * returns its new term_id (or null on failure).
	 *
	 * Defensive against corrupted source hierarchies:
	 *   - self-parent (parent == id) → reparent under root + warning
	 *   - parent points to a non-existent folder → reparent under root + warning
	 *   - cycles (A → B → A) survive the BFS as "unreached" rows and are
	 *     imported under root in a final pass with a warning
	 * Each defensive action appends a localized message to $errors so
	 * the caller can surface it to the user instead of silently losing
	 * folders and their attachments.
	 *
	 * Returns a map source_id => new_term_id.
	 *
	 * @param array<int, array{id: int, parent: int, name: string}> $rows         Source rows in any order.
	 * @param callable                                              $create_term  Callback fn(string $name, int $parent_term_id): int|null.
	 * @param array<int, string>                                    $errors       Output array — appended with one localized warning per repaired row.
	 * @return array<int, int>
	 */
	protected function bfs_create_folders( array $rows, callable $create_term, array &$errors = array() ): array {
		// Set of valid source IDs in the input — used to detect parents
		// pointing to folders that no longer exist (orphans).
		$valid_ids = array();
		foreach ( $rows as $row ) {
			$valid_ids[ (int) $row['id'] ] = true;
		}

		// Build adjacency map, fixing self-parent and orphan rows along
		// the way so they get hung under root instead of disappearing.
		$children_of = array( 0 => array() );
		foreach ( $rows as $row ) {
			$id     = (int) $row['id'];
			$parent = (int) $row['parent'];

			if ( $parent === $id ) {
				$errors[] = sprintf(
					/* translators: 1: folder name, 2: source folder ID */
					__( 'Folder "%1$s" (source ID %2$d) references itself as parent; imported under root.', 'kookoa-folders' ),
					(string) $row['name'],
					$id
				);
				$row['parent'] = 0;
				$parent        = 0;
			} elseif ( 0 !== $parent && ! isset( $valid_ids[ $parent ] ) ) {
				$errors[] = sprintf(
					/* translators: 1: folder name, 2: missing parent source ID */
					__( 'Folder "%1$s" points to a missing parent (source ID %2$d); imported under root.', 'kookoa-folders' ),
					(string) $row['name'],
					$parent
				);
				$row['parent'] = 0;
				$parent        = 0;
			}

			if ( ! isset( $children_of[ $parent ] ) ) {
				$children_of[ $parent ] = array();
			}
			$children_of[ $parent ][] = $row;
		}

		$id_map  = array();
		$visited = array();
		// Index-pointer queue instead of array_shift(): array_shift is
		// O(n) because it reindexes the whole array on every call, so
		// the original BFS was O(n²) on large source hierarchies. With
		// a moving offset the BFS stays O(n).
		$queue  = isset( $children_of[0] ) ? $children_of[0] : array();
		$offset = 0;

		while ( isset( $queue[ $offset ] ) ) {
			$row = $queue[ $offset ];
			++$offset;
			$source_id = (int) $row['id'];

			// Guard against any leftover cycle that slipped past the
			// orphan / self-parent normalization above.
			if ( isset( $visited[ $source_id ] ) ) {
				continue;
			}
			$visited[ $source_id ] = true;

			$source_parent    = (int) $row['parent'];
			$target_parent_id = 0 === $source_parent ? 0 : ( isset( $id_map[ $source_parent ] ) ? $id_map[ $source_parent ] : 0 );

			$new_term_id = $create_term( (string) $row['name'], $target_parent_id );
			if ( null === $new_term_id ) {
				continue;
			}
			$id_map[ $source_id ] = (int) $new_term_id;

			if ( ! empty( $children_of[ $source_id ] ) ) {
				foreach ( $children_of[ $source_id ] as $child ) {
					$queue[] = $child;
				}
			}
		}

		// Cycle recovery: any row whose id was never visited is trapped
		// in a cycle that has no path back to root (e.g. A → B → A).
		// Surface them under root so their attachments are not lost.
		foreach ( $rows as $row ) {
			$source_id = (int) $row['id'];
			if ( isset( $id_map[ $source_id ] ) ) {
				continue;
			}
			$new_term_id = $create_term( (string) $row['name'], 0 );
			if ( null === $new_term_id ) {
				continue;
			}
			$id_map[ $source_id ] = (int) $new_term_id;
			$errors[]             = sprintf(
				/* translators: 1: folder name, 2: source folder ID */
				__( 'Folder "%1$s" (source ID %2$d) was part of a cyclic hierarchy; imported under root.', 'kookoa-folders' ),
				(string) $row['name'],
				$source_id
			);
		}

		return $id_map;
	}

	/**
	 * Helper used by concrete importers to assign one or more attachments
	 * /posts/pages to a target folder. In merge mode, items already
	 * assigned to ANY existing folder are skipped so the user's existing
	 * organization is preserved.
	 *
	 * @param array<int, int> $object_ids Attachment / post / page IDs.
	 * @param int             $term_id    Target folder term ID.
	 * @param string          $mode       "merge" or "replace".
	 * @return array{assigned: int, skipped: int}
	 */
	protected function assign_items_to_folder( array $object_ids, int $term_id, string $mode ): array {
		$assigned         = 0;
		$skipped          = 0;
		$target_post_type = $this->get_target_post_type();
		$taxonomy         = KookoaWPF_Folders::get_taxonomy( $target_post_type );

		foreach ( $object_ids as $object_id ) {
			$object_id = (int) $object_id;
			if ( $object_id <= 0 ) {
				++$skipped;
				continue;
			}

			// Validate the object: source plugins can carry stale IDs
			// (attachment deleted from Media Library but still listed
			// in their tables) or cross-type relations that would
			// otherwise attach an attachment-taxonomy term to a post,
			// or vice versa. get_post_type() returns false on missing.
			$actual_post_type = get_post_type( $object_id );
			if ( false === $actual_post_type || $actual_post_type !== $target_post_type ) {
				++$skipped;
				continue;
			}

			if ( 'merge' === $mode ) {
				$existing = wp_get_object_terms( $object_id, $taxonomy, array( 'fields' => 'ids' ) );
				if ( ! is_wp_error( $existing ) && ! empty( $existing ) ) {
					++$skipped;
					continue;
				}
			}

			$result = wp_set_object_terms( $object_id, $term_id, $taxonomy );
			if ( is_wp_error( $result ) ) {
				++$skipped;
				continue;
			}
			++$assigned;
		}

		return array(
			'assigned' => $assigned,
			'skipped'  => $skipped,
		);
	}
}
