<?php
/**
 * AnWP Football Leagues :: Standing.
 *
 * @since   0.2.0
 * @package AnWP_Football_Leagues
 */

/**
 * AnWP Football Leagues :: Standing post type class.
 *
 * @since 0.2.0
 */
class AnWPFL_Standing extends AnWPFL_DB {

	/**
	 * Parent plugin class.
	 *
	 * @var AnWP_Football_Leagues
	 * @since  0.2.0
	 */
	protected $plugin = null;

	/**
	 * Constructor.
	 *
	 * @param AnWP_Football_Leagues $plugin Main plugin object.
	 *
	 * @since 0.2.0
	 */
	public function __construct( $plugin ) {
		global $wpdb;

		$this->plugin      = $plugin;
		$this->table_name  = $wpdb->anwpfl_standings;
		$this->primary_key = 'standing_id';

		$this->hooks();
	}

	/**
	 * Register the Standing custom post type.
	 *
	 * @since 0.18.0
	 */
	public function register_post_type() {
		$permalink_structure = $this->plugin->options->get_permalink_structure();
		$permalink_slug      = empty( $permalink_structure['standing'] ) ? 'table' : $permalink_structure['standing'];

		register_post_type(
			'anwp_standing',
			[
				'labels'              => [
					'name'          => esc_html__( 'Standing Tables', 'anwp-football-leagues' ),
					'singular_name' => esc_html__( 'Standing Table', 'anwp-football-leagues' ),
					'all_items'     => esc_html__( 'Standing Tables', 'anwp-football-leagues' ),
					'add_new'       => esc_html__( 'Add New Standing Table', 'anwp-football-leagues' ),
					'add_new_item'  => esc_html__( 'Add New Standing Table', 'anwp-football-leagues' ),
					'edit_item'     => esc_html__( 'Edit Standing Table', 'anwp-football-leagues' ),
					'new_item'      => esc_html__( 'New Standing Table', 'anwp-football-leagues' ),
					'view_item'     => esc_html__( 'View Standing Table', 'anwp-football-leagues' ),
				],
				'supports'            => [ 'title' ],
				'rewrite'             => [ 'slug' => $permalink_slug ],
				'show_in_menu'        => 'edit.php?post_type=anwp_competition',
				'public'              => true,
				'exclude_from_search' => 'hide' === AnWPFL_Options::get_value( 'display_front_end_search_standing' ),
			]
		);
	}

	/**
	 * Filter post updated messages for the Standing CPT.
	 *
	 * @param array $messages Post updated messages.
	 *
	 * @return array
	 * @since 0.18.0
	 */
	public function messages( $messages ) {
		global $post, $post_ID;

		$messages['anwp_standing'] = [
			0  => '',
			1  => sprintf( '%s <a href="%s">%s</a>', esc_html__( 'Standing Table updated.', 'anwp-football-leagues' ), esc_url( get_permalink( $post_ID ) ), esc_html__( 'View Standing Table', 'anwp-football-leagues' ) ),
			2  => esc_html__( 'Custom field updated.', 'anwp-football-leagues' ),
			3  => esc_html__( 'Custom field deleted.', 'anwp-football-leagues' ),
			4  => esc_html__( 'Standing Table updated.', 'anwp-football-leagues' ),
			// translators: %s: revision date
			5  => isset( $_GET['revision'] ) ? sprintf( esc_html__( 'Standing Table restored to revision from %s', 'anwp-football-leagues' ), wp_post_revision_title( (int) $_GET['revision'], false ) ) : false, // phpcs:ignore WordPress.Security.NonceVerification
			6  => sprintf( '%s <a href="%s">%s</a>', esc_html__( 'Standing Table published.', 'anwp-football-leagues' ), esc_url( get_permalink( $post_ID ) ), esc_html__( 'View Standing Table', 'anwp-football-leagues' ) ),
			7  => esc_html__( 'Standing Table saved.', 'anwp-football-leagues' ),
			8  => sprintf( '%s <a target="_blank" href="%s">%s</a>', esc_html__( 'Standing Table submitted.', 'anwp-football-leagues' ), esc_url( add_query_arg( 'preview', 'true', get_permalink( $post_ID ) ) ), esc_html__( 'Preview Standing Table', 'anwp-football-leagues' ) ),
			// translators: %1$s: scheduled date
			9  => sprintf( esc_html__( 'Standing Table scheduled for: %1$s.', 'anwp-football-leagues' ), '<strong>' . date_i18n( esc_html__( 'M j, Y @ G:i', 'anwp-football-leagues' ), strtotime( $post->post_date ) ) . '</strong>' ),
			10 => sprintf( '%s <a target="_blank" href="%s">%s</a>', esc_html__( 'Standing Table draft updated.', 'anwp-football-leagues' ), esc_url( add_query_arg( 'preview', 'true', get_permalink( $post_ID ) ) ), esc_html__( 'Preview Standing Table', 'anwp-football-leagues' ) ),
		];

		return $messages;
	}

	/**
	 * Filter bulk post updated messages for the Standing CPT.
	 *
	 * @param array $bulk_messages Bulk updated messages.
	 * @param array $bulk_counts   Counts of updated posts.
	 *
	 * @return array
	 * @since 0.18.0
	 */
	public function bulk_messages( $bulk_messages, $bulk_counts ) {
		$bulk_messages['anwp_standing'] = [
			// translators: %s: number of standing tables
			'updated'   => sprintf( _n( '%s Standing Table updated.', '%s Standing Tables updated.', $bulk_counts['updated'], 'anwp-football-leagues' ), $bulk_counts['updated'] ),
			// translators: %s: number of standing tables
			'locked'    => sprintf( _n( '%s Standing Table not updated, somebody is editing it.', '%s Standing Tables not updated, somebody is editing them.', $bulk_counts['locked'], 'anwp-football-leagues' ), $bulk_counts['locked'] ),
			// translators: %s: number of standing tables
			'deleted'   => sprintf( _n( '%s Standing Table permanently deleted.', '%s Standing Tables permanently deleted.', $bulk_counts['deleted'], 'anwp-football-leagues' ), $bulk_counts['deleted'] ),
			// translators: %s: number of standing tables
			'trashed'   => sprintf( _n( '%s Standing Table moved to the Trash.', '%s Standing Tables moved to the Trash.', $bulk_counts['trashed'], 'anwp-football-leagues' ), $bulk_counts['trashed'] ),
			// translators: %s: number of standing tables
			'untrashed' => sprintf( _n( '%s Standing Table restored from the Trash.', '%s Standing Tables restored from the Trash.', $bulk_counts['untrashed'], 'anwp-football-leagues' ), $bulk_counts['untrashed'] ),
		];

		return $bulk_messages;
	}

	/**
	 * Get a single standing config row by standing_id.
	 *
	 * Cascades: per-request row cache → anwpfl_standings table → postmeta fallback.
	 * Postmeta fallback lets the plugin keep working before the migration Toolbox
	 * task runs. Premium extends the fallback row via the
	 * `anwpfl/standing/row_from_postmeta` filter.
	 *
	 * @since 0.18.0
	 *
	 * @param int $id Standing post ID.
	 *
	 * @return array|null Row as associative array or null if not a fixed standing.
	 */
	public function get_row( int $id ): ?array {
		if ( $this->has_cached_row( $id ) ) {
			return $this->get_cached_row( $id );
		}

		global $wpdb;

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM $this->table_name WHERE $this->primary_key = %d",
				$id
			),
			ARRAY_A
		);

		if ( $row ) {
			$this->cache_row( $id, $row );
			return $row;
		}

		// After migration, the table is authoritative — skip postmeta fallback.
		if ( get_option( 'anwpfl_standings_migrated' ) ) {
			$this->cache_row( $id, null );
			return null;
		}

		// Fallback: build row from postmeta (pre-migration or mid-migration).
		$row = $this->get_standing_row_from_postmeta( $id );

		// Cache null result too — prevents re-querying for non-existent IDs
		// within the same request (admin hooks speculatively call get_row()
		// for every post ID in the list).
		$this->cache_row( $id, $row );

		return $row;
	}

	/**
	 * Build a standing row array from postmeta.
	 *
	 * Migration safety net: returns an array with the same keys as a real
	 * anwpfl_standings row, so read sites work before the Toolbox migration
	 * backfills the table.
	 *
	 * Premium columns are appended via the `anwpfl/standing/row_from_postmeta`
	 * filter so premium hook listeners reading `$row['manual_filling']` etc.
	 * get the correct values on unmigrated standings.
	 *
	 * Returns null when the post is not a fixed standing (no `_anwpfl_fixed`
	 * postmeta value) — matches the table contract where un-fixed standings
	 * have no row.
	 *
	 * @since 0.18.0
	 *
	 * @param int $id Standing post ID.
	 *
	 * @return array|null
	 */
	public function get_standing_row_from_postmeta( int $id, bool $require_fixed = true ): ?array {
		if ( $id <= 0 ) {
			return null;
		}

		// Skip un-fixed standings (no config ever saved) when require_fixed=true.
		// process_clone_standing() passes false so a cloned draft (no _anwpfl_fixed
		// postmeta yet) can still get an upsert row with competition_id=0/group_id=0.
		if ( $require_fixed && 'true' !== get_post_meta( $id, '_anwpfl_fixed', true ) ) {
			return null;
		}

		$last_recalc = get_post_meta( $id, '_anwpfl_last_recalc', true );

		$row = [
			'standing_id'            => $id,
			'competition_id'         => absint( get_post_meta( $id, '_anwpfl_competition', true ) ),
			'group_id'               => absint( get_post_meta( $id, '_anwpfl_competition_group', true ) ),
			'points_win'             => (int) get_post_meta( $id, '_anwpfl_points_win', true ),
			'points_draw'            => (int) get_post_meta( $id, '_anwpfl_points_draw', true ),
			'points_loss'            => (int) get_post_meta( $id, '_anwpfl_points_loss', true ),
			'ranking_rules'          => (string) get_post_meta( $id, '_anwpfl_ranking_rules_current', true ),
			'manual_ordering'        => (int) ( 'true' === get_post_meta( $id, '_anwpfl_manual_ordering', true ) ),
			'is_initial_data_active' => (int) get_post_meta( $id, '_anwpfl_is_initial_data_active', true ),
			'last_recalc'            => $last_recalc ? (string) $last_recalc : null,
			'last_round'             => (int) get_post_meta( $id, '_anwpfl_table_last_round', true ),
			'table_notes'            => (string) get_post_meta( $id, '_anwpfl_table_notes', true ),
			'table_colors'           => (string) get_post_meta( $id, '_anwpfl_table_colors', true ),
			'points_initial'         => (string) get_post_meta( $id, '_anwpfl_points_initial', true ),
			'table_initial'          => (string) get_post_meta( $id, '_anwpfl_table_initial', true ),
			'table_main'             => (string) get_post_meta( $id, '_anwpfl_table_main', true ),
		];

		/**
		 * Filter the standing row built from postmeta.
		 *
		 * Premium uses this to append its columns so pre-migration reads
		 * via get_row() return the full row shape. Includes all JSON blob
		 * columns (table_main_home, table_main_away, etc.) sourced from
		 * corresponding postmeta keys.
		 *
		 * @since 0.18.0
		 *
		 * @param array $row Row as associative array keyed by column name.
		 * @param int   $id  Standing post ID.
		 */
		return apply_filters( 'anwpfl/standing/row_from_postmeta', $row, $id );
	}

	/**
	 * Get all standing rows (club rows) for a standing, sorted by place.
	 *
	 * Decodes the `table_main` JSON blob from the standing row.
	 * No separate query — reuses the per-request row cache from get_row().
	 *
	 * Validates JSON decode to catch the `"null"` string corruption from
	 * the save_metabox re-encode dance (see Gotcha #10 in PLAN.md).
	 *
	 * @since 0.18.0
	 *
	 * @param int $id Standing post ID.
	 *
	 * @return array List of row arrays (each keyed by column name).
	 */
	public function get_standing_rows( int $id ): array {
		$row = $this->get_row( $id );

		if ( ! $row || empty( $row['table_main'] ) ) {
			return [];
		}

		return $this->decode_table_main_json( $row['table_main'], $id, 'table_main' );
	}

	/**
	 * Decode a table_main JSON blob with validation.
	 *
	 * Shared helper for get_standing_rows(). Returns [] on any decode failure and logs
	 * a QM warning so corruption is visible during testing.
	 *
	 * @since 0.18.0
	 *
	 * @param string $json JSON blob to decode.
	 * @param int    $id   Standing ID (for logging).
	 * @param string $col  Column name (for logging).
	 *
	 * @return array
	 */
	protected function decode_table_main_json( string $json, int $id, string $col ): array {
		if ( '' === $json ) {
			return [];
		}

		$decoded = json_decode( $json, true );

		if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
			do_action(
				'qm/warning',
				'[FL Standing] Invalid {col} JSON for standing {id}',
				[
					'col' => $col,
					'id'  => $id,
				]
			);
			return [];
		}

		return $decoded;
	}

	/**
	 * Validate a JSON string from $_POST before storing it in a JSON-blob column.
	 *
	 * Prevents the "null" literal corruption from Gotcha #10: the current
	 * save_metabox re-encode dance does wp_json_encode( json_decode( $raw ) ),
	 * which silently persists the 4-char string "null" when $raw is malformed
	 * (json_decode returns null → json_encode(null) returns the literal "null").
	 * Once Phase 5 removes the postmeta fallback, that corruption is permanent.
	 *
	 * Returns the re-encoded JSON string on success, or null on decode failure
	 * so the caller can skip the column from its upsert payload.
	 *
	 * Pass $shape = 'object' for columns that hold JSON objects ({...}) so an
	 * empty input re-encodes as '{}' via an explicit (object) cast — json_encode
	 * otherwise emits '[]' for empty associative arrays (CLAUDE.md "JSON object
	 * decode gotcha").
	 *
	 * @since 0.18.0
	 *
	 * @param mixed  $raw   Raw $_POST value (expected: JSON string).
	 * @param int    $id    Standing ID (for logging).
	 * @param string $col   Target column name (for logging).
	 * @param string $shape 'array' (default) or 'object' — controls empty-value shape.
	 *
	 * @return string|null Validated JSON string, or null on failure.
	 */
	protected function validate_json_column( $raw, int $id, string $col, string $shape = 'array' ): ?string {
		if ( null === $raw || '' === $raw ) {
			return '';
		}

		if ( ! is_string( $raw ) ) {
			return null;
		}

		$decoded = json_decode( $raw, true );

		if ( JSON_ERROR_NONE !== json_last_error() ) {
			do_action(
				'qm/warning',
				'[FL Standing] Invalid {col} JSON from POST for standing {id}',
				[
					'col' => $col,
					'id'  => $id,
				]
			);
			return null;
		}

		// Object-shape columns: cast empty arrays to (object) so wp_json_encode
		// emits '{}' not '[]'. Non-empty string-keyed arrays already encode as
		// objects without a cast.
		if ( 'object' === $shape && is_array( $decoded ) && empty( $decoded ) ) {
			$decoded = (object) [];
		}

		return wp_json_encode( $decoded );
	}

	/**
	 * Filter CPT title entry placeholder text
	 *
	 * @param string $title Original placeholder text
	 *
	 * @return string        Modified placeholder text
	 */
	public function title( $title ) {

		$screen = get_current_screen();
		if ( isset( $screen->post_type ) && 'anwp_standing' === $screen->post_type ) {
			return esc_html__( 'Standing Table Title', 'anwp-football-leagues' );
		}

		return $title;
	}

	/**
	 * Initiate our hooks.
	 *
	 * @since  0.2.0
	 */
	public function hooks() {

		// CPT registration and admin hooks (previously handled by CPT_Core).
		add_action( 'init', [ $this, 'register_post_type' ] );
		add_filter( 'post_updated_messages', [ $this, 'messages' ] );
		add_filter( 'bulk_post_updated_messages', [ $this, 'bulk_messages' ], 10, 2 );
		add_filter( 'manage_edit-anwp_standing_columns', [ $this, 'columns' ] );
		add_filter( 'manage_edit-anwp_standing_sortable_columns', [ $this, 'sortable_columns' ] );
		add_action( 'manage_posts_custom_column', [ $this, 'columns_display' ], 10, 2 );
		add_filter( 'enter_title_here', [ $this, 'title' ] );

		// Metaboxes
		add_action( 'load-post.php', [ $this, 'init_metaboxes' ] );
		add_action( 'load-post-new.php', [ $this, 'init_metaboxes' ] );
		add_action( 'save_post', [ $this, 'save_metabox' ], 10, 2 );
		add_action( 'add_meta_boxes_anwp_standing', [ $this, 'remove_metaboxes' ], 10, 1 );

		// Notices
		add_action( 'admin_notices', [ $this, 'display_admin_standing_notice' ] );

		// Admin Table filters
		add_filter( 'disable_months_dropdown', [ $this, 'disable_months_dropdown' ], 10, 2 );
		add_action( 'restrict_manage_posts', [ $this, 'add_more_filters' ] );
		add_filter( 'pre_get_posts', [ $this, 'handle_custom_filter' ] );

		add_filter( 'post_row_actions', [ $this, 'modify_quick_actions' ], 10, 2 );
		add_action( 'post_action_clone-standing', [ $this, 'process_clone_standing' ] );
		add_action( 'delete_post', [ $this, 'on_standing_delete' ] );
	}

	/**
	 * Remove term metaboxes.
	 *
	 * @since 0.14.4
	 */
	public function remove_metaboxes() {

		$post = get_post();

		if ( ! $post || ! $this->get_row( $post->ID ) ) {
			remove_meta_box( 'submitdiv', 'anwp_standing', 'side' );
		}
	}

	/**
	 * Filters the array of row action links on the Pages list table.
	 *
	 * @param array   $actions
	 * @param WP_Post $post
	 *
	 * @return array
	 * @since 0.10.12
	 */
	public function modify_quick_actions( $actions, $post ) {

		if ( 'anwp_standing' === $post->post_type && current_user_can( 'edit_post', $post->ID ) ) {

			$clone_link                = admin_url( 'post.php?post=' . intval( $post->ID ) . '&action=clone-standing' );
			$actions['clone-standing'] = '<a href="' . esc_url( $clone_link ) . '">' . esc_html__( 'Clone', 'anwp-football-leagues' ) . '</a>';
		}

		return $actions;
	}

	/**
	 * Handle clone standing action.
	 *
	 * @param int $post_id
	 *
	 * @since 0.10.12
	 */
	public function process_clone_standing( $post_id ) {
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		$standing_id = wp_insert_post(
			[
				'post_type' => 'anwp_standing',
			]
		);

		if ( $standing_id ) {

			/*
			|--------------------------------------------------------------------
			| Read source standing config from custom table and upsert into
			| the new clone. Excludes PK, FK, and computed columns — those
			| get set when the user runs fixed-setup on the clone.
			|
			| Premium columns (columns_config, manual_filling, etc.) are
			| included when present; upsert() whitelists against real columns,
			| so missing premium columns are safely ignored.
			|
			| The cloned row has competition_id=0/group_id=0 — safe because
			| the `competition_group` KEY is non-UNIQUE. When the user runs
			| fixed-setup afterwards, save_metabox()'s first-save branch upserts
			| the real competition/group IDs over this draft row.
			|--------------------------------------------------------------------
			*/
			$source_row = $this->get_row( (int) $post_id );

			if ( $source_row ) {
				$clonable = [
					'points_initial', 'table_colors', 'table_notes',
					'points_win', 'points_draw', 'points_loss',
					'ranking_rules', 'manual_ordering',
					// Premium columns (present only if premium dbDelta ran).
					'manual_filling', 'conferences_support', 'club_conferences',
					'columns_config', 'h2h_apply_after_all_games',
					'table_lazy', 'table_date_start', 'table_date_end',
				];

				$clone_data = array_intersect_key( $source_row, array_flip( $clonable ) );

				if ( $clone_data ) {
					$this->upsert( (int) $standing_id, $clone_data, array_keys( $clone_data ) );
				}
			}

			// phpcs:ignore WordPress.Security.SafeRedirect
			if ( wp_redirect( admin_url( 'post.php?post=' . intval( $standing_id ) . '&action=edit' ) ) ) {
				exit;
			}
		}
	}

	/**
	 * Remove custom table row when standing post is permanently deleted.
	 * Trashed/drafted standings keep their row — only permanent deletes remove it.
	 *
	 * @since 0.18.0
	 *
	 * @param int $post_id Post ID.
	 */
	public function on_standing_delete( int $post_id ): void {
		if ( 'anwp_standing' !== get_post_type( $post_id ) ) {
			return;
		}

		$this->delete( $post_id );
	}

	/**
	 * Fires before the Filter button on the Posts and Pages list tables.
	 *
	 * The Filter button allows sorting by date and/or category on the
	 * Posts list table, and sorting by date on the Pages list table.
	 *
	 * @param string $post_type The post type slug.
	 */
	public function add_more_filters( $post_type ) {

		if ( 'anwp_standing' === $post_type ) {

			ob_start();

			/*
			|--------------------------------------------------------------------
			| Filter By League
			|--------------------------------------------------------------------
			*/
			// phpcs:ignore WordPress.Security.NonceVerification
			$current_league_filter = empty( $_GET['_anwpfl_current_league'] ) ? '' : (int) $_GET['_anwpfl_current_league'];
			?>
			<div class="anwp-x-selector anwp-g-float-left" fl-x-data="selectorItem('league',true)" fl-x-cloak>
				<input fl-x-model.fill="selected" type="text" class="postform anwp-g-admin-list-input anwp-w-120"
					placeholder="<?php echo esc_attr__( 'League ID', 'anwp-football-leagues' ); ?>"
					name="_anwpfl_current_league" value="<?php echo esc_attr( $current_league_filter ); ?>"/>
				<button fl-x-on:click="openModal()" type="button" class="button fl-btn-icon anwp-mr-2 postform">
					<span class="dashicons dashicons-search"></span>
				</button>
			</div>

			<?php
			/*
			|--------------------------------------------------------------------
			| Filter By Season
			|--------------------------------------------------------------------
			*/
			$seasons = get_terms(
				[
					'taxonomy'   => 'anwp_season',
					'hide_empty' => false,
				]
			);

			if ( ! is_wp_error( $seasons ) && ! empty( $seasons ) ) {
				// phpcs:ignore WordPress.Security.NonceVerification
				$current_season_filter = empty( $_GET['_anwpfl_current_season'] ) ? '' : (int) $_GET['_anwpfl_current_season'];
				?>

				<select name='_anwpfl_current_season' id='anwp_season_filter' class='postform'>
					<option value=''><?php echo esc_html__( 'All Seasons', 'anwp-football-leagues' ); ?></option>
					<?php foreach ( $seasons as $season ) : ?>
						<option value="<?php echo esc_attr( $season->term_id ); ?>" <?php selected( $season->term_id, $current_season_filter ); ?>>
							- <?php echo esc_html( $season->name ); ?>
						</option>
					<?php endforeach; ?>
				</select>
				<?php
			}

			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			echo ob_get_clean();
		}
	}

	/**
	 * Handle custom filter.
	 *
	 * @param WP_Query $query
	 */
	public function handle_custom_filter( WP_Query $query ) {
		global $post_type, $pagenow;

		// Check main query in admin
		if ( ! is_admin() || ! $query->is_main_query() ) {
			return;
		}

		if ( 'edit.php' !== $pagenow || 'anwp_standing' !== $post_type ) {
			return;
		}

		$season_competition_ids = null;
		$league_competition_ids = null;

		/*
		|--------------------------------------------------------------------
		| Filter By Season
		|--------------------------------------------------------------------
		*/
		// phpcs:ignore WordPress.Security.NonceVerification
		$filter_by_season = empty( $_GET['_anwpfl_current_season'] ) ? '' : intval( $_GET['_anwpfl_current_season'] );

		if ( $filter_by_season ) {

			$season_args = [
				'numberposts' => - 1,
				'fields'      => 'ids',
				'post_type'   => 'anwp_competition',
				'post_status' => [ 'publish', 'stage_secondary' ],
				'tax_query'   => [
					[
						'taxonomy' => 'anwp_season',
						'field'    => 'id',
						'terms'    => [ $filter_by_season ],
					],
				],
			];

			$season_competition_ids = get_posts( $season_args );
		}

		/*
		|--------------------------------------------------------------------
		| Filter By League
		|--------------------------------------------------------------------
		*/
		// phpcs:ignore WordPress.Security.NonceVerification
		$filter_by_league = empty( $_GET['_anwpfl_current_league'] ) ? '' : intval( $_GET['_anwpfl_current_league'] );

		if ( $filter_by_league ) {

			$league_args = [
				'numberposts' => - 1,
				'fields'      => 'ids',
				'post_type'   => 'anwp_competition',
				'post_status' => [ 'publish', 'stage_secondary' ],
				'tax_query'   => [
					[
						'taxonomy' => 'anwp_league',
						'field'    => 'id',
						'terms'    => [ $filter_by_league ],
					],
				],
			];

			$league_competition_ids = get_posts( $league_args );
		}

		/*
		|--------------------------------------------------------------------
		| Filter standings via custom table instead of postmeta.
		| When both season AND league filters are active, intersect the
		| competition ID sets (AND semantics, matching the old meta_query).
		|--------------------------------------------------------------------
		*/
		$competition_ids = null;

		if ( null !== $season_competition_ids && null !== $league_competition_ids ) {
			$competition_ids = array_intersect( $season_competition_ids, $league_competition_ids );
		} elseif ( null !== $season_competition_ids ) {
			$competition_ids = $season_competition_ids;
		} elseif ( null !== $league_competition_ids ) {
			$competition_ids = $league_competition_ids;
		}

		if ( null !== $competition_ids ) {
			if ( ! empty( $competition_ids ) ) {
				global $wpdb;

				$competition_ids = array_unique( array_map( 'absint', $competition_ids ) );
				$placeholders    = implode( ',', array_fill( 0, count( $competition_ids ), '%d' ) );

				// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery
				$standing_ids = $wpdb->get_col(
					$wpdb->prepare(
						"SELECT standing_id FROM {$wpdb->prefix}anwpfl_standings WHERE competition_id IN ({$placeholders})", // phpcs:ignore WordPress.DB.PreparedSQL
						$competition_ids
					)
				);

				if ( ! empty( $standing_ids ) ) {
					$query->set( 'post__in', $standing_ids );
				} else {
					$query->set( 'post__in', [ 0 ] );
				}
			} else {
				// Intersection is empty — no standings match both filters.
				$query->set( 'post__in', [ 0 ] );
			}
		}
	}

	/**
	 * Filters whether to remove the 'Months' drop-down from the post list table.
	 *
	 * @param bool   $disable   Whether to disable the drop-down. Default false.
	 * @param string $post_type The post type.
	 *
	 * @return bool
	 */
	public function disable_months_dropdown( $disable, $post_type ) {

		return 'anwp_standing' === $post_type ? true : $disable;
	}

	/**
	 * Prepare to Recalculate Standing Table.
	 *
	 * @param int $match_id
	 * @param int $competition_id
	 * @param int $group_id
	 *
	 * @since  0.3.0 (2018-31-01)
	 * @return void
	 */
	public function calculate_standing_prepare( int $match_id, int $competition_id = 0, int $group_id = 0 ) {

		if ( ! $match_id ) {
			return;
		}

		// Get competition & competitionGroup
		if ( ! $competition_id || ! $group_id ) {
			$game_data = anwp_fl()->match->get_game_data( $match_id );

			$competition_id = $game_data['competition_id'] ?? '';
			$group_id       = $game_data['group_id'] ?? '';

			if ( ! $competition_id || ! $group_id ) {
				return;
			}
		}

		$standing_data = $this->get_standing_data_by_competition_group( absint( $competition_id ), absint( $group_id ) );

		if ( $standing_data ) {
			$this->calculate_standing( $standing_data );
		}
	}

	/**
	 * Calculate all competition standings (one/first per group).
	 *
	 * @param int $competition_id
	 *
	 * @return void
	 * @since  0.10.21
	 */
	public function calculate_competition_standings( $competition_id ) {
		if ( ! absint( $competition_id ) ) {
			return;
		}

		$competition_obj = anwp_football_leagues()->competition->get_competition( $competition_id );
		$groups          = $competition_obj ? $competition_obj->groups : null;

		if ( empty( $groups ) || ! is_array( $groups ) ) {
			return;
		}

		$group_ids = wp_list_pluck( $groups, 'id' );

		$competition_id_int = absint( $competition_id );

		foreach ( $group_ids as $group_id ) {
			$standing_data = $this->get_standing_data_by_competition_group( $competition_id_int, absint( $group_id ) );

			if ( $standing_data ) {
				$this->calculate_standing( $standing_data );
			}
		}
	}

	/**
	 * Compute a standing table from finished matches.
	 *
	 * Pure computation — no DB writes, no save-pipeline hooks. Returns the ordered
	 * table plus the raw match rows used to build it. Both core's calculate_standing()
	 * and premium's calculate_standing_optional() share this method; the home/away
	 * type and matchweek-range filters live here so premium can call it unmodified.
	 *
	 * Manual-ordering override is NOT handled here — it reads table_main from the
	 * standings table (a side effect that doesn't belong in pure compute) and is
	 * applied by the caller after this returns.
	 *
	 * Extension hooks fire from inside this method:
	 *  - filter `anwpfl/standing/row_template`         — empty-row initializer per club
	 *  - action `anwpfl/standing/accumulate_match_stats` — per-match accumulator (by-ref)
	 *  - filter `anwpfl/standing/post_compute_table`   — derived/aggregate columns
	 *
	 * @since 0.18.0
	 *
	 * @param array  $data       Standing config (get_standing()/get_standings() shape).
	 * @param string $type       'all' (default), 'home', or 'away'.
	 * @param string $matchweeks '' (all matches), 'N' (single week), or 'N-M' (range).
	 *
	 * @return array{table: array, matches: array}
	 */
	public function compute_standing_table( array $data, string $type = 'all', string $matchweeks = '' ): array {

		global $wpdb;

		/*
		|--------------------------------------------------------------------
		| Initialize empty table (one row per club).
		|--------------------------------------------------------------------
		*/
		$table = [];

		foreach ( $this->plugin->competition->get_competition_clubs( $data['competition'], $data['group'] ) as $club ) {

			if ( ! (int) $club ) {
				continue;
			}

			$row = [
				'club_id'    => $club,
				'club_title' => anwp_football_leagues()->club->get_club_title_by_id( $club ),
				'place'      => 0,
				'played'     => 0,
				'won'        => 0,
				'drawn'      => 0,
				'lost'       => 0,
				'gf'         => 0,
				'ga'         => 0,
				'gd'         => 0,
				'points'     => 0,
				'series'     => '',
			];

			/**
			 * Filter: anwpfl/standing/row_template
			 *
			 * Empty-row initializer for a single club. Lets addons pre-declare
			 * extra counters (yellow_cards, clean_sheets, bonus_points, home_won,
			 * etc.) so they appear consistently in every encoded standing row.
			 *
			 * @since 0.18.0
			 *
			 * @param array $row  Empty row with zero-initialized counters.
			 * @param int   $club Club ID.
			 * @param array $data Standing config.
			 */
			$table[ $club ] = apply_filters( 'anwpfl/standing/row_template', $row, $club, $data );
		}

		/*
		|--------------------------------------------------------------------
		| Query finished matches (with optional matchweek-range filter).
		|--------------------------------------------------------------------
		*/
		$query = $wpdb->prepare(
			"
			SELECT home_club, away_club, home_goals, away_goals, match_id, match_week, home_cards_y, away_cards_y, home_cards_yr, away_cards_yr, home_cards_r, away_cards_r
			FROM {$wpdb->prefix}anwpfl_matches
			WHERE competition_id = %d
				AND group_id = %d
				AND finished = 1
			",
			$data['competition'],
			$data['group']
		);

		if ( ! empty( $matchweeks ) ) {
			$matchweeks_arr = explode( '-', $matchweeks, 2 );
			$matchweek_from = absint( $matchweeks_arr[0] );
			$matchweek_to   = isset( $matchweeks_arr[1] ) ? absint( $matchweeks_arr[1] ) : $matchweek_from;

			$query .= $wpdb->prepare( ' AND match_week >= %d AND match_week <= %d ', $matchweek_from, $matchweek_to );
		}

		$query .= ' ORDER BY kickoff';

		$matches = $wpdb->get_results( $query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		// Get games with custom outcome
		$custom_outcomes = $this->get_games_with_custom_outcome( $data['competition'], $data['group'] );

		/*
		|--------------------------------------------------------------------
		| Populate stats per match.
		|--------------------------------------------------------------------
		*/
		foreach ( $matches as $match ) {

			// Skip matches with clubs not in the competition group (data inconsistency).
			if ( ! isset( $table[ $match->home_club ] ) || ! isset( $table[ $match->away_club ] ) ) {
				continue;
			}

			$is_custom_outcome = ! empty( $custom_outcomes ) && in_array( $match->match_id, $custom_outcomes, true );

			if ( $is_custom_outcome ) {

				if ( 'away' !== $type ) {
					$home_outcome = get_post_meta( $match->match_id, '_anwpfl_outcome_home', true );

					switch ( $home_outcome ) {
						case 'won':
							$table[ $match->home_club ]['won'] ++;
							$table[ $match->home_club ]['series'] .= 'w';
							break;

						case 'drawn':
							$table[ $match->home_club ]['drawn'] ++;
							$table[ $match->home_club ]['series'] .= 'd';
							break;

						case 'lost':
							$table[ $match->home_club ]['lost'] ++;
							$table[ $match->home_club ]['series'] .= 'l';
							break;
					}
				}

				if ( 'home' !== $type ) {
					$away_outcome = get_post_meta( $match->match_id, '_anwpfl_outcome_away', true );

					switch ( $away_outcome ) {
						case 'won':
							$table[ $match->away_club ]['won'] ++;
							$table[ $match->away_club ]['series'] .= 'w';
							break;

						case 'drawn':
							$table[ $match->away_club ]['drawn'] ++;
							$table[ $match->away_club ]['series'] .= 'd';
							break;

						case 'lost':
							$table[ $match->away_club ]['lost'] ++;
							$table[ $match->away_club ]['series'] .= 'l';
							break;
					}
				}
			} else {
				if ( $match->home_goals > $match->away_goals ) {

					if ( 'away' !== $type ) {
						$table[ $match->home_club ]['won'] ++;
						$table[ $match->home_club ]['series'] .= 'w';
					}

					if ( 'home' !== $type ) {
						$table[ $match->away_club ]['lost'] ++;
						$table[ $match->away_club ]['series'] .= 'l';
					}
				} elseif ( $match->home_goals === $match->away_goals ) {

					if ( 'away' !== $type ) {
						$table[ $match->home_club ]['drawn'] ++;
						$table[ $match->home_club ]['series'] .= 'd';
					}

					if ( 'home' !== $type ) {
						$table[ $match->away_club ]['drawn'] ++;
						$table[ $match->away_club ]['series'] .= 'd';
					}
				} else {

					if ( 'away' !== $type ) {
						$table[ $match->home_club ]['lost'] ++;
						$table[ $match->home_club ]['series'] .= 'l';
					}

					if ( 'home' !== $type ) {
						$table[ $match->away_club ]['won'] ++;
						$table[ $match->away_club ]['series'] .= 'w';
					}
				}
			}

			if ( 'away' !== $type ) {
				$table[ $match->home_club ]['gf'] += $match->home_goals;
				$table[ $match->home_club ]['ga'] += $match->away_goals;
				$table[ $match->home_club ]['played'] ++;
			}

			if ( 'home' !== $type ) {
				$table[ $match->away_club ]['gf'] += $match->away_goals;
				$table[ $match->away_club ]['ga'] += $match->home_goals;
				$table[ $match->away_club ]['played'] ++;
			}

			/**
			 * Action: anwpfl/standing/accumulate_match_stats
			 *
			 * Fires once per match, AFTER core's stats (won/drawn/lost/gf/ga/played/series)
			 * have been accumulated for both clubs. Receives both club rows by reference
			 * so listeners can add custom counters (cards, clean sheets, form, bonus
			 * points, etc.) without forking compute_standing_table().
			 *
			 * The $type guard inside compute_standing_table() is NOT applied to this
			 * action — listeners receive the raw $type and decide for themselves
			 * whether to mutate $row_home, $row_away, or both.
			 *
			 * @since 0.18.0
			 *
			 * @param array  $row_home          Home club row (by reference).
			 * @param array  $row_away          Away club row (by reference).
			 * @param object $match             Raw match row from anwpfl_matches.
			 * @param bool   $is_custom_outcome Whether this match has a custom outcome override.
			 * @param array  $data              Standing config.
			 * @param string $type              'all' | 'home' | 'away'.
			 */
			do_action_ref_array(
				'anwpfl/standing/accumulate_match_stats',
				[
					&$table[ $match->home_club ],
					&$table[ $match->away_club ],
					$match,
					$is_custom_outcome,
					$data,
					$type,
				]
			);
		}

		// Calculate points + goal difference.
		foreach ( $table as $club_id => $club ) {
			$table[ $club_id ]['points'] = $club['won'] * (int) $data['win'] + $club['drawn'] * (int) $data['draw'] + $club['lost'] * (int) $data['loss'];
			$table[ $club_id ]['gd']     = $club['gf'] - $club['ga'];
		}

		/*
		|--------------------------------------------------------------------
		| Custom Outcome Points
		|--------------------------------------------------------------------
		*/
		if ( ! empty( $custom_outcomes ) && is_array( $custom_outcomes ) ) {
			foreach ( $custom_outcomes as $custom_outcome ) {
				$outcome_game_data = anwp_fl()->match->get_game_data( $custom_outcome );

				$home_club = $outcome_game_data['home_club'] ?? '';
				$away_club = $outcome_game_data['away_club'] ?? '';

				if ( 'away' !== $type ) {
					$home_outcome = get_post_meta( $custom_outcome, '_anwpfl_outcome_home', true );
					$home_points  = get_post_meta( $custom_outcome, '_anwpfl_outcome_points_home', true );

					if ( isset( $table[ $home_club ]['points'] ) ) {

						$points_added = (int) $data['win'];

						switch ( $home_outcome ) {
							case 'drawn':
								$points_added = (int) $data['draw'];
								break;

							case 'lost':
								$points_added = (int) $data['loss'];
								break;
						}

						$table[ $home_club ]['points'] = $table[ $home_club ]['points'] - $points_added + absint( $home_points );
					}
				}

				if ( 'home' !== $type ) {
					$away_outcome = get_post_meta( $custom_outcome, '_anwpfl_outcome_away', true );
					$away_points  = get_post_meta( $custom_outcome, '_anwpfl_outcome_points_away', true );

					if ( isset( $table[ $away_club ]['points'] ) ) {

						$points_added = (int) $data['win'];

						switch ( $away_outcome ) {
							case 'drawn':
								$points_added = (int) $data['draw'];
								break;

							case 'lost':
								$points_added = (int) $data['loss'];
								break;
						}

						$table[ $away_club ]['points'] = $table[ $away_club ]['points'] - $points_added + absint( $away_points );
					}
				}
			}
		}

		/*
		|--------------------------------------------------------------------
		| Add initial points
		|--------------------------------------------------------------------
		*/
		if ( $data['points_initial'] ) {
			$initial = json_decode( wp_unslash( $data['points_initial'] ) );

			if ( ! empty( $initial ) && is_object( $initial ) ) {
				foreach ( $initial as $club_id => $points_to_add ) {
					$table[ $club_id ]['points'] = $table[ $club_id ]['points'] + (int) $points_to_add;
				}
			}
		}

		/*
		|--------------------------------------------------------------------
		| Check Initial Table Data
		|--------------------------------------------------------------------
		*/
		$is_initial_data_active = $data['is_initial_data_active'] ?? ( $this->get_row( (int) $data['id'] )['is_initial_data_active'] ?? '' );

		if ( AnWP_Football_Leagues::string_to_bool( $is_initial_data_active ) ) {
			$standing_row = $this->get_row( (int) $data['id'] );
			$initial_data = json_decode( $standing_row['table_initial'] ?? '' );

			$table_fields = [
				'played',
				'won',
				'drawn',
				'lost',
				'gf',
				'ga',
				'gd',
				'points',
			];

			foreach ( ( $initial_data ?: [] ) as $row_club_id => $data_row ) {
				foreach ( $table_fields as $table_field ) {
					if ( isset( $table[ $row_club_id ][ $table_field ] ) && ! empty( $data_row->{$table_field} ) ) {
						$table[ $row_club_id ][ $table_field ] += (int) $data_row->{$table_field};
					}
				}
			}
		}

		/*
		|--------------------------------------------------------------------
		| Ordering (non-manual). Manual-ordering override is the caller's job.
		|--------------------------------------------------------------------
		*/
		if ( count( $table ) ) {

			/**
			 * Filter: anwpfl/standing/custom_position_calculation
			 *
			 * @since 0.7.5
			 *
			 * @param bool
			 * @param array $table
			 * @param array $data
			 */
			if ( apply_filters( 'anwpfl/standing/custom_position_calculation', false, $table, $data ) ) {

				/**
				 * Filter: anwpfl/standing/custom_position_calculation_table
				 *
				 * @since 0.7.5
				 *
				 * @param array $table
				 * @param array $data
				 * @param array $matches
				 */
				$table = apply_filters( 'anwpfl/standing/custom_position_calculation_table', $table, $data, $matches );
			} else {
				$points     = [];
				$won        = [];
				$gd         = [];
				$gf         = [];
				$team_title = [];

				foreach ( $table as $key => $row ) {
					$points[ $key ]     = $row['points'];
					$won[ $key ]        = $row['won'];
					$gd[ $key ]         = $row['gd'];
					$gf[ $key ]         = $row['gf'];
					$team_title[ $key ] = $row['club_title'] ?? '';
				}

				$sort_order = [ $points, SORT_DESC ];

				if ( $data['ranking_rules'] ) {
					foreach ( explode( ',', $data['ranking_rules'] ) as $rule ) {
						switch ( $rule ) {

							case 'wins':
								$sort_order[] = $won;
								$sort_order[] = SORT_DESC;
								break;

							case 'goals_scored':
								$sort_order[] = $gf;
								$sort_order[] = SORT_DESC;
								break;

							case 'goals_difference':
								$sort_order[] = $gd;
								$sort_order[] = SORT_DESC;
								break;
						}
					}
				}

				$sort_order[] = $team_title;
				$sort_order[] = SORT_ASC;

				$sort_order[] = &$table;
				call_user_func_array( 'array_multisort', $sort_order );
			}
		}

		// Set Place field.
		$place_counter = 1;
		foreach ( $table as $index => $row ) {
			$table[ $index ]['place'] = $place_counter ++;
		}

		/**
		 * Filter: anwpfl/standing/post_compute_table
		 *
		 * Fires after ordering and place assignment, before the table is returned.
		 * Lets addons compute derived/aggregate columns that need the final shape
		 * (form strings, points-per-game, win percentage, etc.).
		 *
		 * @since 0.18.0
		 *
		 * @param array $table   Ordered standing table.
		 * @param array $matches Raw match rows used to build the table.
		 * @param array $data    Standing config.
		 */
		$table = apply_filters( 'anwpfl/standing/post_compute_table', $table, $matches, $data );

		return [
			'table'   => $table,
			'matches' => $matches,
		];
	}

	/**
	 * Recalculate Standing Table
	 *
	 * @param array $data
	 *
	 * @since 0.3.0 (2018-01-31)
	 */
	private function calculate_standing( $data ) {

		/**
		 * Filter: anwpfl/standing/calculate_standing
		 *
		 * @since 0.7.5
		 *
		 * @param bool
		 * @param int $standing_id
		 * @param int $competition_id
		 * @param int $group_id
		 */
		if ( ! apply_filters( 'anwpfl/standing/calculate_standing', true, $data['id'], $data['competition'], $data['group'] ) ) {
			return;
		}

		$result = $this->compute_standing_table( $data, 'all', '' );

		$table   = $result['table'];
		$matches = $result['matches'];

		/*
		|--------------------------------------------------------------------
		| Manual-ordering override.
		|
		| When manual_ordering is enabled, preserve places from the previously
		| stored table_main. Reads from the standings table — a side effect
		| that's why this lives here, not in compute_standing_table().
		|--------------------------------------------------------------------
		*/
		if ( count( $table ) && 'true' === $data['manual_ordering'] ) {

			$standing_row = $this->get_row( (int) $data['id'] );
			$table_old    = json_decode( $standing_row['table_main'] ?? '' );

			if ( is_array( $table_old ) && count( $table_old ) ) {

				$places = [];

				$table_by_club = [];
				foreach ( $table as $row ) {
					$table_by_club[ $row['club_id'] ] = $row;
				}

				foreach ( $table_old as $row ) {
					if ( isset( $table_by_club[ $row->club_id ] ) ) {
						$table_by_club[ $row->club_id ]['place'] = $row->place;
					}
				}

				$table = array_values( $table_by_club );

				foreach ( $table as $key => $row ) {
					$places[ $key ] = $row['place'];
				}

				array_multisort( $places, SORT_ASC, $table );
			}

			// Re-number place 1..N after manual sort.
			$place_counter = 1;
			foreach ( $table as $index => $row ) {
				$table[ $index ]['place'] = $place_counter ++;
			}
		}

		/**
		 * Trigger before save standing data.
		 *
		 * @param array $data
		 * @param array $table
		 * @param array $matches
		 *
		 * @since 0.14.4
		 */
		do_action( 'anwpfl/standing-calculating/before_save', $data, $table, $matches );

		/*
		|--------------------------------------------------------------------
		| Save table_main / last_recalc / last_round to anwpfl_standings.
		|
		| competition_id / group_id are included in the INSERT payload (for
		| the case where a match save triggers calculate_standing before the
		| standing itself has been re-saved — without them, the fresh INSERT
		| gets MySQL column defaults of 0) but NOT in $update_columns, so
		| existing rows keep their competition binding from save_metabox().
		|
		| last_round is included ONLY when $matches is non-empty — an
		| empty-match recalc must not zero out a previously-stored last_round.
		|--------------------------------------------------------------------
		*/
		$upsert_data = [
			'competition_id' => (int) $data['competition'],
			'group_id'       => (int) $data['group'],
			'table_main'     => wp_json_encode( $table ),
			'last_recalc'    => current_time( 'mysql', true ),
		];

		$update_columns = [ 'table_main', 'last_recalc' ];

		if ( ! empty( $matches ) && ! empty( $matches[ count( $matches ) - 1 ] ) ) {
			$upsert_data['last_round'] = (int) $matches[ count( $matches ) - 1 ]->match_week;
			$update_columns[]          = 'last_round';
		}

		$this->upsert( (int) $data['id'], $upsert_data, $update_columns );

		// Invalidate standings cache. calculate_standing() runs on match save (not standing CPT save),
		// so the on_modify_post hook never fires for the standing post and FL-STANDINGS-LIST stays stale.
		anwp_fl()->cache->delete( 'FL-STANDINGS-LIST' );

		// Table recalculated notice
		$notice_text = sprintf( 'Standing Table "%s" has been successfully recalculated.', get_the_title( $data['id'] ) );
		set_transient( 'anwp-admin-standing-recalculated', $notice_text, 10 );

		/**
		 * Trigger after save standing data.
		 *
		 * @param array $data
		 * @param array $table
		 * @param array $matches
		 *
		 * @since 0.14.4
		 */
		do_action( 'anwpfl/standing-calculating/after_save', $data, $table, $matches );
	}

	/**
	 * Display successful recalculated text.
	 *
	 * @since 0.5.1 (2018-03-19)
	 */
	public function display_admin_standing_notice() {

		if ( get_transient( 'anwp-admin-standing-recalculated' ) ) :
			?>
			<div class="notice notice-success is-dismissible anwp-visible-after-header">
				<p><?php echo esc_html( get_transient( 'anwp-admin-standing-recalculated' ) ); ?></p>
			</div>
			<?php
			delete_transient( 'anwp-admin-standing-recalculated' );
		endif;
	}

	/**
	 * Meta box initialization.
	 *
	 * @since  0.2.0 (2017-12-07)
	 */
	public function init_metaboxes() {
		add_action(
			'add_meta_boxes',
			function ( $post_type ) {

				if ( 'anwp_standing' === $post_type ) {
					add_meta_box(
						'anwpfl_standing',
						__( 'Standing Table', 'anwp-football-leagues' ),
						[ $this, 'render_metabox' ],
						$post_type,
						'normal',
						'high'
					);
				}
			}
		);
	}

	/**
	 * Render Meta Box content for Competition Stages.
	 *
	 * @param WP_Post $post The post object.
	 *
	 * @since  0.2.0 (2017-10-28)
	 */
	public function render_metabox( WP_Post $post ) {

		// Block render until standings migration is complete (0.18.0).
		// Even though save_metabox() writes directly to the custom table without a gate,
		// editing standings while clubs/competitions data still lives in postmeta creates
		// referential gray zones. Block edits until the full migration completes.
		if ( ! get_option( 'anwpfl_standings_migrated' ) ) {
			AnWP_Football_Leagues::print_migration_required_panel(
				__( 'Standings Data Migration Required', 'anwp-football-leagues' )
			);
			return;
		}

		// Add nonce for security and authentication.
		wp_nonce_field( 'anwp_save_metabox_' . $post->ID, 'anwp_metabox_nonce' );

		$app_id         = apply_filters( 'anwpfl/standing/vue_app_id', 'fl-app-standing' );
		$row            = $this->get_row( $post->ID );
		$competition_id = $row ? absint( $row['competition_id'] ) : 0;
		$fixed          = $competition_id > 0;
		$group_id       = $fixed ? $row['group_id'] : 0;

		?>
		<div class="anwp-b-wrap anwpfl-standing-metabox-wrapper">
			<?php if ( $fixed ) : ?>
				<div class="mb-2 border border-success bg-light px-2 py-2 d-flex align-items-center flex-wrap">
					<?php
					$competition_obj = anwp_football_leagues()->competition->get_competition( $competition_id );

					if ( ! $competition_obj ) {
						echo '<span class="text-danger">' . esc_html__( 'Competition not found', 'anwp-football-leagues' ) . '</span>';
						return;
					}

					$groups = $competition_obj->groups;

					// get_competition() bulk-loads stage_groups from the custom table,
					// which may be empty for competitions not re-saved after migration
					// (row exists but stage_groups = '[]'). Fall back to postmeta.
					if ( empty( $groups ) ) {
						$groups = json_decode( (string) get_post_meta( $competition_id, '_anwpfl_groups', true ) );
					}

					$group_data = [];

					if ( absint( $group_id ) && ! empty( $groups ) && is_array( $groups ) ) :
						foreach ( $groups as $index => $group ) :
							if ( absint( $group->id ) === absint( $group_id ) ) :
								$group_data['title'] = $group->title ?: 'Group #' . ( $index + 1 );
								$group_data['clubs'] = $group->clubs;
								break;
							endif;
						endforeach;
					endif;
					?>

					<?php if ( $competition_obj->logo ) : ?>
						<img class="anwp-object-contain anwp-w-30 anwp-h-30 mr-2" src="<?php echo esc_attr( $competition_obj->logo ); ?>" alt="competition logo">
					<?php endif; ?>

					<b class="mr-1"><?php echo esc_html__( 'Competition', 'anwp-football-leagues' ); ?>:</b>
					<span><?php echo esc_html( $competition_obj->title_full ); ?></span>

					<?php if ( ! empty( $group_data['title'] ) ) : ?>
						<span class="text-muted small mx-2">|</span>
						<span><?php echo esc_html( $group_data['title'] ); ?></span>
					<?php endif; ?>

					<span class="text-muted small mx-2">|</span>
					<b class="mr-1"><?php echo esc_html__( 'League', 'anwp-football-leagues' ); ?>:</b>
					<span><?php echo esc_html( $competition_obj->league_text ); ?></span>

					<span class="text-muted small mx-2">|</span>
					<b class="mr-1"><?php echo esc_html__( 'Season', 'anwp-football-leagues' ); ?>:</b>
					<span><?php echo esc_html( $competition_obj->season_text ); ?></span>

					<a target="_blank" href="<?php echo esc_url( admin_url( 'post.php?post=' . $competition_obj->id . '&action=edit' ) ); ?>" class="button button-secondary ml-auto"><?php echo esc_html__( 'Edit Competition', 'anwp-football-leagues' ); ?></a>
				</div>

				<?php if ( ! empty( $row['last_recalc'] ) ) : ?>
					<div class="my-0 text-right">Last Recalculated: <?php echo esc_html( $row['last_recalc'] ); ?></div>
				<?php endif; ?>

				<?php if ( empty( $group_data['clubs'] ) && empty( $row['table_lazy'] ) ) : ?>
					<div class="alert alert-warning border-warning d-flex align-items-center" role="alert">
						<svg class="anwp-icon mr-2 anwp-icon--octi anwp-icon--s16">
							<use href="#icon-alert"></use>
						</svg>
						<?php echo esc_html__( "Selected Standing group doesn't have any club. Please add clubs to the Competition group first.", 'anwp-football-leagues' ); ?>
					</div>
				<?php else : ?>

					<?php if ( ! empty( $row['table_lazy'] ) ) : ?>
						<div class="alert alert-info border-info d-flex align-items-center mb-3" role="alert">
							<svg class="anwp-icon mr-2 anwp-icon--octi anwp-icon--s16">
								<use href="#icon-info"></use>
							</svg>
							<?php echo esc_html__( 'Lazy Import Standing. Data is managed via API import.', 'anwp-football-leagues' ); ?>
						</div>
					<?php endif; ?>

					<?php
					$group_clubs = [];

					foreach ( $group_data['clubs'] ?? [] as $club_id ) {
						$club_obj = anwp_fl()->club->get_club( $club_id );

						$group_clubs[ $club_id ] = [
							'title' => $club_obj->title,
							'logo'  => $club_obj->logo,
							'id'    => $club_id,
						];
					}

					$standing_l10n = [
						'add_initial_points'          => esc_html__( 'Add Initial Points', 'anwp-football-leagues' ),
						'add_remove_color'            => esc_html__( 'add color', 'anwp-football-leagues' ),
						'add_remove_points'           => esc_html__( 'add points', 'anwp-football-leagues' ),
						'add_table_color'             => esc_html__( 'Add Table Color', 'anwp-football-leagues' ),
						'automatic_pos_calculation'   => esc_html__( 'Automatic Position Calculation', 'anwp-football-leagues' ),
						'available_placeholders'      => esc_html__( 'Available placeholders', 'anwp-football-leagues' ),
						'available_rules'             => esc_html__( 'Available rules', 'anwp-football-leagues' ),
						'close'                       => esc_html__( 'Close', 'anwp-football-leagues' ),
						'club'                        => esc_html__( 'Club', 'anwp-football-leagues' ),
						'club_initial_points'         => esc_html__( 'Club initial points', 'anwp-football-leagues' ),
						'color'                       => esc_html__( 'Color', 'anwp-football-leagues' ),
						'color_by'                    => esc_html__( 'Color by', 'anwp-football-leagues' ),
						'current_ranking_rules'       => esc_html__( 'Current standing ranking rules', 'anwp-football-leagues' ),
						'custom'                      => esc_html__( 'custom', 'anwp-football-leagues' ),
						'disable'                     => esc_html__( 'Disable', 'anwp-football-leagues' ),
						'drawn'                       => esc_html__( 'Drawn', 'anwp-football-leagues' ),
						'enable'                      => esc_html__( 'Enable', 'anwp-football-leagues' ),
						'ga'                          => esc_html_x( 'GA', 'table - goals against', 'anwp-football-leagues' ),
						'gd'                          => esc_html_x( 'GD', 'table - goals diff', 'anwp-football-leagues' ),
						'gf'                          => esc_html_x( 'GF', 'table - goals for', 'anwp-football-leagues' ),
						'goals_difference'            => esc_html__( 'Goals Difference', 'anwp-football-leagues' ),
						'goals_scored'                => esc_html__( 'Goals Scored', 'anwp-football-leagues' ),
						'initial_points'              => esc_html__( 'Initial points', 'anwp-football-leagues' ),
						'initial_standing_table_data' => esc_html__( 'Initial Standing Table Data', 'anwp-football-leagues' ),
						'Load_data'                   => esc_html__( 'Load Data', 'anwp-football-leagues' ),
						'load_initial_data_another'   => esc_html__( 'Load initial data from another Standing table', 'anwp-football-leagues' ),
						'lost'                        => esc_html__( 'Lost', 'anwp-football-leagues' ),
						'not_specified'               => esc_html__( 'not specified', 'anwp-football-leagues' ),
						'notes_below_table'           => esc_html__( 'Notes (below table)', 'anwp-football-leagues' ),
						'place'                       => esc_html__( 'Place', 'anwp-football-leagues' ),
						'played'                      => esc_html__( 'Played', 'anwp-football-leagues' ),
						'points'                      => esc_html__( 'Points', 'anwp-football-leagues' ),
						'points_for_a_draw'           => esc_html__( 'Points for a draw', 'anwp-football-leagues' ),
						'points_for_a_loss'           => esc_html__( 'Points for a loss', 'anwp-football-leagues' ),
						'points_for_a_win'            => esc_html__( 'Points for a win', 'anwp-football-leagues' ),
						'predefined'                  => esc_html__( 'predefined', 'anwp-football-leagues' ),
						'ranking_rules'               => esc_html__( 'Ranking Rules', 'anwp-football-leagues' ),
						'ranking_rules_notes_1'       => esc_html__( 'Ranking rules are used to determine the position of the team in the Standing Table (from top to bottom).', 'anwp-football-leagues' ),
						'ranking_rules_notes_2'       => esc_html__( 'Click on arrows to change ranking rules order.', 'anwp-football-leagues' ),
						'save_changes'                => esc_html__( 'Save changes', 'anwp-football-leagues' ),
						'select_club'                 => esc_html__( 'Select club', 'anwp-football-leagues' ),
						'select_rules_from_list'      => esc_html__( 'Select appropriate rules from the list below.', 'anwp-football-leagues' ),
						'standing_table'              => esc_html__( 'Standing Table', 'anwp-football-leagues' ),
						'standing_table_colors'       => esc_html__( 'Standing Table Colors', 'anwp-football-leagues' ),
						'standing_table_not_selected' => esc_html__( 'Standing Table Is Not Selected', 'anwp-football-leagues' ),
						'wins'                        => esc_html__( 'Wins', 'anwp-football-leagues' ),
						'won'                         => esc_html__( 'Won', 'anwp-football-leagues' ),
					];

					$standing_data = [
						'manualOrdering'         => ! empty( $row['manual_ordering'] ) ? 'yes' : '',
						'competition'            => $row['competition_id'] ?? '',
						'tableNotes'             => $row['table_notes'] ?? '',
						'competitionGroup'       => $row['group_id'] ?? '',
						'pointsWin'              => $row['points_win'] ?? '',
						'pointsDraw'             => $row['points_draw'] ?? '',
						'pointsLoss'             => $row['points_loss'] ?? '',
						'rankingRulesCurrent'    => $row['ranking_rules'] ?? '',
						'pointsInitial'          => $row['points_initial'] ?? '',
						'tableColors'            => $row['table_colors'] ?? '',
						'tableMain'              => $row['table_main'] ?? '',
						'is_initial_data_active' => (bool) ( $row['is_initial_data_active'] ?? 0 ),
						'table_initial'          => json_decode( $row['table_initial'] ?? '' ) ?: (object) [],
						'l10n'                   => anwp_fl()->helper->entity_decode( $standing_l10n ),
						'optionStandings'        => $this->get_standing_list(),
						'standing_id'            => absint( $post->ID ),
						'spinner_url'            => admin_url( 'images/spinner.gif' ),
						'rest_root'              => esc_url_raw( rest_url() ),
						'rest_nonce'             => wp_create_nonce( 'wp_rest' ),
					];

					/**
					 * Filter Standing Data.
					 *
					 * @param array $data    Standing data
					 * @param int   $post_id Standing ID
					 *
					 * @since 0.7.5
					 */
					$standing_data = apply_filters( 'anwpfl/standing/data_to_admin_vue', $standing_data, $post->ID );
					?>
					<?php if ( empty( $row['table_lazy'] ) ) : ?>
					<div class="anwp-border anwp-border-gray-500 mb-4">
						<div class="anwp-border-bottom anwp-border-gray-500 bg-white d-flex align-items-center px-3 py-2 anwp-text-gray-700 anwp-font-semibold">
							<svg class="anwp-icon anwp-icon--s16 mr-2 anwp-icon--octi anwp-fill-current">
								<use href="#icon-database"></use>
							</svg>

							<?php echo esc_html__( 'Clubs', 'anwp-football-leagues' ); ?> (<?php echo count( $group_data['clubs'] ?? [] ); ?>)
						</div>

						<div class="bg-white pt-3 pl-3 d-flex flex-wrap">
							<?php foreach ( $group_clubs as $club ) : ?>
								<div class="d-flex align-items-center mr-3 anwp-bg-gray-100 anwp-border anwp-border-gray-300 p-2 mb-3">
									<?php if ( $club['logo'] ) : ?>
										<img class="anwp-object-contain anwp-w-25 anwp-h-25 mr-2" src="<?php echo esc_url( $club['logo'] ); ?>" alt="club logo">
									<?php endif; ?>
									<span class="anwp-text-sm"><?php echo esc_html( $club['title'] ); ?></span>
								</div>
							<?php endforeach; ?>
						</div>
						<div class="bg-light px-3 py-2 d-flex align-items-center anwp-border anwp-border-top anwp-border-gray-400">
							<svg class="anwp-icon anwp-icon--octi anwp-icon--s16 mr-2">
								<use href="#icon-info"></use>
							</svg>
							<?php echo esc_html__( 'You can add or remove clubs only on Competition page', 'anwp-football-leagues' ); ?>
						</div>
					</div>
					<?php endif; ?>

					<script type="text/javascript">
						window._AnWP_FL_Clubs = <?php echo wp_json_encode( $group_clubs ); ?>;
						window._AnWP_FL_Standing_Data = <?php echo wp_json_encode( $standing_data ); ?>;
					</script>

					<div id="<?php echo esc_attr( $app_id ); ?>"></div>
					<?php
				endif;
			else :
				$available_competitions = anwp_football_leagues()->competition->get_competition_groups_without_standing();

				if ( empty( $available_competitions ) ) :
					?>
					<div class="anwp-b-wrap anwpfl-standing-metabox-wrapper">
						<div class="alert alert-warning border-warning d-flex align-items-center" role="alert">
							<svg class="anwp-icon mr-2 anwp-icon--octi anwp-icon--s16">
								<use href="#icon-alert"></use>
							</svg>
							<?php echo esc_html__( 'There are no available "Round-Robin" competitions.', 'anwp-football-leagues' ); ?>
						</div>
					</div>
					<?php
				else :
					/*
					|--------------------------------------------------------------------
					| Standing Setup
					|--------------------------------------------------------------------
					*/

					$standing_l10n = [
						'select_competition_group'         => __( 'Select Competition Group', 'anwp-football-leagues' ),
						'competition'                      => __( 'Competition', 'anwp-football-leagues' ),
						'group'                            => __( 'Group', 'anwp-football-leagues' ),
						'save_continue'                    => __( 'Save & Continue', 'anwp-football-leagues' ),
						'some_options_will_be_copied_from' => __( 'Some options will be copied from', 'anwp-football-leagues' ),
					];

					$setup_data = [
						'optionsClub' => $this->plugin->club->get_clubs_list(),
						'cloned'      => '',
						'l10n'        => anwp_fl()->helper->entity_decode( $standing_l10n ),
					];
					?>
					<script type="text/javascript">
						window._AnWP_FL_Competitions = <?php echo wp_json_encode( $available_competitions ); ?>;
						window._AnWP_FL_Standing_Setup = <?php echo wp_json_encode( $setup_data ); ?>;
					</script>

					<div id="<?php echo esc_attr( apply_filters( 'anwpfl/standing/vue_app_setup_id', 'fl-app-standing-setup' ) ); ?>"></div>
				<?php endif; ?>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Save the meta when the post is saved.
	 *
	 * @param int $post_id The ID of the post being saved.
	 *
	 * @since  0.2.0 (2017-12-10)
	 * @return bool|int
	 */
	public function save_metabox( $post_id ) {

		// Block saves until standings migration is complete (0.18.0).
		// Mirrors the render gate; defensive in case a third-party plugin POSTs to the edit screen.
		if ( ! get_option( 'anwpfl_standings_migrated' ) ) {
			return $post_id;
		}

		/*
		 * We need to verify this came from the our screen and with proper authorization,
		 * because save_post can be triggered at other times.
		 */

		// Check if our nonce is set.
		if ( ! isset( $_POST['anwp_metabox_nonce'] ) ) {
			return $post_id;
		}

		// Verify that the nonce is valid.
		if ( ! wp_verify_nonce( $_POST['anwp_metabox_nonce'], 'anwp_save_metabox_' . $post_id ) ) {
			return $post_id;
		}

		// Check post type
		if ( 'anwp_standing' !== $_POST['post_type'] ) {
			return $post_id;
		}

		/*
		 * If this is an autosave, our form has not been submitted,
		 * so we don't want to do anything.
		 */
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return $post_id;
		}

		// Check the user's permissions.
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return $post_id;
		}

		// check if there was a multisite switch before
		if ( is_multisite() && ms_is_switched() ) {
			return $post_id;
		}

		/* OK, it's safe for us to save the data now. */

		/** ---------------------------------------
		 * Save Standing Data
		 *
		 * @since 0.2.0
		 * ---------------------------------------*/

		$post_data = wp_unslash( $_POST );
		$fixed     = isset( $post_data['_anwpfl_fixed'] ) ? sanitize_key( $post_data['_anwpfl_fixed'] ) : '';

		if ( isset( $post_data['_anwpfl_fixed_setup'] ) && 'yes' === $post_data['_anwpfl_fixed_setup'] ) {
			$competition       = isset( $post_data['_anwpfl_competition'] ) ? absint( $post_data['_anwpfl_competition'] ) : '';
			$competition_group = isset( $post_data['_anwpfl_competition_group'] ) ? absint( $post_data['_anwpfl_competition_group'] ) : '';

			if ( $competition && $competition_group ) {
				/*
				|--------------------------------------------------------------------
				| Fixed-setup: create the standing row in anwpfl_standings.
				| Row existence IS the "fixed" flag — no postmeta needed.
				|--------------------------------------------------------------------
				*/
				$this->upsert(
					$post_id,
					[
						'competition_id' => $competition,
						'group_id'       => $competition_group,
						'points_win'     => 3,
						'points_draw'    => 1,
						'points_loss'    => 0,
					],
					[ 'competition_id', 'group_id', 'points_win', 'points_draw', 'points_loss' ]
				);
			}
		} elseif ( 'true' === $fixed ) {

			// Re-encode for calculate_standing() call below.
			$point_initial = wp_json_encode( json_decode( $post_data['_anwpfl_points_initial'] ) );

			// phpcs:disable WordPress.NamingConventions

			/*
			|--------------------------------------------------------------------
			| Handle General Data — build $data array for filter/action hooks.
			| Competition/group sourced from custom table (set during fixed-setup).
			|--------------------------------------------------------------------
			*/
			$data = [];

			$standing_row = $this->get_row( $post_id );

			$data['_anwpfl_table_notes']       = wp_kses_post( $post_data['_anwpfl_table_notes'] );
			$data['_anwpfl_competition']       = (int) ( $standing_row['competition_id'] ?? 0 );
			$data['_anwpfl_competition_group'] = (int) ( $standing_row['group_id'] ?? 0 );
			$data['_anwpfl_points_win']        = (int) $post_data['_anwpfl_points_win'];
			$data['_anwpfl_points_draw']       = (int) $post_data['_anwpfl_points_draw'];
			$data['_anwpfl_points_loss']       = (int) $post_data['_anwpfl_points_loss'];

			$data['_anwpfl_ranking_rules_current'] = sanitize_text_field( $post_data['_anwpfl_ranking_rules_current'] );
			$data['_anwpfl_manual_ordering']       = sanitize_key( $post_data['_anwpfl_manual_ordering'] );

			/**
			 * Filter Standing Data before save
			 *
			 * @param array $data    Match data
			 * @param int   $post_id Standing ID
			 * @param array $post_data
			 *
			 * @since 0.7.5
			 */
			$data = apply_filters( 'anwpfl/standing/data_to_save', $data, $post_id, $post_data );

			/**
			 * Trigger on save standing data.
			 *
			 * @param array $data    Standing data
			 * @param int   $post_id Standing ID
			 *
			 * @since 0.7.5
			 */
			do_action( 'anwpfl/standing/on_save', $data, $post_id );

			/*
			|--------------------------------------------------------------------
			| Upsert core columns into anwpfl_standings.
			|
			| Placed AFTER the data_to_save filter (so premium has already
			| appended its keys to $data) and AFTER the on_save action (so
			| remove_series_on_manual_edit has already run its own upsert of
			| table_main for manual_filling standings) and BEFORE
			| calculate_standing() (which has its own upsert of table_main /
			| last_recalc / last_round for non-manual standings).
			|
			| JSON columns are validated via validate_json_column() to catch
			| the "null" literal corruption from the re-encode dance.
			|--------------------------------------------------------------------
			*/
			$is_initial_active = isset( $post_data['_anwpfl_is_initial_data_active'] )
				&& AnWP_Football_Leagues::string_to_bool( $post_data['_anwpfl_is_initial_data_active'] );

			$upsert_data = [
				'competition_id'         => (int) $data['_anwpfl_competition'],
				'group_id'               => (int) $data['_anwpfl_competition_group'],
				'points_win'             => (int) $data['_anwpfl_points_win'],
				'points_draw'            => (int) $data['_anwpfl_points_draw'],
				'points_loss'            => (int) $data['_anwpfl_points_loss'],
				'ranking_rules'          => (string) $data['_anwpfl_ranking_rules_current'],
				'manual_ordering'        => (int) ( 'true' === $data['_anwpfl_manual_ordering'] ),
				'is_initial_data_active' => (int) $is_initial_active,
				'table_notes'            => (string) $data['_anwpfl_table_notes'],
			];

			// JSON blob columns — validate before including so malformed POST
			// doesn't persist the literal string "null".
			$json_cols = [
				'table_colors'   => [ $post_data['_anwpfl_table_colors'] ?? '', 'object' ],
				'points_initial' => [ $post_data['_anwpfl_points_initial'] ?? '', 'object' ],
				'table_main'     => [ $post_data['_anwpfl_table_main'] ?? '', 'array' ],
			];

			foreach ( $json_cols as $col => $config ) {
				$validated = $this->validate_json_column( $config[0], $post_id, $col, $config[1] );
				if ( null !== $validated ) {
					$upsert_data[ $col ] = $validated;
				}
			}

			// table_initial is conditional on is_initial_data_active — cleared when inactive.
			if ( $is_initial_active ) {
				$table_initial_validated = $this->validate_json_column(
					$post_data['_anwpfl_table_initial'] ?? '',
					$post_id,
					'table_initial',
					'object'
				);
				if ( null !== $table_initial_validated ) {
					$upsert_data['table_initial'] = $table_initial_validated;
				}
			} else {
				$upsert_data['table_initial'] = '';
			}

			$this->upsert( $post_id, $upsert_data, array_keys( $upsert_data ) );

			// Recalculate standing
			$standing_data = [
				'id'              => $post_id,
				'competition'     => $data['_anwpfl_competition'],
				'group'           => $data['_anwpfl_competition_group'],
				'points_initial'  => $point_initial,
				'win'             => $data['_anwpfl_points_win'],
				'draw'            => $data['_anwpfl_points_draw'],
				'loss'            => $data['_anwpfl_points_loss'],
				'ranking_rules'   => $data['_anwpfl_ranking_rules_current'],
				'manual_ordering' => $data['_anwpfl_manual_ordering'],
			];

			$this->calculate_standing( $standing_data );
		}

		// Generate title if empty
		if ( isset( $post_data['post_title'] ) && '' === $post_data['post_title'] ) {

			$standing_title = $this->generate_title( $post_id );

			if ( $standing_title ) {
				remove_action( 'save_post', [ $this, 'save_metabox' ] );

				// update the post, which calls save_post again
				wp_update_post(
					[
						'ID'         => $post_id,
						'post_title' => $standing_title,
					]
				);

				// re-hook this function
				add_action( 'save_post', [ $this, 'save_metabox' ] );
			}
		}

		return $post_id;
	}

	/**
	 * Registers admin columns to display. Hooked in via CPT_Core.
	 *
	 * @param array $columns Array of registered column names/labels.
	 *
	 * @return array          Modified array.
	 */
	public function sortable_columns( $sortable_columns ) {

		return array_merge( $sortable_columns, [ 'standing_id' => 'ID' ] );
	}

	/**
	 * Registers admin columns to display. Hooked in via CPT_Core.
	 *
	 * @since  0.2.0
	 *
	 * @param  array $columns Array of registered column names/labels.
	 * @return array          Modified array.
	 */
	public function columns( $columns ) {
		// Add new columns
		$new_columns = [
			'anwpfl_competition' => esc_html__( 'Competition', 'anwp-football-leagues' ),
			'anwpfl_group'       => esc_html__( 'Group', 'anwp-football-leagues' ),
			'standing_id'        => esc_html__( 'ID', 'anwp-football-leagues' ),
		];

		// Merge old and new columns
		$columns = array_merge( $new_columns, $columns );

		// Change columns order
		$new_columns_order = [
			'cb',
			'title',
			'anwpfl_competition',
			'anwpfl_group',
			'date',
			'standing_id',
		];
		$new_columns       = [];

		foreach ( $new_columns_order as $c ) {

			if ( isset( $columns[ $c ] ) ) {
				$new_columns[ $c ] = $columns[ $c ];
			}
		}

		return $new_columns;
	}

	/**
	 * Handles admin column display. Hooked in via CPT_Core.
	 *
	 * @since  0.2.0
	 *
	 * @param array   $column   Column currently being rendered.
	 * @param integer $post_id  ID of post to display column for.
	 */
	/**
	 * Bulk-warm standing rows into the per-request cache.
	 *
	 * Replaces N per-row get_row() queries on the admin standing list with
	 * one SELECT IN. After warming, the columns_display dispatch hits cache.
	 *
	 * @since 0.18.2
	 *
	 * @param int[] $standing_ids Standing post IDs.
	 */
	public function warm_standings_full( array $standing_ids ): void {
		$standing_ids = array_unique( array_filter( array_map( 'absint', $standing_ids ) ) );

		if ( empty( $standing_ids ) ) {
			return;
		}

		$rows = $this->fetch_rows( $standing_ids );

		foreach ( $rows as $row ) {
			$this->cache_row( (int) $row['standing_id'], $row );
		}
	}

	public function columns_display( $column, $post_id ) {

		$standing_row = $this->get_row( $post_id );

		switch ( $column ) {
			case 'anwpfl_competition':
				$competition_id  = (int) ( $standing_row['competition_id'] ?? 0 );
				$competition_row = $competition_id ? anwp_fl()->competition->get_competition_list_row( $competition_id ) : null;

				if ( $competition_row ) {
					echo esc_html( $competition_row['title'] ?? '' );

					if ( '' !== ( $competition_row['multistage'] ?? '' ) && ( $competition_row['stage_title'] ?? '' ) ) {
						echo '<br>' . esc_html( $competition_row['stage_title'] );
					}
				}

				break;

			case 'anwpfl_group':
				$competition_id = (int) ( $standing_row['competition_id'] ?? 0 );

				$group_id = (int) ( $standing_row['group_id'] ?? 0 );

				// Get competition title
				$competition_row = $competition_id ? anwp_fl()->competition->get_row( $competition_id ) : null;
				$groups          = $competition_row ? json_decode( $competition_row['stage_groups'] ?? '' ) : null;

				if ( ! empty( $groups ) && is_array( $groups ) ) {
					foreach ( $groups as $index => $group ) {

						if ( absint( $group->id ) === $group_id ) {
							$title = $group->title ? : 'Group #' . ( $index + 1 );
							echo esc_html( $title );
						}
					}
				}

				break;

			case 'standing_id':
				echo (int) $post_id;
				break;

		}
	}

	/**
	 * Helper function, returns standings with id and title.
	 *
	 * @since 0.10.8
	 * @return array $output_data
	 */
	public function get_standing_options() {

		static $options = null;

		if ( null === $options ) {

			$options = [];

			foreach ( $this->get_standings() as $standing ) {
				$options[ $standing['id'] ] = $standing['title'];
			}

			asort( $options );
		}

		return $options;
	}

	/**
	 * Helper function, returns standings with id and title.
	 *
	 * @since 0.14.4
	 * @return array $options
	 */
	public function get_standing_list() {

		static $options = null;

		if ( null === $options ) {

			$options = [];

			foreach ( $this->get_standings() as $standing ) {
				$options[] = [
					'id'    => $standing['id'],
					'title' => $standing['title'],
				];
			}
		}

		return $options;
	}

	/**
	 * Parse and prepare table notes to output.
	 *
	 * @param string $notes - Raw notes string
	 * @param array $table_colors
	 *
	 * @return string
	 * @since 0.10.11
	 */
	public function prepare_table_notes( $notes, $table_colors = [] ) {

		/*
		|--------------------------------------------------------------------
		| Predefined Colors
		|--------------------------------------------------------------------
		*/
		$replace_data = [ 'info', 'warning', 'danger', 'primary', 'secondary', 'success' ];

		foreach ( $replace_data as $replace ) {
			$notes = str_ireplace( '%' . $replace . '%', '<span class="anwp-border anwp-border-light mr-1 px-3 anwp-bg-' . $replace . '-light"></span>', $notes );
		}

		/*
		|--------------------------------------------------------------------
		| Custom Colors
		|--------------------------------------------------------------------
		*/
		if ( ! empty( $table_colors ) && is_array( $table_colors ) ) {
			foreach ( $table_colors as $table_color ) {
				if ( ! str_starts_with( $table_color, '#' ) ) {
					continue;
				}

				$notes = str_ireplace( '%' . $table_color . '%', '<span class="anwp-border anwp-border-light mr-1 px-3" style="background-color: ' . esc_attr( $table_color ) . '"></span>', $notes );
			}
		}

		return nl2br( $notes );
	}

	/**
	 * Filter standing table partition data.
	 *
	 * @param array  $table
	 * @param string $partial
	 *
	 * @return array
	 * @since 0.10.23
	 */
	public function get_standing_partial_data( $table, $partial ) {

		$first = 0;
		$last  = 0;

		if ( ! strpos( $partial, '-', 1 ) && absint( $partial ) ) {
			$club_id = absint( $partial );

			foreach ( $table as $table_row ) {
				if ( $club_id === $table_row->club_id ) {

					$first = ( $table_row->place - 2 ) < 1 ? 1 : $table_row->place - 2;
					$last  = $table_row->place + 2;

					break;
				}
			}
		} elseif ( strpos( $partial, '-', 1 ) ) {
			$partial_arr = explode( '-', $partial );

			$first = absint( trim( $partial_arr[0] ) );

			if ( ! empty( $partial_arr[1] ) ) {
				$last = absint( trim( $partial_arr[1] ) );
			}
		}

		if ( empty( $first ) || empty( $last ) ) {
			return $table;
		}

		foreach ( $table as $row_index => $row_item ) {
			if ( $row_item->place < $first || $row_item->place > $last ) {
				unset( $table[ $row_index ] );
			}
		}

		return $table;
	}

	/**
	 * Generate Standing Table title
	 *
	 * @param int $standing_id
	 *
	 * @return string        Standing Table title
	 * @since 0.11.2
	 */
	public function generate_title( $standing_id ) {

		$standing_row   = $this->get_row( $standing_id );
		$competition_id = absint( $standing_row['competition_id'] ?? 0 );
		$group_id       = absint( $standing_row['group_id'] ?? 0 );

		// Get competition title
		$competition_row = $competition_id ? anwp_fl()->competition->get_row( $competition_id ) : null;
		$groups          = $competition_row ? json_decode( $competition_row['stage_groups'] ?? '' ) : null;

		$generated_title = get_the_title( $competition_id );

		if ( ! empty( $groups ) && is_array( $groups ) ) {
			foreach ( $groups as $index => $group ) {
				if ( absint( $group->id ) === absint( $group_id ) ) {
					$generated_title .= ' - ' . esc_html( $group->title ? : ( esc_html__( 'Group', 'anwp-football-leagues' ) . ' #' . ( $index + 1 ) ) );
				}
			}
		}

		return $generated_title;
	}

	/**
	 * Get Game Ids with Custom Outcome
	 *
	 * @param int $competition_id
	 * @param int $group_id
	 *
	 * @since 0.11.2
	 * @return array Array of game ids
	 */
	public function get_games_with_custom_outcome( int $competition_id, int $group_id ): array {
		global $wpdb;

		if ( ! absint( $competition_id ) || ! absint( $group_id ) ) {
			return [];
		}

		$has_custom_outcome = $wpdb->get_var(
			"
			SELECT COUNT(*)
			FROM $wpdb->postmeta
			WHERE meta_key = '_anwpfl_custom_outcome'
			"
		);

		if ( empty( $has_custom_outcome ) ) {
			return [];
		}

		// Get games with custom outcome
		$custom_query = "
		SELECT p.post_id
		FROM $wpdb->postmeta p
		INNER JOIN $wpdb->anwpfl_matches m ON m.match_id = p.post_id
		WHERE p.meta_key = '_anwpfl_custom_outcome' AND p.meta_value = 'yes' AND m.finished = 1
		";

		$custom_query .= $wpdb->prepare( ' AND m.competition_id = %d ', $competition_id );
		$custom_query .= $wpdb->prepare( ' AND m.group_id = %d ', $group_id );

		return $wpdb->get_col( $custom_query ); // phpcs:ignore WordPress.DB.PreparedSQL
	}

	/**
	 * Get list of Standings.
	 *
	 * @since 0.14.4
	 * @return array
	 */
	public function get_standings() {

		static $output_data = null;

		if ( null === $output_data ) {

			$cache_key = 'FL-STANDINGS-LIST';

			if ( anwp_fl()->cache->get( $cache_key ) ) {
				$output_data = anwp_fl()->cache->get( $cache_key );

				return $output_data;
			}

			global $wpdb;

			/*
			|--------------------------------------------------------------------
			| Get All Standings from anwpfl_standings custom table.
			|
			| Row existence IS the "fixed" flag — no _anwpfl_fixed check needed.
			| competition_id > 0 excludes draft clones from process_clone_standing().
			| Explicit column list avoids JSON blob columns (table_main etc.).
			|--------------------------------------------------------------------
			*/
			$output_data = [];

			$all_standings = $wpdb->get_results(
				"
				SELECT s.standing_id, s.competition_id, s.group_id, s.points_win, s.points_draw,
					s.points_loss, s.ranking_rules, s.manual_ordering, s.is_initial_data_active,
					s.table_notes, s.table_colors, s.points_initial, s.last_round,
					s.table_lazy, s.table_date_start, s.table_date_end,
					p.post_title
				FROM {$wpdb->prefix}anwpfl_standings s
				INNER JOIN {$wpdb->posts} p ON s.standing_id = p.ID
				WHERE p.post_status = 'publish'
					AND s.competition_id > 0
				ORDER BY s.standing_id
				"
			) ?: [];

			if ( empty( $all_standings ) ) {
				return [];
			}

			foreach ( $all_standings as $standing ) {
				$standing_id = (int) $standing->standing_id;

				$standing_data = [
					'id'                     => $standing_id,
					'title'                  => $standing->post_title,
					'table_notes'            => $standing->table_notes ?? '',
					'manual_ordering'        => ( (int) $standing->manual_ordering ) ? 'true' : '',
					'ranking_rules'          => $standing->ranking_rules ?? '',
					'points_initial'         => $standing->points_initial ?? '',
					'is_initial_data_active' => ( (int) $standing->is_initial_data_active ) ? '1' : '',
					'table_colors'           => $standing->table_colors ?? '',
					'last_round'             => absint( $standing->last_round ?? 0 ),
					'competition'            => absint( $standing->competition_id ?? 0 ),
					'group'                  => absint( $standing->group_id ?? 0 ),
					'win'                    => absint( $standing->points_win ?? 0 ),
					'draw'                   => absint( $standing->points_draw ?? 0 ),
					'loss'                   => absint( $standing->points_loss ?? 0 ),
				];

				/**
				 * Filter Cached Standing Data
				 *
				 * @param array  $standing_data
				 * @param int    $standing_id
				 * @param object $standing
				 *
				 * @since 0.14.4
				 */
				$output_data[ $standing_id ] = apply_filters( 'anwpfl/standing/data_to_cache', $standing_data, $standing_id, $standing );
			}

			/*
			|--------------------------------------------------------------------
			| Save transient
			|--------------------------------------------------------------------
			*/
			if ( ! empty( $output_data ) ) {
				anwp_fl()->cache->set( $cache_key, $output_data );
			}
		}

		return $output_data;
	}

	/**
	 * Get standing data.
	 *
	 * @param $id
	 *
	 * @return [ // <pre>
	 *        'id'                     => (int),
	 *        'title'                  => (string),
	 *        'table_notes'            => (string),
	 *        'manual_ordering'        => (string),
	 *        'ranking_rules'          => (string),
	 *        'points_initial'         => (string),
	 *        'is_initial_data_active' => (string),
	 *        'table_colors'           => (string),
	 *        'competition'            => (int),
	 *        'group'                  => (int),
	 *        'win'                    => (int),
	 *        'draw'                   => (int),
	 *        'loss'                   => (int),
	 * ]|bool
	 *
	 * @since 0.14.4
	 */
	public function get_standing( $id ) {
		$standing_obj = array_values( wp_list_filter( $this->get_standings(), [ 'id' => absint( $id ) ] ) );

		return empty( $standing_obj ) ? false : (object) $standing_obj[0];
	}

	/**
	 * Find standing config by competition + group via indexed query.
	 *
	 * Returns the same shape as get_standings() entries (with 'competition',
	 * 'group', 'win' etc. keys) so callers can pass the result directly to
	 * calculate_standing(). Uses the `competition_group` composite index —
	 * O(1) lookup instead of iterating all standings.
	 *
	 * @since 0.18.0
	 *
	 * @param int $competition_id Competition post ID.
	 * @param int $group_id       Group ID.
	 *
	 * @return array|null Standing data array (same shape as get_standings() entry), or null.
	 */
	public function get_standing_data_by_competition_group( int $competition_id, int $group_id ): ?array {

		if ( ! $competition_id || ! $group_id ) {
			return null;
		}

		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$standing = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT s.standing_id, s.competition_id, s.group_id, s.points_win, s.points_draw,
					s.points_loss, s.ranking_rules, s.manual_ordering, s.is_initial_data_active,
					s.table_notes, s.table_colors, s.points_initial, s.last_round,
					p.post_title
				FROM {$wpdb->prefix}anwpfl_standings s
				INNER JOIN {$wpdb->posts} p ON s.standing_id = p.ID
				WHERE s.competition_id = %d
					AND s.group_id = %d
					AND p.post_status = 'publish'
				LIMIT 1",
				$competition_id,
				$group_id
			)
		);

		if ( ! $standing ) {
			return null;
		}

		return [
			'id'                     => (int) $standing->standing_id,
			'title'                  => $standing->post_title,
			'table_notes'            => $standing->table_notes ?? '',
			'manual_ordering'        => ( (int) $standing->manual_ordering ) ? 'true' : '',
			'ranking_rules'          => $standing->ranking_rules ?? '',
			'points_initial'         => $standing->points_initial ?? '',
			'is_initial_data_active' => ( (int) $standing->is_initial_data_active ) ? '1' : '',
			'table_colors'           => $standing->table_colors ?? '',
			'last_round'             => absint( $standing->last_round ?? 0 ),
			'competition'            => absint( $standing->competition_id ?? 0 ),
			'group'                  => absint( $standing->group_id ?? 0 ),
			'win'                    => absint( $standing->points_win ?? 0 ),
			'draw'                   => absint( $standing->points_draw ?? 0 ),
			'loss'                   => absint( $standing->points_loss ?? 0 ),
		];
	}
}
