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

namespace Hafenstudios\Kurato\Db;

defined( 'ABSPATH' ) || exit;

/**
 * Lern-Log der Hans-Empfehlungen (hs_recommendations, HANS-KI-KONZEPT
 * Schicht 4). Haelt je erzeugter Empfehlung die Entscheidung des Betreibers und
 * das spaeter gemessene Ergebnis fest.
 *
 * H1 legt nur die Schreib-/Lese-Wege an: record (Empfehlung wurde gezeigt),
 * decide (angenommen/verworfen/abgelaufen), set_outcome (T+X Tage gemessen) und
 * die Rohdaten-Lieferung score_for_rule/recent. Die eigentliche Gewichtung
 * (Bandit-Prinzip light) wertet H2/H4 aus diesen Rohdaten aus - hier wird nichts
 * bewertet, nur gezaehlt.
 *
 * Es werden bewusst KEINE Kontaktdaten gespeichert: context_hash ist ein Hash
 * des Auswahl-Kontexts (Dedup und Zuordnung), kein personenbezogenes Feld.
 *
 * Nicht final, damit Tests die DB-Methoden per Test-Double ueberschreiben koennen.
 */
class RecommendationRepository {

	/**
	 * Erlaubte Entscheidungen (Konzept Schicht 4).
	 *
	 * @var array<int, string>
	 */
	public const DECISIONS = array( 'accepted', 'dismissed', 'expired' );

	/**
	 * Erlaubte Ergebnisse (gemessen ueber stage_history/bookings, Konzept Schicht 4).
	 *
	 * @var array<int, string>
	 */
	public const OUTCOMES = array( 'advanced', 'converted', 'none' );

	/**
	 * Protokolliert eine gezeigte Empfehlung.
	 *
	 * @param string $rule_key     Regel-Schluessel (z.B. "R1_haenger").
	 * @param string $context_hash Hash des Auswahl-Kontexts (keine Klardaten).
	 * @return int Datensatz-ID (0 bei Fehler).
	 */
	public function record( string $rule_key, string $context_hash = '' ): int {
		$rule_key = trim( $rule_key );
		if ( '' === $rule_key ) {
			return 0;
		}
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$ok = $wpdb->insert(
			Schema::table( 'recommendations' ),
			array(
				'rule_key'     => substr( $rule_key, 0, 60 ),
				'context_hash' => substr( $context_hash, 0, 64 ),
				'created_at'   => current_time( 'mysql', true ),
				'decision'     => '',
				'outcome'      => '',
			)
		);
		return $ok ? (int) $wpdb->insert_id : 0;
	}

	/**
	 * Haelt die Entscheidung des Betreibers fest (nur die erste zaehlt).
	 *
	 * @param int    $id       Datensatz-ID.
	 * @param string $decision accepted | dismissed | expired.
	 * @return bool Erfolg.
	 */
	public function decide( int $id, string $decision ): bool {
		if ( $id <= 0 || ! in_array( $decision, self::DECISIONS, true ) ) {
			return false;
		}
		global $wpdb;
		// Nur setzen, solange noch keine Entscheidung vorliegt (idempotent).
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->update(
			Schema::table( 'recommendations' ),
			array(
				'decision'   => $decision,
				'decided_at' => current_time( 'mysql', true ),
			),
			array(
				'id'       => $id,
				'decision' => '',
			)
		);
		return (bool) $rows;
	}

	/**
	 * Haelt das gemessene Ergebnis fest (T+X Tage, Konzept Schicht 4).
	 *
	 * @param int    $id      Datensatz-ID.
	 * @param string $outcome advanced | converted | none.
	 * @return bool Erfolg.
	 */
	public function set_outcome( int $id, string $outcome ): bool {
		if ( $id <= 0 || ! in_array( $outcome, self::OUTCOMES, true ) ) {
			return false;
		}
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->update(
			Schema::table( 'recommendations' ),
			array(
				'outcome'    => $outcome,
				'outcome_at' => current_time( 'mysql', true ),
			),
			array( 'id' => $id )
		);
		return (bool) $rows;
	}

	/**
	 * Protokolliert eine gezeigte Empfehlung nur einmal je (rule_key, context_hash)
	 * und liefert die Datensatz-ID der noch offenen Empfehlung (H2-Dedup). Semantik:
	 *
	 * - existiert bereits eine noch nicht entschiedene Zeile fuer diesen Kontext ->
	 *   deren ID (die Karte wird erneut mit derselben ID gezeigt, kein neuer Eintrag);
	 * - existiert eine bereits entschiedene Zeile (accepted/dismissed/expired) ->
	 *   0 (die Karte wird nicht erneut gezeigt, der Betreiber hat sie schon behandelt);
	 * - sonst: neuer Eintrag, dessen ID.
	 *
	 * So entsteht kein Spam bei jedem Seitenaufruf und "Nicht mehr vorschlagen"
	 * (dismissed) unterdrueckt die Karte dauerhaft.
	 *
	 * @param string $rule_key     Regel-Schluessel.
	 * @param string $context_hash Deterministischer Kontext-Hash.
	 * @return int Anzuzeigende Datensatz-ID, oder 0 (nicht zeigen / Fehler).
	 */
	public function record_once( string $rule_key, string $context_hash ): int {
		$rule_key     = substr( trim( $rule_key ), 0, 60 );
		$context_hash = substr( $context_hash, 0, 64 );
		if ( '' === $rule_key || '' === $context_hash ) {
			return 0;
		}

		$existing = $this->latest_for_context( $rule_key, $context_hash );
		if ( null !== $existing ) {
			// Schon entschieden -> nicht erneut zeigen; sonst die offene ID.
			return '' === (string) $existing['decision'] ? (int) $existing['id'] : 0;
		}

		return $this->record( $rule_key, $context_hash );
	}

	/**
	 * Juengste Zeile fuer einen Kontext (rule_key + context_hash), oder null.
	 *
	 * @param string $rule_key     Regel-Schluessel.
	 * @param string $context_hash Kontext-Hash.
	 * @return array<string, mixed>|null
	 */
	public function latest_for_context( string $rule_key, string $context_hash ): ?array {
		global $wpdb;
		$table = Schema::table( 'recommendations' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT id, rule_key, context_hash, created_at, decision, decided_at, outcome, outcome_at FROM {$table} WHERE rule_key = %s AND context_hash = %s ORDER BY id DESC LIMIT 1", $rule_key, $context_hash ), ARRAY_A );
		return $row ? $this->hydrate( $row ) : null;
	}

	/**
	 * Angenommene Empfehlungen ohne gemessenes Ergebnis, deren Entscheidung vor der
	 * Reifegrenze liegt (T+X Tage, OutcomeTracker Schicht 4). Liefert nur die zur
	 * Messung noetigen Felder (id, decided_at) - keine Kontaktdaten.
	 *
	 * @param string $decided_before_utc Reifegrenze: decided_at <= diese UTC-Zeit.
	 * @param int    $limit              Obergrenze je Durchlauf.
	 * @return array<int, array{id:int,decided_at:string}>
	 */
	public function accepted_awaiting_outcome( string $decided_before_utc, int $limit = 200 ): array {
		$limit = max( 1, min( 500, $limit ) );
		global $wpdb;
		$table = Schema::table( 'recommendations' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, decided_at FROM {$table} WHERE decision = 'accepted' AND outcome = '' AND decided_at IS NOT NULL AND decided_at <= %s ORDER BY id ASC LIMIT %d", $decided_before_utc, $limit ), ARRAY_A );
		$out  = array();
		foreach ( (array) $rows as $row ) {
			$out[] = array(
				'id'         => (int) $row['id'],
				'decided_at' => (string) $row['decided_at'],
			);
		}
		return $out;
	}

	/**
	 * Juengste Empfehlungen (fuer Anzeige/Debug).
	 *
	 * @param int $limit Anzahl (1..200).
	 * @return array<int, array<string, mixed>>
	 */
	public function recent( int $limit = 20 ): array {
		$limit = max( 1, min( 200, $limit ) );
		global $wpdb;
		$table = Schema::table( 'recommendations' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, rule_key, context_hash, created_at, decision, decided_at, outcome, outcome_at FROM {$table} ORDER BY id DESC LIMIT %d", $limit ), ARRAY_A );
		return array_map( array( $this, 'hydrate' ), $rows ?: array() );
	}

	/**
	 * Rohdaten der Wirkung einer Regel: Annahmequote und Uplift-Bestandteile
	 * (Ergebnis-Zaehlung). H2/H4 bilden daraus den gleitenden Wirkungs-Score;
	 * hier wird nur gezaehlt, nicht bewertet.
	 *
	 * @param string $rule_key Regel-Schluessel.
	 * @return array{
	 *   rule_key:string, total:int, accepted:int, dismissed:int, expired:int,
	 *   decided:int, acceptance_rate:float,
	 *   outcome_advanced:int, outcome_converted:int, outcome_none:int, outcome_measured:int
	 * }
	 */
	public function score_for_rule( string $rule_key ): array {
		$out = array(
			'rule_key'          => $rule_key,
			'total'             => 0,
			'accepted'          => 0,
			'dismissed'         => 0,
			'expired'           => 0,
			'decided'           => 0,
			'acceptance_rate'   => 0.0,
			'outcome_advanced'  => 0,
			'outcome_converted' => 0,
			'outcome_none'      => 0,
			'outcome_measured'  => 0,
		);
		if ( '' === trim( $rule_key ) ) {
			return $out;
		}

		global $wpdb;
		$table = Schema::table( 'recommendations' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( $wpdb->prepare( "SELECT decision, outcome, COUNT(*) AS total FROM {$table} WHERE rule_key = %s GROUP BY decision, outcome", $rule_key ), ARRAY_A );

		foreach ( (array) $rows as $row ) {
			$count    = (int) ( $row['total'] ?? 0 );
			$decision = (string) ( $row['decision'] ?? '' );
			$outcome  = (string) ( $row['outcome'] ?? '' );

			$out['total'] += $count;

			if ( 'accepted' === $decision ) {
				$out['accepted'] += $count;
			} elseif ( 'dismissed' === $decision ) {
				$out['dismissed'] += $count;
			} elseif ( 'expired' === $decision ) {
				$out['expired'] += $count;
			}

			if ( 'advanced' === $outcome ) {
				$out['outcome_advanced'] += $count;
			} elseif ( 'converted' === $outcome ) {
				$out['outcome_converted'] += $count;
			} elseif ( 'none' === $outcome ) {
				$out['outcome_none'] += $count;
			}
		}

		$out['decided']          = $out['accepted'] + $out['dismissed'] + $out['expired'];
		$out['outcome_measured'] = $out['outcome_advanced'] + $out['outcome_converted'] + $out['outcome_none'];
		$out['acceptance_rate']  = $out['decided'] > 0 ? round( $out['accepted'] / $out['decided'], 4 ) : 0.0;

		return $out;
	}

	/**
	 * Normalisiert eine Zeile.
	 *
	 * @param array<string, mixed> $row Rohzeile.
	 * @return array<string, mixed>
	 */
	private function hydrate( array $row ): array {
		return array(
			'id'           => (int) ( $row['id'] ?? 0 ),
			'rule_key'     => (string) ( $row['rule_key'] ?? '' ),
			'context_hash' => (string) ( $row['context_hash'] ?? '' ),
			'created_at'   => (string) ( $row['created_at'] ?? '' ),
			'decision'     => (string) ( $row['decision'] ?? '' ),
			'decided_at'   => isset( $row['decided_at'] ) && null !== $row['decided_at'] ? (string) $row['decided_at'] : '',
			'outcome'      => (string) ( $row['outcome'] ?? '' ),
			'outcome_at'   => isset( $row['outcome_at'] ) && null !== $row['outcome_at'] ? (string) $row['outcome_at'] : '',
		);
	}
}
