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

namespace Hafenstudios\Wellenbrecher\Db;

defined( 'ABSPATH' ) || exit;

/**
 * Zugriff auf hs_spam_log (Einzelereignisse, kurzlebig).
 *
 * Datenschutz-Invarianten dieses Repositories: ip_hmac ist bereits ein HMAC
 * (nie Klartext), email_domain ist nur die Domain, excerpt wird auf 500
 * Zeichen gekuerzt. payload ist NUR fuer quarantinierte Leadlotse-Submits
 * gedacht (Restore-Weg, W3).
 */
class SpamLogRepository {

	public const VERDICT_BLOCKED     = 'blocked';
	public const VERDICT_QUARANTINED = 'quarantined';
	public const VERDICT_FLAGGED     = 'flagged';

	/**
	 * Tabellenname.
	 *
	 * @return string
	 */
	public function table(): string {
		return Schema::table( 'spam_log' );
	}

	/**
	 * Schreibt ein Ereignis. reasons darf als Array kommen (wird JSON).
	 *
	 * @param array<string, mixed> $data Ereignisdaten.
	 * @return int Neue ID (0 bei Fehler).
	 */
	public function insert( array $data ): int {
		global $wpdb;

		$reasons = $data['reasons'] ?? array();
		if ( is_array( $reasons ) ) {
			$reasons = (string) wp_json_encode( $reasons );
		}

		$excerpt = isset( $data['excerpt'] ) ? (string) $data['excerpt'] : null;
		if ( null !== $excerpt && mb_strlen( $excerpt ) > 500 ) {
			$excerpt = mb_substr( $excerpt, 0, 500 );
		}

		$row = array(
			'channel'      => (string) ( $data['channel'] ?? '' ),
			'verdict'      => (string) ( $data['verdict'] ?? self::VERDICT_FLAGGED ),
			'score'        => (int) ( $data['score'] ?? 0 ),
			'reasons'      => (string) $reasons,
			'ip_hmac'      => isset( $data['ip_hmac'] ) && '' !== $data['ip_hmac'] ? (string) $data['ip_hmac'] : null,
			'email_domain' => isset( $data['email_domain'] ) && '' !== $data['email_domain'] ? (string) $data['email_domain'] : null,
			'excerpt'      => $excerpt,
			'payload'      => isset( $data['payload'] ) ? (string) $data['payload'] : null,
			'form_id'      => isset( $data['form_id'] ) ? (int) $data['form_id'] : null,
			'comment_id'   => isset( $data['comment_id'] ) ? (int) $data['comment_id'] : null,
			'created_at'   => (string) ( $data['created_at'] ?? current_time( 'mysql', true ) ),
		);

		$result = $wpdb->insert( $this->table(), $row );
		return false === $result ? 0 : (int) $wpdb->insert_id;
	}

	/**
	 * Liest Ereignisse mit Pagination und Filtern.
	 *
	 * @param array<string, mixed> $args channel, verdict, page, per_page.
	 * @return array{items: array<int, array<string, mixed>>, total: int, page: int, per_page: int}
	 */
	public function query( array $args = array() ): array {
		global $wpdb;

		$page     = max( 1, (int) ( $args['page'] ?? 1 ) );
		$per_page = min( 100, max( 1, (int) ( $args['per_page'] ?? 20 ) ) );

		$where  = array();
		$params = array();

		if ( ! empty( $args['channel'] ) ) {
			$where[]  = 'channel = %s';
			$params[] = (string) $args['channel'];
		}
		if ( ! empty( $args['verdict'] ) ) {
			$where[]  = 'verdict = %s';
			$params[] = (string) $args['verdict'];
		}

		$where_sql = $where ? ' WHERE ' . implode( ' AND ', $where ) : '';
		$table     = $this->table();

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Tabellenname/WHERE aus eigener Whitelist, Werte prepared.
		$count_sql = "SELECT COUNT(*) FROM {$table}{$where_sql}";
		$total     = (int) $wpdb->get_var( $params ? $wpdb->prepare( $count_sql, $params ) : $count_sql );

		$list_sql    = "SELECT * FROM {$table}{$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d";
		$list_params = array_merge( $params, array( $per_page, ( $page - 1 ) * $per_page ) );
		$items       = (array) $wpdb->get_results( $wpdb->prepare( $list_sql, $list_params ), ARRAY_A );
		// phpcs:enable

		return array(
			'items'    => array_map( array( $this, 'hydrate' ), $items ),
			'total'    => $total,
			'page'     => $page,
			'per_page' => $per_page,
		);
	}

	/**
	 * Loescht nach den DSGVO-Fristen: blocked/flagged nach X Tagen,
	 * quarantined nach Y Tagen.
	 *
	 * @param int      $block_days      Frist fuer blocked/flagged.
	 * @param int      $quarantine_days Frist fuer quarantined.
	 * @param int|null $now             Bezugszeit (Default: jetzt, fuer Tests).
	 * @return int Anzahl geloeschter Zeilen.
	 */
	public function purge( int $block_days, int $quarantine_days, ?int $now = null ): int {
		global $wpdb;

		$now     = $now ?? time();
		$table   = $this->table();
		$deleted = 0;

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Tabellenname aus eigener Definition, Werte prepared.
		$deleted += (int) $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE verdict IN ('blocked','flagged') AND created_at < %s",
				gmdate( 'Y-m-d H:i:s', $now - ( max( 0, $block_days ) * DAY_IN_SECONDS ) )
			)
		);
		$deleted += (int) $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table} WHERE verdict = 'quarantined' AND created_at < %s",
				gmdate( 'Y-m-d H:i:s', $now - ( max( 0, $quarantine_days ) * DAY_IN_SECONDS ) )
			)
		);
		// phpcs:enable

		return $deleted;
	}

	/**
	 * Liest einen einzelnen Eintrag nach ID (fuer die Quarantaene-Wiederherstellung).
	 *
	 * @param int $id Eintrags-ID.
	 * @return array<string, mixed>|null Null, wenn kein Eintrag existiert.
	 */
	public function find( int $id ): ?array {
		global $wpdb;

		if ( $id <= 0 ) {
			return null;
		}
		$table = $this->table();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Tabellenname aus eigener Definition, Wert prepared.
		$rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
		$row  = $rows[0] ?? null;
		return is_array( $row ) ? $this->hydrate( $row ) : null;
	}

	/**
	 * Loescht Eintraege nach IDs (Bulk-Loeschen der Block-Log-Seite).
	 *
	 * @param array<int, int> $ids Eintrags-IDs.
	 * @return int Anzahl geloeschter Zeilen.
	 */
	public function delete( array $ids ): int {
		global $wpdb;

		$ids = array_values( array_filter( array_map( 'intval', $ids ) ) );
		if ( array() === $ids ) {
			return 0;
		}
		$table = $this->table();
		$list  = implode( ',', $ids );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Tabellenname aus eigener Definition, IDs sind int-gecastet.
		return (int) $wpdb->query( "DELETE FROM {$table} WHERE id IN ({$list})" );
	}

	/**
	 * Findet Quarantaene-Zeilen mit Klartext-Payload, deren dekodierter Payload
	 * die angefragte E-Mail enthaelt (DSGVO-Export/-Loeschung, Bauplan Kap. 9).
	 * Zweistufig: grober LIKE-Vorfilter auf der payload-Spalte, dann exakte
	 * Pruefung im dekodierten JSON, damit kein Teilstring-Zufallstreffer aus der
	 * JSON-Kodierung durchrutscht. Payloads sind der EINZIGE Klartext-Personen-
	 * bezug im Plugin; alle uebrigen Log-Zeilen sind pseudonymisiert.
	 *
	 * @param string $email E-Mail.
	 * @return array<int, array<string, mixed>> Hydratisierte Zeilen mit exaktem Treffer.
	 */
	public function find_payload_rows_by_email( string $email ): array {
		global $wpdb;

		$email = trim( $email );
		if ( '' === $email ) {
			return array();
		}

		$table = $this->table();
		$like  = '%' . $wpdb->esc_like( $email ) . '%';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Tabellenname aus eigener Definition, Wert prepared.
		$rows = (array) $wpdb->get_results(
			$wpdb->prepare( "SELECT * FROM {$table} WHERE payload IS NOT NULL AND payload LIKE %s", $like ),
			ARRAY_A
		);

		$matches = array();
		foreach ( $rows as $row ) {
			if ( self::payload_contains_email( (string) ( $row['payload'] ?? '' ), $email ) ) {
				$matches[] = $this->hydrate( $row );
			}
		}
		return $matches;
	}

	/**
	 * Exakte E-Mail-Pruefung im dekodierten Payload: die E-Mail muss als
	 * (Teil-)Wert eines Feldes vorkommen (case-insensitiv). Verhindert falsche
	 * Treffer des groben LIKE-Vorfilters.
	 *
	 * @param string $payload_json Roh-JSON aus der Zeile.
	 * @param string $email        Gesuchte E-Mail.
	 * @return bool
	 */
	public static function payload_contains_email( string $payload_json, string $email ): bool {
		$decoded = json_decode( $payload_json, true );
		if ( ! is_array( $decoded ) ) {
			return false;
		}
		$needle = strtolower( trim( $email ) );
		if ( '' === $needle ) {
			return false;
		}
		$found = false;
		array_walk_recursive(
			$decoded,
			static function ( $value ) use ( $needle, &$found ): void {
				if ( is_scalar( $value ) && false !== strpos( strtolower( (string) $value ), $needle ) ) {
					$found = true;
				}
			}
		);
		return $found;
	}

	/**
	 * Wiederholungstaeter-Lookup: gibt es zum IP-HMAC bereits einen
	 * geblockten oder quarantinierten Eintrag? flagged zaehlt bewusst
	 * nicht (Beobachtungsmodus darf sich nicht selbst verstaerken).
	 *
	 * @param string $ip_hmac IP-HMAC (aktueller Wochen-Salt).
	 * @return bool
	 */
	public function has_spam_match( string $ip_hmac ): bool {
		global $wpdb;

		if ( '' === $ip_hmac ) {
			return false;
		}
		$table = $this->table();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Tabellenname aus eigener Definition, Wert prepared.
		$count = (int) $wpdb->get_var(
			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE ip_hmac = %s AND verdict IN ('blocked','quarantined')", $ip_hmac )
		);
		return $count > 0;
	}

	/**
	 * PoW-Eskalation (Technik A3): hat der IP-HMAC in den letzten $within_seconds
	 * einen Eintrag im Spam-Log (jeder Verdict ist ein Nicht-pass)? Nur dann
	 * spielt der Token-Endpoint eine Proof-of-Work-Challenge aus; unauffaellige
	 * Besucher rechnen nie.
	 *
	 * @param string   $ip_hmac        IP-HMAC (aktueller Wochen-Salt).
	 * @param int      $within_seconds Rueckblick-Fenster in Sekunden.
	 * @param int|null $now            Bezugszeit (Default: jetzt, fuer Tests).
	 * @return bool
	 */
	public function has_recent_match( string $ip_hmac, int $within_seconds, ?int $now = null ): bool {
		global $wpdb;

		if ( '' === $ip_hmac ) {
			return false;
		}
		$now   = $now ?? time();
		$since = gmdate( 'Y-m-d H:i:s', $now - max( 0, $within_seconds ) );
		$table = $this->table();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Tabellenname aus eigener Definition, Werte prepared.
		$count = (int) $wpdb->get_var(
			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE ip_hmac = %s AND created_at >= %s", $ip_hmac, $since )
		);
		return $count > 0;
	}

	/**
	 * Dekodiert eine Zeile (reasons als Array).
	 *
	 * @param array<string, mixed> $row DB-Zeile.
	 * @return array<string, mixed>
	 */
	protected function hydrate( array $row ): array {
		$reasons        = json_decode( (string) ( $row['reasons'] ?? '' ), true );
		$row['reasons'] = is_array( $reasons ) ? $reasons : array();
		$row['score']   = (int) ( $row['score'] ?? 0 );
		return $row;
	}
}
