<?php
/**
 * Narrowing the audience list to what somebody is looking for.
 *
 * @package TextTango
 */

declare( strict_types=1 );

namespace TextTango\Marketing;

use TextTango\Consent\Recipient;
use TextTango\Support\Paging;

defined( 'ABSPATH' ) || exit;

/**
 * What the Audience screen is showing, validated (spec §11).
 *
 * ### Why this filters in memory rather than in SQL
 *
 * {@see \TextTango\Messages\LogFilter} builds a `WHERE` clause because the
 * message log is one table. The audience is not: it is consenting accounts,
 * consenting guest orders and an imported list of bare numbers, joined by
 * nothing a database could join them by. `Audience::roster()` already walks all
 * three under one bounded ceiling, so the rows exist before anything is filtered
 * and the filtering is a pass over an array.
 *
 * That makes every rule here **pure**, which matters more than usual: a search
 * that quietly matches nothing looks exactly like a store with no customers.
 */
final class AudienceFilter {

	/**
	 * No narrowing.
	 */
	public const ANY = '';

	/**
	 * Query argument naming the search term.
	 */
	public const SEARCH_ARG = 'who';

	/**
	 * Query argument naming the state.
	 */
	public const STATE_ARG = 'state';

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

	/**
	 * What somebody typed.
	 */
	private string $search;

	/**
	 * The state being shown, or {@see self::ANY}.
	 */
	private string $state;

	/**
	 * The page being read.
	 */
	private Paging $paging;

	/**
	 * @param string $search Number or name.
	 * @param string $state  One of `Audience::STATE_*`, or ANY.
	 * @param int    $page   Page being read.
	 */
	public function __construct( string $search = '', string $state = self::ANY, int $page = 1 ) {
		$this->search = trim( $search );
		$this->state  = self::normalise( $state );
		$this->paging = new Paging( $page );
	}

	/**
	 * The states somebody may filter by, and what each is called.
	 *
	 * @return array<string, string>
	 */
	public static function states(): array {
		return array(
			Audience::STATE_CONSENTING => __( 'Can be messaged', 'texttango' ),
			Audience::STATE_SUPPRESSED => __( 'Opted out', 'texttango' ),
			Audience::STATE_UNUSABLE   => __( 'No usable number', 'texttango' ),
		);
	}

	/**
	 * A state, or nothing.
	 *
	 * Anything unrecognised widens to everything rather than erroring or matching
	 * nothing — the rule {@see \TextTango\Marketing\Segment::normalise()} and
	 * `LogFilter` already follow. A hand-edited address should show more than was
	 * asked for, never an empty table that reads as "nobody is here".
	 *
	 * @param string $state Raw state.
	 */
	public static function normalise( string $state ): string {
		return array_key_exists( $state, self::states() ) ? $state : self::ANY;
	}

	/**
	 * Read a filter out of a request.
	 *
	 * @param array<string, mixed> $query Raw query arguments, usually `$_GET`.
	 */
	public static function from_request( array $query ): self {
		return new self(
			isset( $query[ self::SEARCH_ARG ] ) ? sanitize_text_field( (string) $query[ self::SEARCH_ARG ] ) : '',
			isset( $query[ self::STATE_ARG ] ) ? sanitize_key( (string) $query[ self::STATE_ARG ] ) : self::ANY,
			isset( $query[ self::PAGE_ARG ] ) ? (int) $query[ self::PAGE_ARG ] : 1
		);
	}

	/**
	 * Whether one row belongs in what is being shown.
	 *
	 * @param array<string, mixed> $row A roster row.
	 */
	public function matches( array $row ): bool {
		if ( self::ANY !== $this->state && (string) ( $row['state'] ?? '' ) !== $this->state ) {
			return false;
		}

		if ( '' === $this->search ) {
			return true;
		}

		return self::hits( $this->search, (string) ( $row['number'] ?? '' ), (string) ( $row['name'] ?? '' ) );
	}

	/**
	 * Whether a search term finds this number or this name.
	 *
	 * ### Why the number is normalised first
	 *
	 * A merchant reads a number off an order as `024 123 4567` and the plugin
	 * stores `+233241234567`. A plain substring match finds nothing, and finding
	 * nothing is indistinguishable from the person not being there — so the term
	 * goes through {@see Recipient::normalize()}, the same function that decided
	 * what to store, before being compared.
	 *
	 * A term that does not normalise to a number is treated as a name. That is
	 * why it is a fallback rather than an alternative: `0241` is a fragment of a
	 * number, not somebody called "0241", and normalising it fails, so the digits
	 * are also matched against the stored number directly.
	 *
	 * Pure, and separate from `matches()` so the comparison can be checked on its
	 * own — this is the rule that decides whether a search appears to work.
	 *
	 * @param string $term   What was typed.
	 * @param string $number The row's E.164 number.
	 * @param string $name   The row's name, if it has one.
	 */
	public static function hits( string $term, string $number, string $name ): bool {
		$term = trim( $term );

		if ( '' === $term ) {
			return true;
		}

		if ( '' !== $name && false !== stripos( $name, $term ) ) {
			return true;
		}

		if ( '' === $number ) {
			return false;
		}

		$exact = Recipient::normalize( $term );

		if ( '' !== $exact && $exact === $number ) {
			return true;
		}

		// The digits alone, so a fragment finds a number the way somebody expects
		// when they remember only the last few.
		$digits = (string) preg_replace( '/\D+/', '', $term );

		return '' !== $digits && str_contains( (string) preg_replace( '/\D+/', '', $number ), $digits );
	}

	/**
	 * The rows being shown, in order, filtered but not yet paged.
	 *
	 * @param array<int, array<string, mixed>> $rows Every roster row.
	 * @return array<int, array<string, mixed>>
	 */
	public function apply( array $rows ): array {
		$kept = array();

		foreach ( $rows as $row ) {
			if ( $this->matches( $row ) ) {
				$kept[] = $row;
			}
		}

		return $kept;
	}

	/**
	 * The same filter, held to a list that has this many rows.
	 *
	 * Without this, page nine of a list that now has two pages renders the empty
	 * state — which says "nobody is here" about a screen that has plenty.
	 *
	 * @param int $total Rows after filtering.
	 */
	public function clamp( int $total ): self {
		$clone         = clone $this;
		$clone->paging = $this->paging->clamp( $total );

		return $clone;
	}

	/**
	 * Whether anything is being narrowed.
	 */
	public function is_active(): bool {
		return '' !== $this->search || self::ANY !== $this->state;
	}

	/**
	 * The filter as query arguments, without the page.
	 *
	 * @return array<string, string>
	 */
	public function filter_args(): array {
		$args = array();

		if ( '' !== $this->search ) {
			$args[ self::SEARCH_ARG ] = $this->search;
		}

		if ( self::ANY !== $this->state ) {
			$args[ self::STATE_ARG ] = $this->state;
		}

		return $args;
	}

	/**
	 * The filter as query arguments, including a page.
	 *
	 * @param int|null $page Page, or null for the current one.
	 * @return array<string, string>
	 */
	public function query_args( ?int $page = null ): array {
		return array_merge( $this->filter_args(), $this->paging->query_args( $page, self::PAGE_ARG ) );
	}

	/**
	 * What was typed.
	 */
	public function search(): string {
		return $this->search;
	}

	/**
	 * The state being shown.
	 */
	public function state(): string {
		return $this->state;
	}

	/**
	 * The page being read.
	 */
	public function paging(): Paging {
		return $this->paging;
	}
}
