<?php
/**
 * Plugin activation handler.
 *
 * Registers post types and taxonomies, runs migrations, inserts default
 * taxonomy terms, creates a default dealer, and flushes rewrite rules.
 *
 * @package ThemeREX\AutoDealer
 */

declare(strict_types=1);

namespace ThemeREX\AutoDealer\Core;

use ThemeREX\AutoDealer\Core\Activation\StarterTemplates;
use ThemeREX\AutoDealer\Core\Activation\TermSeeder;
use ThemeREX\AutoDealer\Database\MigrationRunner;
use ThemeREX\AutoDealer\Forms\FormConfig;
use ThemeREX\AutoDealer\PostTypes\DealerPostType;
use ThemeREX\AutoDealer\PostTypes\VehiclePostType;

class Activator {

	/**
	 * Option key for the database schema version.
	 *
	 * @var string
	 */
	private const DB_VERSION_OPTION = 'trx_ad_db_version';

	/**
	 * Seed-data schema version. Bump whenever the default term seed shape
	 * changes (e.g. v2 split Models out of Make and added Make-link term
	 * meta). On every page load, `ensureUpgraded()` compares this to the
	 * stored `trx_ad_seed_version` option and re-runs the seed if behind.
	 */
	public const SEED_VERSION_OPTION = 'trx_ad_seed_version';
	public const SEED_VERSION        = 8;

	/**
	 * Option keys for the auto-seeded starter templates. Used by
	 * `seedDefaultTemplates()` so re-runs are idempotent — only seed when
	 * the stored ID points to a missing post.
	 */
	public const DEFAULT_GUTENBERG_TEMPLATE_OPTION = 'trx_ad_default_gutenberg_template_id';
	public const DEFAULT_ELEMENTOR_TEMPLATE_OPTION = 'trx_ad_default_elementor_template_id';

	/**
	 * Phase 19 — meta keys stamped on every plugin-managed template post
	 * (`wp_block` / `elementor_library`). Read by the Templates admin page,
	 * the per-listing override meta box, and the Reset/Delete handlers.
	 *
	 *  - `_trx_ad_template_kind`: marks a post as a single-listing template
	 *    so it appears in the dropdown and on the admin page (value:
	 *    `single-listing`).
	 *  - `_trx_ad_template_origin`: tracks how the post was created — `seed`
	 *    (auto-seeded by this routine), `duplicate` (cloned via the admin
	 *    page button). Posts without this meta were imported manually or
	 *    pre-date Phase 19 and stay outside Reset's scope.
	 */
	public const TEMPLATE_KIND_META   = '_trx_ad_template_kind';
	public const TEMPLATE_ORIGIN_META = '_trx_ad_template_origin';
	public const TEMPLATE_KIND_VALUE  = 'single-listing';

	/**
	 * Current schema version shipped with this release.
	 *
	 * @var int
	 */
	private const DB_VERSION = 4;

	/**
	 * Run all activation tasks.
	 *
	 * Called by register_activation_hook().
	 *
	 * @return void
	 */
	public static function activate(): void {
		self::registerPostTypes();
		self::registerTaxonomies();
		self::runMigrations();
		self::insertDefaultTerms();
		self::createDefaultDealer();
		self::ensureComparePage();
		self::ensureFavoritesPage();
		self::ensureDealerDashboardPage();
		self::grantAdminCaps();
		self::seedDefaultTemplates();
		FormConfig::seedDefaults();
		flush_rewrite_rules();
		update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
		update_option( self::SEED_VERSION_OPTION, self::SEED_VERSION );
		update_option( 'trx_ad_activation_redirect', true );
	}

	/**
	 * Create the public Compare page if missing. Stores its ID in the
	 * `trx_ad_compare_page_id` option so the compare-bar partial can
	 * link to it. Idempotent — subsequent calls return the existing ID.
	 *
	 * The page contains the `[trx_ad_compare]` shortcode which renders
	 * the comparison cards + spec table from the user's localStorage
	 * compare bag.
	 *
	 * @return int Page ID.
	 */
	public static function ensureComparePage(): int {
		$existing = (int) get_option( 'trx_ad_compare_page_id', 0 );
		if ( $existing > 0 ) {
			$post = get_post( $existing );
			if ( $post instanceof \WP_Post && 'trash' !== $post->post_status ) {
				return $existing;
			}
		}
		// Try to find an existing page with our shortcode before creating.
		$matches = get_posts(
			array(
				'post_type'      => 'page',
				'post_status'    => array( 'publish', 'draft' ),
				'posts_per_page' => 1,
				's'              => '[trx_ad_compare',
			)
		);
		if ( ! empty( $matches ) ) {
			update_option( 'trx_ad_compare_page_id', (int) $matches[0]->ID );
			return (int) $matches[0]->ID;
		}
		$id = wp_insert_post(
			array(
				'post_type'    => 'page',
				'post_status'  => 'publish',
				'post_title'   => __( 'Compare Vehicles', 'trx-auto-dealer' ),
				'post_name'    => 'compare-vehicles',
				'post_content' => '<!-- wp:shortcode -->[trx_ad_compare]<!-- /wp:shortcode -->',
			),
			true
		);
		if ( is_wp_error( $id ) || (int) $id <= 0 ) {
			return 0;
		}
		update_option( 'trx_ad_compare_page_id', (int) $id );
		return (int) $id;
	}

	/**
	 * Create the public Favorites page if missing. Mirrors ensureComparePage:
	 * stores ID in `trx_ad_favorites_page_id`, idempotent (returns existing
	 * ID if a published/draft page already has the shortcode).
	 *
	 * @return int Page ID, or 0 on failure.
	 */
	public static function ensureFavoritesPage(): int {
		$existing = (int) get_option( 'trx_ad_favorites_page_id', 0 );
		if ( $existing > 0 ) {
			$post = get_post( $existing );
			if ( $post instanceof \WP_Post && 'trash' !== $post->post_status ) {
				return $existing;
			}
		}
		$matches = get_posts(
			array(
				'post_type'      => 'page',
				'post_status'    => array( 'publish', 'draft' ),
				'posts_per_page' => 1,
				's'              => '[trx_ad_favorites',
			)
		);
		if ( ! empty( $matches ) ) {
			update_option( 'trx_ad_favorites_page_id', (int) $matches[0]->ID );
			return (int) $matches[0]->ID;
		}
		$id = wp_insert_post(
			array(
				'post_type'    => 'page',
				'post_status'  => 'publish',
				'post_title'   => __( 'My Favorites', 'trx-auto-dealer' ),
				'post_name'    => 'favorites',
				'post_content' => '<!-- wp:shortcode -->[trx_ad_favorites]<!-- /wp:shortcode -->',
			),
			true
		);
		if ( is_wp_error( $id ) || (int) $id <= 0 ) {
			return 0;
		}
		update_option( 'trx_ad_favorites_page_id', (int) $id );
		return (int) $id;
	}

	/**
	 * Create the frontend Dealer Dashboard page if missing. Mirrors
	 * ensureComparePage/ensureFavoritesPage: stores the ID in
	 * `trx_ad_dealer_dashboard_page_id`, idempotent (returns the existing ID
	 * if a published/draft page already carries the shortcode).
	 *
	 * The page hosts the `[trx_ad_dealer_dashboard]` shortcode. Per ADR-18
	 * both tiers ship a frontend dashboard; Pro's multi-dealer module links
	 * the Auto Dealer / Auto Dealer Agent roles here (admin-bar shortcut +
	 * login redirect) so dealers have a destination that is NOT wp-admin's
	 * generic dashboard or WooCommerce's My Account screen (UAT #19/#34).
	 *
	 * @return int Page ID, or 0 on failure.
	 */
	public static function ensureDealerDashboardPage(): int {
		$existing = (int) get_option( 'trx_ad_dealer_dashboard_page_id', 0 );
		if ( $existing > 0 ) {
			$post = get_post( $existing );
			if ( $post instanceof \WP_Post && 'trash' !== $post->post_status ) {
				return $existing;
			}
		}
		$matches = get_posts(
			array(
				'post_type'      => 'page',
				'post_status'    => array( 'publish', 'draft' ),
				'posts_per_page' => 1,
				's'              => '[trx_ad_dealer_dashboard',
			)
		);
		if ( ! empty( $matches ) ) {
			update_option( 'trx_ad_dealer_dashboard_page_id', (int) $matches[0]->ID );
			return (int) $matches[0]->ID;
		}
		$id = wp_insert_post(
			array(
				'post_type'    => 'page',
				'post_status'  => 'publish',
				'post_title'   => __( 'Dealer Dashboard', 'trx-auto-dealer' ),
				'post_name'    => 'dealer-dashboard',
				'post_content' => '<!-- wp:shortcode -->[trx_ad_dealer_dashboard]<!-- /wp:shortcode -->',
			),
			true
		);
		if ( is_wp_error( $id ) || (int) $id <= 0 ) {
			return 0;
		}
		update_option( 'trx_ad_dealer_dashboard_page_id', (int) $id );
		return (int) $id;
	}

	/**
	 * Idempotent upgrade routine — runs on every page load via the
	 * `plugins_loaded` hook so a plain file-replace upgrade (no
	 * deactivate/reactivate) still applies seed-schema changes.
	 *
	 * Cheap when up-to-date (one option read). Re-seeds + flushes
	 * rewrites when the version is behind, then bumps the stored version
	 * so subsequent page loads no-op.
	 *
	 * @return void
	 */
	public static function ensureUpgraded(): void {
		$stored = (int) get_option( self::SEED_VERSION_OPTION, 0 );
		if ( $stored >= self::SEED_VERSION ) {
			return;
		}
		// `wp_insert_term` silently skips dupes; `update_term_meta`
		// overwrites — so re-running is safe.
		self::insertDefaultTerms();
		self::grantAdminCaps();
		self::ensureComparePage();
		self::ensureFavoritesPage();
		self::ensureDealerDashboardPage();
		self::seedDefaultTemplates();
		FormConfig::seedDefaults();

		// Re-sync every existing listing's specs row so admin list tables
		// + frontend templates always have data even when older listings
		// were created before the sync hook ran reliably.
		self::resyncAllListingSpecs();

		// Refresh stale term counts. WordPress maintains
		// `wp_term_taxonomy.count` automatically when terms are
		// (de)assigned via the standard hooks, but counts can drift on
		// imports, REST writes, or after a CPT registration change.
		// Symptom: "Ford (1)" in the make dropdown when 2 Ford listings
		// are actually published.
		self::refreshTermCounts();

		// Bust any leftover aggregation transients from earlier versions
		// that cached pre-resync data (the reason the inventory sidebar
		// kept showing only Toyota even after new makes were published).
		( new \ThemeREX\AutoDealer\Cache\AggregationCache() )->flush();

		flush_rewrite_rules();
		update_option( self::SEED_VERSION_OPTION, self::SEED_VERSION );
	}

	/**
	 * Re-run `VehicleSpecsSync::syncOnSave` for every existing listing.
	 *
	 * One-shot used by `ensureUpgraded()` to backfill specs rows that
	 * were created on an older plugin version (or that an external
	 * import wrote post meta for without firing our save_post hook).
	 *
	 * Capped at 5000 listings to avoid timeout on huge sites; sites
	 * larger than that should run a WP-CLI re-sync instead.
	 *
	 * @return void
	 */
	public static function resyncAllListingSpecs(): void {
		self::resyncAllListingSpecsWithReport();
	}

	/**
	 * Resync variant that returns a per-listing report.
	 *
	 * Captures, for each listing: post ID, title, the data collected for
	 * upsert, and any WP_Error returned by syncOnSave. Surfaced in the admin
	 * UI by the "Re-sync vehicle specs" handler so the user can see which
	 * listings synced cleanly vs which need attention.
	 *
	 * @return array<int, array{id:int,title:string,data:array<string,mixed>,error:string}>
	 */
	public static function resyncAllListingSpecsWithReport(): array {
		global $wpdb;

		// Drop orphaned specs rows first — those whose post_id no longer
		// references a live `trx_ad_listing` post. Otherwise their VIN
		// values can block the unique index on resync.
		$table = $wpdb->prefix . 'trx_ad_vehicle_specs';
		// $table is built from $wpdb->prefix + a hardcoded suffix; no user
		// input ever reaches the interpolation. $wpdb->posts is a WP-managed
		// property. PHPCS can't infer this and flags the multi-line interpolation.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query(
			"DELETE s FROM {$table} s
			 LEFT JOIN {$wpdb->posts} p ON p.ID = s.post_id
			 WHERE p.ID IS NULL
			    OR p.post_type <> 'trx_ad_listing'
			    OR p.post_status = 'trash'"
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		// Activation-time backfill must bypass third-party query filters
		// (multilingual plugins, etc.) so we re-sync every listing exactly once.
		$ids = get_posts(
			array(
				'post_type'        => 'trx_ad_listing',
				'post_status'      => array( 'publish', 'draft', 'pending', 'private', 'future' ),
				'fields'           => 'ids',
				'posts_per_page'   => 5000,
				'no_found_rows'    => true,
				'suppress_filters' => true, // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters
			)
		);
		if ( ! is_array( $ids ) || empty( $ids ) ) {
			return array();
		}
		$sync = new \ThemeREX\AutoDealer\Database\VehicleSpecsSync();

		// `collectData()` is public as of plan T2-4 — the reflection trick
		// was a code smell and was the one production-side use of reflection.
		$report = array();
		foreach ( $ids as $id ) {
			$pid      = (int) $id;
			$data     = $sync->collectData( $pid );
			$result   = $sync->syncOnSave( $pid );
			$err      = is_wp_error( $result ) ? $result->get_error_code() . ': ' . $result->get_error_message() : '';
			$report[] = array(
				'id'    => $pid,
				'title' => (string) get_the_title( $pid ),
				'data'  => is_array( $data ) ? $data : array(),
				'error' => $err,
			);
		}
		return $report;
	}

	/**
	 * Force WordPress to recalculate the cached count for every term in
	 * our taxonomies. The count is stored on `wp_term_taxonomy.count` and
	 * usually maintained automatically, but drifts on imports, REST
	 * writes, and CPT registration changes.
	 *
	 * Called from `ensureUpgraded()` once per seed-version bump.
	 *
	 * @return void
	 */
	private static function refreshTermCounts(): void {
		$taxonomies = array(
			'trx_ad_make',
			'trx_ad_model',
			'trx_ad_body_style',
			'trx_ad_fuel_type',
			'trx_ad_transmission',
			'trx_ad_drivetrain',
			'trx_ad_condition',
			'trx_ad_vehicle_type',
			'trx_ad_color',
			'trx_ad_feature',
		);
		foreach ( $taxonomies as $tax ) {
			$ids = get_terms(
				array(
					'taxonomy'   => $tax,
					'hide_empty' => false,
					'fields'     => 'ids',
				)
			);
			if ( is_array( $ids ) && ! empty( $ids ) ) {
				wp_update_term_count_now( $ids, $tax );
			}
		}
	}

	/**
	 * Seed one ready-to-use Gutenberg `wp_block` and one Elementor library
	 * template on activation. Customers shouldn't have to hand-craft a
	 * single-listing layout before they can preview Gutenberg/Elementor mode
	 * — having a working starter shipped with the plugin removes the
	 * out-of-the-box dead-end.
	 *
	 * Idempotent: stores the seeded post IDs in options. Re-runs only seed
	 * a missing post (deleted/trashed). Customers' own templates aren't
	 * touched.
	 *
	 * @return void
	 */
	public static function seedDefaultTemplates(): void {
		StarterTemplates::seed();
	}

	/**
	 * Grant the administrator role the listing/dealer caps generated by
	 * `capability_type: 'trx_ad_listing'` / `'trx_ad_dealer'`.
	 *
	 * Without this, admin loses menu access after a fresh activation: WP
	 * checks `edit_trx_ad_listings` (not `edit_posts`) for the menu, list
	 * table, and quick-edit row actions, and admin doesn't have it by default.
	 * Idempotent — `add_cap` is a no-op if the cap is already granted.
	 *
	 * @return void
	 */
	public static function grantAdminCaps(): void {
		$admin = get_role( 'administrator' );
		if ( ! $admin ) {
			return;
		}
		$caps = array(
			// Listing primitives.
			'edit_trx_ad_listings',
			'edit_others_trx_ad_listings',
			'edit_published_trx_ad_listings',
			'edit_private_trx_ad_listings',
			'publish_trx_ad_listings',
			'delete_trx_ad_listings',
			'delete_others_trx_ad_listings',
			'delete_published_trx_ad_listings',
			'delete_private_trx_ad_listings',
			'read_private_trx_ad_listings',
			'read_trx_ad_listing',
			// Dealer primitives.
			'edit_trx_ad_dealers',
			'edit_others_trx_ad_dealers',
			'edit_published_trx_ad_dealers',
			'edit_private_trx_ad_dealers',
			'publish_trx_ad_dealers',
			'delete_trx_ad_dealers',
			'delete_others_trx_ad_dealers',
			'delete_published_trx_ad_dealers',
			'delete_private_trx_ad_dealers',
			'read_private_trx_ad_dealers',
			'read_trx_ad_dealer',
		);
		foreach ( $caps as $cap ) {
			$admin->add_cap( $cap );
		}
	}

	/**
	 * Register custom post types so rewrite rules are generated.
	 *
	 * @return void
	 */
	private static function registerPostTypes(): void {
		$vehicle = new VehiclePostType();
		$vehicle->register();

		$dealer = new DealerPostType();
		$dealer->register();
	}

	/**
	 * Register all vehicle taxonomies.
	 *
	 * Taxonomy registration is normally done inside VehiclePostType or
	 * a dedicated Taxonomies class, but during activation the init hook
	 * has not fired yet. We register them inline here so that term
	 * insertion and rewrite flushing work correctly.
	 *
	 * @return void
	 */
	private static function registerTaxonomies(): void {
		TermSeeder::registerTaxonomies();
	}

	/**
	 * Run pending database migrations.
	 *
	 * @return void
	 */
	private static function runMigrations(): void {
		$runner = new MigrationRunner();
		$runner->runPendingMigrations();
	}

	/**
	 * Insert default terms for all vehicle taxonomies.
	 *
	 * Uses wp_insert_term which silently skips duplicates, so this is
	 * safe to call on every activation.
	 *
	 * @return void
	 */
	public static function insertDefaultTerms(): void {
		TermSeeder::insertDefaultTerms();
	}

	/**
	 * Create a default dealer post if none exists. Thin facade over
	 * `TermSeeder::createDefaultDealer()`.
	 *
	 * @return void
	 */
	private static function createDefaultDealer(): void {
		TermSeeder::createDefaultDealer();
	}
}
