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

namespace Hafenstudios\Kurato\Db;

defined( 'ABSPATH' ) || exit;

/**
 * Abonnements (hs_subscriptions): ein Kontakt in einer Liste mit DOI-Status
 * (pending / confirmed / unsubscribed).
 *
 * Der einzige gueltige Vorwaerts-Uebergang ist pending -> confirmed; er ist
 * idempotent (confirmed -> confirmed ist ein No-op). Zusammen mit dem UNIQUE
 * (list_id, contact_id) macht das den Abo-Konsumenten robust gegen doppelte
 * lead_confirmed-Events und Replays (K4-Konzept Abschnitt 3).
 *
 * Nicht final, damit Tests die DB-Methoden per Test-Double ueberschreiben koennen.
 */
class SubscriptionRepository {

	/**
	 * Legt ein Abo an, falls es noch nicht existiert (idempotent).
	 *
	 * @param int    $list_id    Listen-ID.
	 * @param int    $contact_id Kontakt-ID.
	 * @param string $status     Startstatus (pending / confirmed).
	 * @param string $source     Herkunft (z.B. leadlotse, import, manual).
	 * @param int    $consent_id Consent-Referenz (0 = keine).
	 * @return int Abo-ID (0 bei Fehler).
	 */
	public function subscribe( int $list_id, int $contact_id, string $status = 'pending', string $source = '', int $consent_id = 0 ): int {
		if ( $list_id <= 0 || $contact_id <= 0 ) {
			return 0;
		}
		$existing = $this->find( $list_id, $contact_id );
		if ( null !== $existing ) {
			return (int) $existing['id'];
		}

		$status = in_array( $status, array( 'pending', 'confirmed', 'unsubscribed' ), true ) ? $status : 'pending';
		$now    = current_time( 'mysql', true );

		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$ok = $wpdb->insert(
			Schema::table( 'subscriptions' ),
			array(
				'list_id'       => $list_id,
				'contact_id'    => $contact_id,
				'status'        => $status,
				'consent_id'    => $consent_id > 0 ? $consent_id : null,
				'source'        => substr( sanitize_text_field( $source ), 0, 100 ),
				'subscribed_at' => $now,
				'confirmed_at'  => 'confirmed' === $status ? $now : null,
			)
		);
		if ( false === $ok ) {
			// UNIQUE-Race: paralleles Abo. Bestehendes zurueckgeben.
			$existing = $this->find( $list_id, $contact_id );
			return null !== $existing ? (int) $existing['id'] : 0;
		}
		return (int) $wpdb->insert_id;
	}

	/**
	 * Findet ein Abo nach Liste und Kontakt.
	 *
	 * @param int $list_id    Listen-ID.
	 * @param int $contact_id Kontakt-ID.
	 * @return array<string, mixed>|null
	 */
	public function find( int $list_id, int $contact_id ): ?array {
		global $wpdb;
		$table = Schema::table( 'subscriptions' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE list_id = %d AND contact_id = %d", $list_id, $contact_id ), ARRAY_A );
		return $row ? $this->hydrate( $row ) : null;
	}

	/**
	 * Eine Subscription per ID.
	 *
	 * @param int $id Abo-ID.
	 * @return array<string, mixed>|null
	 */
	public function get( int $id ): ?array {
		global $wpdb;
		$table = Schema::table( 'subscriptions' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
		return $row ? $this->hydrate( $row ) : null;
	}

	/**
	 * Bestaetigt ein Abo (nur der Uebergang pending -> confirmed wirkt).
	 *
	 * @param int $id         Abo-ID.
	 * @param int $consent_id Consent-Referenz.
	 * @return bool true, wenn JETZT bestaetigt wurde (vorher pending).
	 */
	public function confirm( int $id, int $consent_id = 0 ): bool {
		global $wpdb;
		$data = array(
			'status'       => 'confirmed',
			'confirmed_at' => current_time( 'mysql', true ),
			'doi_token_hash' => '',
		);
		if ( $consent_id > 0 ) {
			$data['consent_id'] = $consent_id;
		}
		// Nur pending -> confirmed: die WHERE-Klausel macht den Uebergang idempotent.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$affected = $wpdb->update(
			Schema::table( 'subscriptions' ),
			$data,
			array( 'id' => $id, 'status' => 'pending' )
		);
		return is_int( $affected ) && $affected > 0;
	}

	/**
	 * Bestaetigt alle offenen (pending) Abos eines Kontakts. Fuer den
	 * lead_confirmed-Weg, wo nur der Kontakt bekannt ist.
	 *
	 * @param int $contact_id Kontakt-ID.
	 * @param int $consent_id Consent-Referenz.
	 * @return int Anzahl JETZT bestaetigter Abos.
	 */
	public function confirm_pending_for_contact( int $contact_id, int $consent_id = 0 ): int {
		global $wpdb;
		$table = Schema::table( 'subscriptions' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$table} WHERE contact_id = %d AND status = 'pending'", $contact_id ) );
		$count = 0;
		foreach ( array_map( 'intval', $ids ?: array() ) as $id ) {
			if ( $this->confirm( $id, $consent_id ) ) {
				$count++;
			}
		}
		return $count;
	}

	/**
	 * Findet ein Abo per DOI-Token-Hash (Kurato-eigener Bestaetigungs-Endpoint).
	 *
	 * @param string $hash Token-Hash.
	 * @return array<string, mixed>|null
	 */
	public function find_by_token_hash( string $hash ): ?array {
		if ( '' === $hash ) {
			return null;
		}
		global $wpdb;
		$table = Schema::table( 'subscriptions' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE doi_token_hash = %s", $hash ), ARRAY_A );
		return $row ? $this->hydrate( $row ) : null;
	}

	/**
	 * Speichert den DOI-Token-Hash an einem Abo.
	 *
	 * @param int    $id   Abo-ID.
	 * @param string $hash Token-Hash.
	 * @return void
	 */
	public function set_doi_token_hash( int $id, string $hash ): void {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update( Schema::table( 'subscriptions' ), array( 'doi_token_hash' => substr( $hash, 0, 64 ) ), array( 'id' => $id ) );
	}

	/**
	 * Meldet ein Abo ab.
	 *
	 * @param int $list_id    Listen-ID.
	 * @param int $contact_id Kontakt-ID.
	 * @return bool
	 */
	public function unsubscribe( int $list_id, int $contact_id ): bool {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$affected = $wpdb->update(
			Schema::table( 'subscriptions' ),
			array( 'status' => 'unsubscribed', 'unsubscribed_at' => current_time( 'mysql', true ) ),
			array( 'list_id' => $list_id, 'contact_id' => $contact_id )
		);
		return is_int( $affected ) && $affected > 0;
	}

	/**
	 * Meldet ein zuvor bestaetigtes, jetzt abgemeldetes Abo wieder an
	 * (Preference Center, K4d-2).
	 *
	 * Der Uebergang wirkt NUR, wenn das Abo aktuell `unsubscribed` ist; das
	 * WHERE macht ihn idempotent. Die Zusatzbedingung "war schon einmal
	 * bestaetigt" (confirmed_at gesetzt) prueft der Aufrufer VOR diesem Aufruf,
	 * damit kein Neu-Abo ohne Double-Opt-in entsteht. `confirmed_at` bleibt
	 * erhalten (der urspruengliche Einwilligungszeitpunkt), `unsubscribed_at`
	 * wird geleert.
	 *
	 * @param int $list_id    Listen-ID.
	 * @param int $contact_id Kontakt-ID.
	 * @return bool true, wenn ein abgemeldetes Abo wieder auf confirmed ging.
	 */
	public function resubscribe( int $list_id, int $contact_id ): bool {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$affected = $wpdb->update(
			Schema::table( 'subscriptions' ),
			array( 'status' => 'confirmed', 'unsubscribed_at' => null ),
			array( 'list_id' => $list_id, 'contact_id' => $contact_id, 'status' => 'unsubscribed' )
		);
		return is_int( $affected ) && $affected > 0;
	}

	/**
	 * Meldet alle Abos eines Kontakts ab (globale Abmeldung).
	 *
	 * @param int $contact_id Kontakt-ID.
	 * @return int Anzahl abgemeldeter Abos.
	 */
	public function unsubscribe_all( int $contact_id ): int {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$affected = $wpdb->update(
			Schema::table( 'subscriptions' ),
			array( 'status' => 'unsubscribed', 'unsubscribed_at' => current_time( 'mysql', true ) ),
			array( 'contact_id' => $contact_id )
		);
		return is_int( $affected ) ? $affected : 0;
	}

	/**
	 * Abos eines Kontakts mit Listen-Label (fuers Kontaktprofil und Preference Center).
	 *
	 * @param int $contact_id Kontakt-ID.
	 * @return array<int, array<string, mixed>>
	 */
	public function for_contact( int $contact_id ): array {
		global $wpdb;
		$subs  = Schema::table( 'subscriptions' );
		$lists = Schema::table( 'lists' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT s.*, l.label AS list_label FROM {$subs} s INNER JOIN {$lists} l ON l.id = s.list_id WHERE s.contact_id = %d ORDER BY l.label ASC",
				$contact_id
			),
			ARRAY_A
		);
		return array_map(
			function ( array $row ): array {
				$sub               = $this->hydrate( $row );
				$sub['list_label'] = (string) $row['list_label'];
				return $sub;
			},
			$rows ?: array()
		);
	}

	/**
	 * Kontakt-IDs mit bestaetigtem Abo einer Liste (Kampagnen-Materialisierung).
	 *
	 * Nur `confirmed`: Marketing-Mails gehen ausschliesslich an bestaetigte Abos
	 * (K4-Konzept Abschnitt 4). Suppression (email_status) wird spaeter je Kontakt
	 * geprueft, nicht hier.
	 *
	 * @param int $list_id Listen-ID.
	 * @return array<int, int>
	 */
	public function confirmed_contact_ids( int $list_id ): array {
		if ( $list_id <= 0 ) {
			return array();
		}
		global $wpdb;
		$table = Schema::table( 'subscriptions' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$ids = $wpdb->get_col( $wpdb->prepare( "SELECT contact_id FROM {$table} WHERE list_id = %d AND status = 'confirmed'", $list_id ) );
		return array_map( 'intval', $ids ?: array() );
	}

	/**
	 * Kontakt-IDs mit mindestens einem bestaetigten Abo (irgendeine Liste).
	 *
	 * Grundlage der Segment-Materialisierung: Segment-Treffer duerfen nur dann eine
	 * Marketing-Mail erhalten, wenn sie irgendwo bestaetigt abonniert haben
	 * (K4-Konzept Abschnitt 4, INVARIANTE: nur confirmed). Suppression je Kontakt
	 * wird spaeter geprueft, nicht hier.
	 *
	 * @return array<int, int>
	 */
	public function confirmed_contact_ids_any(): array {
		global $wpdb;
		$table = Schema::table( 'subscriptions' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$ids = $wpdb->get_col( "SELECT DISTINCT contact_id FROM {$table} WHERE status = 'confirmed'" );
		return array_map( 'intval', $ids ?: array() );
	}

	/**
	 * Loescht alle Abos eines Kontakts (Recht auf Vergessen, PrivacyBridge).
	 *
	 * @param int $contact_id Kontakt-ID.
	 * @return void
	 */
	public function delete_by_contact( int $contact_id ): void {
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->delete( Schema::table( 'subscriptions' ), array( 'contact_id' => $contact_id ) );
	}

	/**
	 * 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'],
			'list_id'         => (int) $row['list_id'],
			'contact_id'      => (int) $row['contact_id'],
			'status'          => (string) $row['status'],
			'doi_token_hash'  => (string) ( $row['doi_token_hash'] ?? '' ),
			'consent_id'      => null !== $row['consent_id'] ? (int) $row['consent_id'] : null,
			'source'          => (string) $row['source'],
			'subscribed_at'   => (string) $row['subscribed_at'],
			'confirmed_at'    => null !== $row['confirmed_at'] ? (string) $row['confirmed_at'] : '',
			'unsubscribed_at' => null !== $row['unsubscribed_at'] ? (string) $row['unsubscribed_at'] : '',
		);
	}
}
