<?php
/**
 * @package Hafenstudios\Kurato
 */

namespace Hafenstudios\Kurato\Insight;

use Hafenstudios\Kurato\Db\ContactRepository;
use Hafenstudios\Kurato\Db\Schema;
use Hafenstudios\Kurato\Db\StageRepository;

defined( 'ABSPATH' ) || exit;

/**
 * Funnel-Analytik (HANS-KI-KONZEPT Schicht 1, kein LLM). Rechnet je Zeitraum
 * (30/90/365 Tage) aus den lokalen Tabellen (hs_stage_history, hs_bookings,
 * hs_subscriptions, hs_campaigns):
 *
 * - Kontakte je Stufe und Konversionsrate je Stufenuebergang (aus stage_history),
 * - Median-Verweildauer je Stufe,
 * - "Haenger": Kontakte laenger als X Tage ohne jede Aktivitaet in ihrer Stufe,
 * - Wirkungszahlen: Buchungen, bestaetigte Abos, versendete Kampagnen im Zeitraum.
 *
 * Ehrlichkeits-Linie (K4e): jede Zahl traegt ihre Bezugsgroesse. Eine Uebergangs-
 * rate wird nur zusammen mit der Zahl der Kontakte ausgewiesen, die die Ausgangs-
 * stufe im Zeitraum erreicht haben (base); ohne Basis keine Rate.
 *
 * Die Roh-Zaehlungen kommen aus geschuetzten fetch_*-Methoden (SQL ueber wpdb mit
 * prepared Statements). Die eigentlichen Rechnungen (Median, Rate, Haenger-
 * Erkennung) sind reine, statisch pruefbare Methoden. Tests ueberschreiben die
 * fetch_*-Methoden mit Fake-Quellen (Muster BookingReminders::config).
 *
 * **CP6 (Seekarte): die Datenbank aggregiert, nicht PHP.** Vorher lieferten zwei
 * Quellen je Kontakt bzw. je Aufenthalt eine Zeile und PHP zaehlte/sortierte sie -
 * bei 10.000 Kontakten also 10.000 Zeilen im Speicher fuer vier Zahlen. Beide sind
 * durch Aggregat-Quellen ersetzt ({@see fetch_hanging_counts()},
 * {@see fetch_dwell_medians()}), deren Antwortgroesse an der Zahl der STUFEN
 * haengt und nicht an der Zahl der Kontakte. Die Rechenvorschriften selbst sind
 * unveraendert: der Median kommt weiter aus {@see median()} (die Datenbank liefert
 * nur die ein bis zwei mittleren Werte), die Haenger-Schwelle ist dieselbe strikte
 * Groesser-Regel wie in {@see count_over_threshold()}.
 *
 * Ebenfalls CP6: {@see with_filter()} legt einen {@see FunnelFilter} an den
 * Bericht. Ohne Filter bleiben alle Abfragen byte-gleich zu vorher.
 *
 * Nicht final, damit Tests die Datenquellen per Test-Double ersetzen koennen.
 */
class FunnelAnalytics {

	/**
	 * Erlaubte Zeitraeume in Tagen (Konzept Schicht 1).
	 *
	 * @var array<int, int>
	 */
	public const RANGES = array( 30, 90, 365 );

	/**
	 * Schwellwert (Tage), ab dem ein Kontakt ohne Aktivitaet als "Haenger" gilt.
	 * Als Filter kurato_funnel_hanging_days ueberschreibbar.
	 */
	public const HANGING_DAYS = 14;

	/**
	 * Aktiver Filter (CP6). Immer gesetzt; ohne Filter der leere.
	 *
	 * @var FunnelFilter|null
	 */
	private ?FunnelFilter $filter = null;

	/**
	 * Liefert eine Kopie dieser Analytik mit angelegtem Filter (CP6).
	 *
	 * Bewusst eine Kopie statt eines Setters: die Instanz aus Plugin.php wird von
	 * mehreren Routen geteilt, und ein Bericht darf nie den Filter eines anderen
	 * Aufrufs erben. Bewusst auch KEIN zusaetzliches report()-Argument, damit
	 * bestehende Aufrufer und Test-Doubles unveraendert weiterlaufen.
	 *
	 * @param FunnelFilter|null $filter Filter (null = alles sehen).
	 * @return static
	 */
	public function with_filter( ?FunnelFilter $filter ) {
		$copy         = clone $this;
		$copy->filter = $filter;
		return $copy;
	}

	/**
	 * Der aktive Filter (nie null).
	 *
	 * @return FunnelFilter
	 */
	protected function filter(): FunnelFilter {
		return $this->filter ?? FunnelFilter::none();
	}

	/**
	 * Kontaktzahl je Stufe unter dem aktiven Filter. Oeffentlich, weil die
	 * Seekarte dieselbe Zahl fuer die Zeile "und 847 weitere" braucht und sie
	 * nicht ein zweites Mal berechnen darf.
	 *
	 * @return array<int, int> stage_id => Anzahl.
	 */
	public function stage_counts(): array {
		return $this->fetch_current_counts();
	}

	/**
	 * Baut den vollstaendigen Funnel-Bericht fuer einen Zeitraum.
	 *
	 * @param int $days Zeitraum in Tagen (wird auf RANGES normalisiert).
	 * @return array{
	 *   days:int, since:string,
	 *   hanging_days:int,
	 *   stages:array<int, array{stage_id:int,slug:string,label:string,is_won:bool,is_lost:bool,current:int,median_days:?float,hanging:int}>,
	 *   transitions:array<int, array{from_id:int,from_label:string,to_id:int,to_label:string,moved:int,base:int,rate:?float}>,
	 *   impact:array{contacts_total:int,bookings:int,subscriptions_confirmed:int,campaigns_sent:int}
	 * }
	 */
	public function report( int $days ): array {
		$days  = self::normalize_days( $days );
		$since = gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS );
		$now   = gmdate( 'Y-m-d H:i:s' );

		$hanging_days = self::hanging_threshold();

		$stages      = $this->fetch_stages();
		$current     = $this->fetch_current_counts();
		$arrivals    = $this->fetch_arrivals( $since );
		$transitions = $this->fetch_transitions( $since );
		$medians     = $this->fetch_dwell_medians( $since );
		$hanging     = $this->fetch_hanging_counts( $now, $hanging_days );

		$stage_rows = array();
		foreach ( $stages as $stage ) {
			$sid          = (int) $stage['id'];
			$stage_rows[] = array(
				'stage_id'    => $sid,
				'slug'        => (string) $stage['slug'],
				'label'       => (string) $stage['label'],
				'is_won'      => (bool) $stage['is_won'],
				'is_lost'     => (bool) $stage['is_lost'],
				'current'     => (int) ( $current[ $sid ] ?? 0 ),
				'median_days' => isset( $medians[ $sid ] ) ? $medians[ $sid ] : null,
				'hanging'     => (int) ( $hanging[ $sid ] ?? 0 ),
			);
		}

		return array(
			'days'         => $days,
			'since'        => $since,
			'hanging_days' => $hanging_days,
			'stages'       => $stage_rows,
			'transitions'  => $this->build_transitions( $stages, $arrivals, $transitions ),
			'impact'       => array(
				'contacts_total'          => $this->fetch_contacts_total(),
				'bookings'                => $this->fetch_bookings_count( $since ),
				'subscriptions_confirmed' => $this->fetch_subscriptions_confirmed( $since ),
				'campaigns_sent'          => $this->fetch_campaigns_sent( $since ),
			),
		);
	}

	/**
	 * Baut die Uebergangsraten fuer aufeinanderfolgende Stufenpaare (Reihenfolge
	 * nach sort_order). moved = Zahl der Wechsel S_i -> S_(i+1) im Zeitraum, base =
	 * Zahl der Kontakte, die S_i im Zeitraum erreicht haben. Die Rate ist bewusst
	 * nur mit ihrer Basis aussagekraeftig (Ehrlichkeits-Linie).
	 *
	 * @param array<int, array<string, mixed>>            $stages      Geordnete Stufen.
	 * @param array<int, int>                             $arrivals    stage_id => Ankuenfte im Zeitraum.
	 * @param array<int, array{from:int,to:int,count:int}> $transitions Wechsel im Zeitraum.
	 * @return array<int, array<string, mixed>>
	 */
	private function build_transitions( array $stages, array $arrivals, array $transitions ): array {
		// Wechsel nach (from,to) indizieren.
		$moved_index = array();
		foreach ( $transitions as $t ) {
			$key                 = (int) $t['from'] . '>' . (int) $t['to'];
			$moved_index[ $key ] = ( $moved_index[ $key ] ?? 0 ) + (int) $t['count'];
		}

		$out   = array();
		$count = count( $stages );
		for ( $i = 0; $i < $count - 1; $i++ ) {
			$from = $stages[ $i ];
			$to   = $stages[ $i + 1 ];
			// Abschluss-Stufen (gewonnen/verloren) sind kein sinnvoller "naechster
			// Schritt" der Vorgaengerstufe; sie werden als Ziel uebersprungen, wenn
			// sie nicht direkt in der Reihenfolge stehen. Hier bleibt die reine
			// Reihenfolge massgeblich (der Betreiber ordnet die Pipeline selbst).
			$from_id = (int) $from['id'];
			$to_id   = (int) $to['id'];
			$moved   = (int) ( $moved_index[ $from_id . '>' . $to_id ] ?? 0 );
			$base    = (int) ( $arrivals[ $from_id ] ?? 0 );

			$out[] = array(
				'from_id'    => $from_id,
				'from_label' => (string) $from['label'],
				'to_id'      => $to_id,
				'to_label'   => (string) $to['label'],
				'moved'      => $moved,
				'base'       => $base,
				'rate'       => self::rate( $moved, $base ),
			);
		}
		return $out;
	}

	// --- Reine Rechnungen (direkt pruefbar) --------------------------------

	/**
	 * Median einer Werteliste. Leere Liste => null (keine Aussage). Bei gerader
	 * Anzahl der Mittelwert der beiden mittleren Werte, bei ungerader der mittlere.
	 *
	 * @param array<int, float|int> $values Werte (z.B. Verweildauern in Tagen).
	 * @return float|null
	 */
	public static function median( array $values ): ?float {
		$values = array_values(
			array_map( 'floatval', array_filter( $values, static function ( $v ): bool {
				return is_numeric( $v );
			} ) )
		);
		$n = count( $values );
		if ( 0 === $n ) {
			return null;
		}
		sort( $values, SORT_NUMERIC );
		$mid = intdiv( $n, 2 );
		if ( 0 === $n % 2 ) {
			return round( ( $values[ $mid - 1 ] + $values[ $mid ] ) / 2, 1 );
		}
		return round( $values[ $mid ], 1 );
	}

	/**
	 * Uebergangsrate moved/base als Anteil (0..1). Ohne Basis (base <= 0) gibt es
	 * keine ehrliche Rate: dann null.
	 *
	 * @param int $moved Zahl der Weiterbewegten.
	 * @param int $base  Bezugsgroesse (erreichten die Ausgangsstufe).
	 * @return float|null
	 */
	public static function rate( int $moved, int $base ): ?float {
		if ( $base <= 0 ) {
			return null;
		}
		return round( $moved / $base, 4 );
	}

	/**
	 * Zaehlt Werte, die den Schwellwert echt ueberschreiten (Haenger-Erkennung:
	 * Tage ohne Aktivitaet > Schwelle).
	 *
	 * @param array<int, int|float> $values    Tage-Werte je Kontakt.
	 * @param int                   $threshold Schwellwert in Tagen.
	 * @return int
	 */
	public static function count_over_threshold( array $values, int $threshold ): int {
		$count = 0;
		foreach ( $values as $value ) {
			if ( (float) $value > (float) $threshold ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Normalisiert einen Zeitraum auf einen erlaubten Wert (Default 30).
	 *
	 * @param int $days Roh-Eingabe.
	 * @return int
	 */
	public static function normalize_days( int $days ): int {
		return in_array( $days, self::RANGES, true ) ? $days : self::RANGES[0];
	}

	/**
	 * Haenger-Schwelle (Tage), per Filter kurato_funnel_hanging_days anpassbar.
	 *
	 * @return int
	 */
	public static function hanging_threshold(): int {
		$days = (int) apply_filters( 'kurato_funnel_hanging_days', self::HANGING_DAYS );
		return $days > 0 ? $days : self::HANGING_DAYS;
	}

	// --- Datenquellen (SQL-Seams, in Tests ueberschrieben) -----------------

	/**
	 * Geordnete Pipeline-Stufen (id, slug, label, is_won, is_lost).
	 *
	 * @return array<int, array<string, mixed>>
	 */
	protected function fetch_stages(): array {
		return ( new StageRepository() )->all();
	}

	/**
	 * Aktuelle Kontaktzahl je Stufe (Aggregat, eine Zeile je Stufe).
	 *
	 * @return array<int, int> stage_id => Anzahl.
	 */
	protected function fetch_current_counts(): array {
		$filter = $this->filter()->where( 'c' );
		if ( '' === $filter['sql'] ) {
			return ( new StageRepository() )->contact_counts();
		}

		global $wpdb;
		$contacts = Schema::table( 'contacts' );
		$sql      = "SELECT c.stage_id AS stage_id, COUNT(*) AS total FROM {$contacts} c WHERE c.stage_id IS NOT NULL{$filter['sql']} GROUP BY c.stage_id";
		// Ein Filter ohne Werte (z. B. nur "ohne naechsten Schritt") traegt keine
		// Platzhalter - prepare() ohne Argumente ist in WordPress ein Fehler.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( $filter['params'] ? $wpdb->prepare( $sql, ...$filter['params'] ) : $sql, ARRAY_A );
		$out  = array();
		foreach ( (array) $rows as $row ) {
			$out[ (int) $row['stage_id'] ] = (int) $row['total'];
		}
		return $out;
	}

	/**
	 * Ankuenfte je Stufe im Zeitraum (to_stage_id-Zaehlung aus stage_history).
	 *
	 * @param string $since UTC-Grenze.
	 * @return array<int, int> stage_id => Anzahl.
	 */
	protected function fetch_arrivals( string $since ): array {
		global $wpdb;
		$table = Schema::table( 'stage_history' );
		$join  = $this->contact_join();
		$where = $this->filter()->where( 'c' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT h.to_stage_id AS to_stage_id, COUNT(*) AS total FROM {$table} h{$join} WHERE h.to_stage_id IS NOT NULL AND h.changed_at >= %s{$where['sql']} GROUP BY h.to_stage_id",
				...array_merge( array( $since ), $where['params'] )
			),
			ARRAY_A
		);
		$out = array();
		foreach ( (array) $rows as $row ) {
			$out[ (int) $row['to_stage_id'] ] = (int) $row['total'];
		}
		return $out;
	}

	/**
	 * Stufenwechsel im Zeitraum als (from,to,count).
	 *
	 * @param string $since UTC-Grenze.
	 * @return array<int, array{from:int,to:int,count:int}>
	 */
	protected function fetch_transitions( string $since ): array {
		global $wpdb;
		$table = Schema::table( 'stage_history' );
		$join  = $this->contact_join();
		$where = $this->filter()->where( 'c' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT h.from_stage_id AS from_stage_id, h.to_stage_id AS to_stage_id, COUNT(*) AS total FROM {$table} h{$join} WHERE h.from_stage_id IS NOT NULL AND h.to_stage_id IS NOT NULL AND h.changed_at >= %s{$where['sql']} GROUP BY h.from_stage_id, h.to_stage_id",
				...array_merge( array( $since ), $where['params'] )
			),
			ARRAY_A
		);
		$out = array();
		foreach ( (array) $rows as $row ) {
			$out[] = array(
				'from'  => (int) $row['from_stage_id'],
				'to'    => (int) $row['to_stage_id'],
				'count' => (int) $row['total'],
			);
		}
		return $out;
	}

	/**
	 * Median-Verweildauer (Tage) je Stufe fuer im Zeitraum begonnene, bereits
	 * abgeschlossene Aufenthalte. Ein Aufenthalt in Stufe S beginnt mit der
	 * Ankunft (to=S) und endet mit dem naechsten Stufenwechsel desselben
	 * Kontakts; offene Aufenthalte zaehlen nicht mit.
	 *
	 * **Zwei Abfragen statt einer Schleife.** Erst zaehlt die Datenbank die
	 * Aufenthalte je Stufe (eine Zeile je Stufe), dann holt sie je Stufe genau die
	 * ein bis zwei mittleren Werte per ORDER BY + LIMIT/OFFSET. Der Median selbst
	 * entsteht unveraendert in {@see median()} - dieselbe Vorschrift wie bisher,
	 * nur ohne alle Zwischenwerte durch PHP zu schleppen.
	 *
	 * @param string $since UTC-Grenze der Ankunft.
	 * @return array<int, float|null> stage_id => Median in Tagen (null = keine Basis).
	 */
	protected function fetch_dwell_medians( string $since ): array {
		global $wpdb;
		$where  = $this->filter()->where( 'c' );
		$inner  = $this->dwell_sql();
		$params = array_merge( array( $since ), $where['params'] );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$counts = $wpdb->get_results(
			$wpdb->prepare( "SELECT d.stage_id AS stage_id, COUNT(*) AS n FROM ({$inner}) d GROUP BY d.stage_id", ...$params ),
			ARRAY_A
		);

		$out = array();
		foreach ( (array) $counts as $row ) {
			$sid = (int) $row['stage_id'];
			$n   = (int) $row['n'];
			if ( $n <= 0 ) {
				continue;
			}
			// Ungerade Anzahl: der eine mittlere Wert. Gerade: die beiden mittleren.
			$limit  = 0 === $n % 2 ? 2 : 1;
			$offset = 0 === $n % 2 ? intdiv( $n, 2 ) - 1 : intdiv( $n, 2 );

			$middle = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT d.secs FROM ({$inner}) d WHERE d.stage_id = %d ORDER BY d.secs ASC LIMIT %d OFFSET %d",
					...array_merge( $params, array( $sid, $limit, $offset ) )
				)
			);

			$days = array();
			foreach ( (array) $middle as $secs ) {
				$days[] = (float) $secs / DAY_IN_SECONDS;
			}
			$out[ $sid ] = self::median( $days );
		}
		// phpcs:enable

		return $out;
	}

	/**
	 * Zahl der "Haenger" je Stufe: Kontakte, deren letzte Aktivitaet laenger als
	 * die Schwelle zurueckliegt. Aktivitaet = juengster Stufenwechsel, juengstes
	 * Mail-Ereignis, juengste Buchung oder die Kontaktzeile selbst.
	 *
	 * Die Schwellenregel ist dieselbe strikte Groesser-Regel wie in
	 * {@see count_over_threshold()} - sie steht jetzt nur im WHERE statt in einer
	 * PHP-Schleife, damit die Antwort eine Zeile je Stufe bleibt.
	 *
	 * @param string $now       UTC-"Jetzt".
	 * @param int    $threshold Schwelle in Tagen.
	 * @return array<int, int> stage_id => Anzahl.
	 */
	protected function fetch_hanging_counts( string $now, int $threshold ): array {
		global $wpdb;
		$contacts = Schema::table( 'contacts' );
		$where    = $this->filter()->where( 'c', $now );
		$activity = $this->activity_sql();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT c.stage_id AS stage_id, COUNT(*) AS total
				FROM {$contacts} c
				WHERE c.stage_id IS NOT NULL{$where['sql']}
				AND FLOOR( GREATEST( 0, TIMESTAMPDIFF( SECOND, {$activity}, %s ) ) / 86400 ) > %d
				GROUP BY c.stage_id",
				...array_merge( $where['params'], array( $now, $threshold ) )
			),
			ARRAY_A
		);
		$out = array();
		foreach ( (array) $rows as $row ) {
			$out[ (int) $row['stage_id'] ] = (int) $row['total'];
		}
		return $out;
	}

	/**
	 * SQL-Ausdruck fuer den Zeitpunkt der letzten Aktivitaet eines Kontakts
	 * (Alias c). Eine Stelle, weil Haenger-Zaehlung und Reede-Rangfolge
	 * dieselbe Definition von "still" brauchen.
	 *
	 * @return string
	 */
	protected function activity_sql(): string {
		$history  = Schema::table( 'stage_history' );
		$events   = Schema::table( 'mail_events' );
		$bookings = Schema::table( 'bookings' );

		return "GREATEST(
			COALESCE( c.updated_at, c.created_at ),
			COALESCE( ( SELECT MAX(h_a.changed_at) FROM {$history} h_a WHERE h_a.contact_id = c.id ), c.created_at ),
			COALESCE( ( SELECT MAX(e_a.created_at) FROM {$events} e_a WHERE e_a.contact_id = c.id ), c.created_at ),
			COALESCE( ( SELECT MAX(b_a.created_at) FROM {$bookings} b_a WHERE b_a.contact_id = c.id ), c.created_at )
		)";
	}

	/**
	 * Der Verweildauer-Rohsatz als Unterabfrage (Platzhalter: erst %s fuer since,
	 * danach die Filter-Werte).
	 *
	 * @return string
	 */
	private function dwell_sql(): string {
		$table = Schema::table( 'stage_history' );
		$join  = $this->contact_join();
		$where = $this->filter()->where( 'c' );

		return "SELECT h.to_stage_id AS stage_id,
				TIMESTAMPDIFF(SECOND, h.changed_at, (
					SELECT MIN(h2.changed_at) FROM {$table} h2
					WHERE h2.contact_id = h.contact_id AND h2.changed_at > h.changed_at
				)) AS secs
			FROM {$table} h{$join}
			WHERE h.to_stage_id IS NOT NULL AND h.changed_at >= %s{$where['sql']}
			HAVING secs IS NOT NULL";
	}

	/**
	 * Kontakt-Join fuer die verlaufsbasierten Abfragen - nur wenn ein Filter
	 * ueberhaupt wirkt. Ohne Filter bleibt die Abfrage exakt die von vorher.
	 *
	 * @return string
	 */
	private function contact_join(): string {
		if ( ! $this->filter()->is_active() ) {
			return '';
		}
		$contacts = Schema::table( 'contacts' );
		return " INNER JOIN {$contacts} c ON c.id = h.contact_id";
	}

	/**
	 * Bestaetigte Buchungen im Zeitraum.
	 *
	 * @param string $since UTC-Grenze.
	 * @return int
	 */
	protected function fetch_bookings_count( string $since ): int {
		global $wpdb;
		$table = Schema::table( 'bookings' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = 'confirmed' AND created_at >= %s", $since ) );
	}

	/**
	 * Bestaetigte Abos im Zeitraum (confirmed_at im Fenster).
	 *
	 * @param string $since UTC-Grenze.
	 * @return int
	 */
	protected function fetch_subscriptions_confirmed( string $since ): int {
		global $wpdb;
		$table = Schema::table( 'subscriptions' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status = 'confirmed' AND confirmed_at IS NOT NULL AND confirmed_at >= %s", $since ) );
	}

	/**
	 * Im Zeitraum gestartete Versand-Kampagnen (sending/sent). Als Bezug dient der
	 * Startzeitpunkt, ersatzweise die Anlage.
	 *
	 * @param string $since UTC-Grenze.
	 * @return int
	 */
	protected function fetch_campaigns_sent( string $since ): int {
		global $wpdb;
		$table = Schema::table( 'campaigns' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE status IN ('sending','sent') AND COALESCE( started_at, created_at ) >= %s", $since ) );
	}

	/**
	 * Gesamtzahl der Kontakte (Bezugsgroesse des Berichts). Mit aktivem Filter
	 * die Zahl der Kontakte, die der Filter uebrig laesst - sonst wuerde die
	 * Ansicht Raten gegen eine Basis stellen, die gar nicht gemeint ist.
	 *
	 * Die uebrigen Wirkungszahlen (Buchungen, Abos, Kampagnen) bleiben bewusst
	 * ungefiltert: sie zaehlen Ereignisse des Zeitraums, nicht Kontakte.
	 *
	 * @return int
	 */
	protected function fetch_contacts_total(): int {
		$filter = $this->filter()->where( 'c' );
		if ( '' === $filter['sql'] ) {
			return ( new ContactRepository() )->count_all();
		}

		global $wpdb;
		$contacts = Schema::table( 'contacts' );
		$sql      = "SELECT COUNT(*) FROM {$contacts} c WHERE 1 = 1{$filter['sql']}";
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return (int) $wpdb->get_var( $filter['params'] ? $wpdb->prepare( $sql, ...$filter['params'] ) : $sql );
	}
}
