<?php
/**
 * Where you are in a list of rows.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Support;

defined( 'ABSPATH' ) || exit;

/**
 * One page of a table (spec §11).
 *
 * Five screens page through rows — the message log, the opt-out list, the
 * consent list, the audit trail and campaign history — and the arithmetic is
 * the same on all of them. It lives here rather than five times over, for the
 * reason the empty states and the table markup were consolidated: five copies
 * of one rule become five slightly different rules.
 *
 * The rules look trivial and are not. Every one of them turns an empty table
 * into a lie if it is wrong, and an empty table is read as "there is nothing
 * here" on screens where that is the opposite of the truth — an empty opt-out
 * list means nobody asked to be left alone.
 */
final class Paging {

	/**
	 * Rows per page.
	 *
	 * Enough that a busy day fits on one page, small enough that the `OFFSET`
	 * stays shallow for anybody who does page back through a long history.
	 */
	public const PER_PAGE = 25;

	/**
	 * Query argument naming the page.
	 */
	public const ARG = 'paged';

	/**
	 * One-based page number.
	 */
	private int $page;

	/**
	 * Rows on a page.
	 */
	private int $per_page;

	/**
	 * @param int $page     One-based page; anything lower becomes 1.
	 * @param int $per_page Rows per page.
	 */
	public function __construct( int $page = 1, int $per_page = self::PER_PAGE ) {
		$this->page     = max( 1, $page );
		$this->per_page = max( 1, $per_page );
	}

	/**
	 * Read the page out of a query string.
	 *
	 * @param array<string, mixed> $query    Raw query arguments, usually `$_GET`.
	 * @param string               $arg      Argument naming the page.
	 * @param int                  $per_page Rows per page.
	 */
	public static function from_request( array $query, string $arg = self::ARG, int $per_page = self::PER_PAGE ): self {
		return new self( isset( $query[ $arg ] ) ? (int) $query[ $arg ] : 1, $per_page );
	}

	/**
	 * The same page brought back inside the results.
	 *
	 * A page beyond the last one returns no rows, and an empty table is read as
	 * "there is nothing here" — the worst answer to give somebody who has paged
	 * one step too far, and the only one they get if this is left alone.
	 *
	 * @param int $total Rows in total.
	 */
	public function clamp( int $total ): self {
		$pages = $this->pages( $total );

		return $this->page <= $pages ? $this : new self( $pages, $this->per_page );
	}

	/**
	 * How many pages a number of rows makes.
	 *
	 * Never zero: a screen with nothing on it is still on page one of one, and
	 * a zero here would make every "page %d of %d" read as "page 1 of 0".
	 *
	 * @param int $total Rows in total.
	 */
	public function pages( int $total ): int {
		return max( 1, (int) ceil( $total / $this->per_page ) );
	}

	/**
	 * The page numbers to offer, with gaps marked.
	 *
	 * Returns page numbers in order, with `null` standing for a run that was
	 * skipped — so a template renders an ellipsis wherever it finds one and needs
	 * no arithmetic of its own.
	 *
	 * The first and last pages are always offered, because "jump to the start"
	 * and "jump to the end" are the two things anybody actually wants from a long
	 * list, and a window that hides them makes the end unreachable without
	 * clicking through everything in between.
	 *
	 * @param int $total  Rows in total.
	 * @param int $radius Pages to offer either side of the current one.
	 * @return array<int, int|null>
	 */
	public function window( int $total, int $radius = 2 ): array {
		$pages = $this->pages( $total );

		// Short enough that a window would hide nothing and cost a gap marker.
		if ( $pages <= ( $radius * 2 ) + 5 ) {
			return range( 1, $pages );
		}

		$offer = array( 1, $pages );

		for ( $page = $this->page - $radius; $page <= $this->page + $radius; $page++ ) {
			if ( $page > 1 && $page < $pages ) {
				$offer[] = $page;
			}
		}

		$offer = array_values( array_unique( $offer ) );
		sort( $offer );

		$window = array();
		$last   = 0;

		foreach ( $offer as $page ) {
			// A gap of exactly one is filled rather than marked: an ellipsis
			// standing for a single page is wider than the page it hides.
			if ( $page - $last > 1 ) {
				$window[] = $last > 0 && 2 === $page - $last ? $page - 1 : null;
			}

			$window[] = $page;
			$last     = $page;
		}

		return $window;
	}

	/**
	 * Rows to skip for this page.
	 */
	public function offset(): int {
		return ( $this->page - 1 ) * $this->per_page;
	}

	/**
	 * One-based page number.
	 */
	public function page(): int {
		return $this->page;
	}

	/**
	 * Rows on a page.
	 */
	public function per_page(): int {
		return $this->per_page;
	}

	/**
	 * The last row shown, given how many arrived.
	 *
	 * @param int $shown Rows on this page.
	 */
	public function last( int $shown ): int {
		return $this->offset() + $shown;
	}

	/**
	 * The first row shown.
	 *
	 * One-based, and reported as 1 on an empty list rather than 0, since the
	 * summary is not rendered at all when there is nothing to summarise.
	 */
	public function first(): int {
		return $this->offset() + 1;
	}

	/**
	 * This page as a query argument, for building a URL.
	 *
	 * Page one is left out, so an unpaged list has one address rather than two
	 * that render the same rows.
	 *
	 * @param int|null $page Page to point at; this one when omitted.
	 * @param string   $arg  Argument naming the page.
	 * @return array<string, string>
	 */
	public function query_args( ?int $page = null, string $arg = self::ARG ): array {
		$page = null === $page ? $this->page : max( 1, $page );

		return $page > 1 ? array( $arg => (string) $page ) : array();
	}
}
