<?php
/**
 * What the merchant is asking the message log for.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Messages;

use TextTango\Api\PhoneNumber;
use TextTango\Support\Paging;

defined( 'ABSPATH' ) || exit;

/**
 * The validated shape of a message-log query (spec §11).
 *
 * The log answers one question more than any other: "this customer says they
 * never got their text — did we send it?" Until now it could not, because it
 * was the fifty newest rows with no way to reach the fifty-first.
 *
 * This is a value object rather than a set of parameters threaded through
 * `MessageLog`, so the rules about what counts as a valid request — and the
 * `WHERE` they turn into — can be read and tested without a database anywhere
 * near them.
 */
final class LogFilter {

	/**
	 * Rows per page.
	 *
	 * Kept as an alias so callers and tests naming `LogFilter::PER_PAGE` still
	 * read, but the number itself lives in one place now that five screens page.
	 */
	public const PER_PAGE = Paging::PER_PAGE;

	/**
	 * The value meaning "do not filter on this".
	 */
	public const ANY = '';

	/**
	 * Status to match, or {@see self::ANY}.
	 */
	private string $status;

	/**
	 * Event to match, or {@see self::ANY}.
	 */
	private string $event;

	/**
	 * The number as the merchant typed it, kept so the box can show it back.
	 */
	private string $search;

	/**
	 * The search resolved to E.164, when it resolved. Empty otherwise.
	 */
	private string $exact;

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

	/**
	 * @param string $status Status to match, or {@see self::ANY}.
	 * @param string $event  Event to match, or {@see self::ANY}.
	 * @param string $search Number as typed.
	 * @param int    $page   One-based page.
	 * @param string $exact  The search as E.164, if it resolved.
	 */
	public function __construct( string $status = self::ANY, string $event = self::ANY, string $search = '', int $page = 1, string $exact = '' ) {
		$this->status = $status;
		$this->event  = $event;
		$this->search = $search;
		$this->exact  = $exact;
		$this->page   = max( 1, $page );
	}

	/**
	 * Read a filter out of the query string.
	 *
	 * Anything unrecognised **widens** to "no filter" rather than matching
	 * nothing — the same rule {@see \TextTango\Marketing\Segment::normalise()}
	 * follows. A hand-edited URL should show too much, which is obvious and
	 * recoverable, rather than an empty table that reads as "you have never sent
	 * anything" and sends somebody looking for a bug that is not there.
	 *
	 * @param array<string, mixed> $query    Raw query arguments, usually `$_GET`.
	 * @param array<string, mixed> $statuses Known statuses, keyed by status.
	 * @param string[]             $events   Known event names.
	 */
	public static function from_request( array $query, array $statuses, array $events ): self {
		$status = isset( $query['log_status'] ) ? sanitize_key( (string) $query['log_status'] ) : '';
		$event  = isset( $query['log_event'] ) ? sanitize_key( (string) $query['log_event'] ) : '';
		$search = isset( $query['log_search'] ) ? sanitize_text_field( (string) $query['log_search'] ) : '';
		$page   = isset( $query['log_page'] ) ? (int) $query['log_page'] : 1;

		if ( ! isset( $statuses[ $status ] ) ) {
			$status = self::ANY;
		}

		if ( ! in_array( $event, $events, true ) ) {
			$event = self::ANY;
		}

		return new self( $status, $event, $search, $page, self::resolve( $search ) );
	}

	/**
	 * The search as a full E.164 number, when it is one.
	 *
	 * Worth the parse: an exact match uses `recipient_created`, where the
	 * fallback below cannot use any index at all.
	 *
	 * @param string $search Number as typed.
	 */
	private static function resolve( string $search ): string {
		if ( '' === self::digits( $search ) ) {
			return '';
		}

		$e164 = PhoneNumber::normalize( $search );

		return is_wp_error( $e164 ) ? '' : (string) $e164;
	}

	/**
	 * Just the digits of a string.
	 *
	 * @param string $value Any string.
	 */
	private static function digits( string $value ): string {
		return (string) preg_replace( '/\D+/', '', $value );
	}

	/**
	 * The `WHERE` clause and its parameters.
	 *
	 * Returns the clause **including** the leading `WHERE`, or an empty string
	 * when nothing is filtered, so the caller concatenates rather than deciding.
	 *
	 * @return array{0: string, 1: array<int, string>}
	 */
	public function where(): array {
		$clauses = array();
		$params  = array();

		if ( self::ANY !== $this->status ) {
			$clauses[] = 'status = %s';
			$params[]  = $this->status;
		}

		if ( self::ANY !== $this->event ) {
			$clauses[] = 'event = %s';
			$params[]  = $this->event;
		}

		$digits = self::digits( $this->search );

		if ( '' !== $this->exact ) {
			$clauses[] = 'recipient = %s';
			$params[]  = $this->exact;
		} elseif ( '' !== $digits ) {
			/*
			 * The masked column shows the last four digits and nothing else, so
			 * those four are all a merchant reading the screen can type — which
			 * makes a suffix match the only search that answers the question
			 * they actually have. A leading wildcard cannot use an index, so
			 * this scans; it runs only when somebody types, the result is
			 * paged, and the alternative is a search that cannot find anything
			 * from what the screen shows. Stated here rather than left for the
			 * next person to discover from a slow query log.
			 */
			$clauses[] = 'recipient LIKE %s';
			$params[]  = '%' . $digits;
		}

		if ( empty( $clauses ) ) {
			return array( '', array() );
		}

		return array( 'WHERE ' . implode( ' AND ', $clauses ), $params );
	}

	/**
	 * The same filter with its page brought back inside the results.
	 *
	 * A page number beyond the last one returns no rows, and an empty table is
	 * read as "nothing was ever sent" — the worst possible answer to give
	 * somebody who has just paged one step too far, and the only one they get if
	 * this is left alone. Clamping shows the last page instead.
	 *
	 * @param int $total Matching rows.
	 */
	public function clamp( int $total ): self {
		$pages = self::pages( $total );

		if ( $this->page <= $pages ) {
			return $this;
		}

		// Everything but the page survives, or paging past the end would quietly
		// drop the filters and show the merchant an unfiltered log instead.
		return new self( $this->status, $this->event, $this->search, $pages, $this->exact );
	}

	/**
	 * Whether anything is being filtered.
	 *
	 * Drives the empty state: "nothing matches these filters" and "nothing sent
	 * yet" look identical in an empty table and mean opposite things.
	 */
	public function is_active(): bool {
		return self::ANY !== $this->status
			|| self::ANY !== $this->event
			|| '' !== self::digits( $this->search );
	}

	/**
	 * The filter without its page, for a link that must not move the page.
	 *
	 * The audit trail sits under the message log on the same screen, so its
	 * pager has to carry the log's filter — but not the log's page, or turning
	 * to page two of the audit trail would drag the log along with it.
	 *
	 * @return array<string, string>
	 */
	public function filter_args(): array {
		return array_filter(
			array(
				'log_status' => $this->status,
				'log_event'  => $this->event,
				'log_search' => $this->search,
			),
			static fn( string $value ): bool => '' !== $value
		);
	}

	/**
	 * The filter as query arguments, with the page.
	 *
	 * @param int|null $page Page to point at; the current one when omitted.
	 * @return array<string, string>
	 */
	public function query_args( ?int $page = null ): array {
		$page = null === $page ? $this->page : max( 1, $page );

		return array_filter(
			array(
				'log_status' => $this->status,
				'log_event'  => $this->event,
				'log_search' => $this->search,
				'log_page'   => $page > 1 ? (string) $page : '',
			),
			static fn( string $value ): bool => '' !== $value
		);
	}

	/**
	 * How many rows to skip for this page.
	 */
	public function offset(): int {
		return $this->paging()->offset();
	}

	/**
	 * The paging half of this filter, on its own.
	 *
	 * Where the page arithmetic lives, so the message log and the four screens
	 * that have no filter at all share one set of rules.
	 */
	public function paging(): Paging {
		return new Paging( $this->page );
	}

	/**
	 * Status being matched, or {@see self::ANY}.
	 */
	public function status(): string {
		return $this->status;
	}

	/**
	 * Event being matched, or {@see self::ANY}.
	 */
	public function event(): string {
		return $this->event;
	}

	/**
	 * The number as the merchant typed it.
	 */
	public function search(): string {
		return $this->search;
	}

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

	/**
	 * How many pages a given number of matching rows makes.
	 *
	 * @param int $total Matching rows.
	 */
	public static function pages( int $total ): int {
		return ( new Paging() )->pages( $total );
	}
}
