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

namespace Hafenstudios\Wellenbrecher\Channels;

use Hafenstudios\Core\Settings\Settings;
use Hafenstudios\Wellenbrecher\Db\CommentLookup;
use Hafenstudios\Wellenbrecher\Db\SpamLogRepository;
use Hafenstudios\Wellenbrecher\Db\StatsRepository;
use Hafenstudios\Wellenbrecher\Engine\Context;
use Hafenstudios\Wellenbrecher\Engine\Verdict;
use Hafenstudios\Wellenbrecher\Signals\FieldRotation;
use Hafenstudios\Wellenbrecher\Support\Allowlist;
use Hafenstudios\Wellenbrecher\Support\IpHasher;
use Hafenstudios\Wellenbrecher\Support\ObservationMode;
use Hafenstudios\Wellenbrecher\Support\RequestSnapshot;
use Hafenstudios\Wellenbrecher\Support\SpamRecorder;
use Hafenstudios\Wellenbrecher\Support\Text;

defined( 'ABSPATH' ) || exit;

/**
 * Kommentar-Kanal (Bauplan Kap. 7.1): duenner Adapter um die Score-Engine.
 *
 * - init: Feld-Tausch-Mapping ($_POST[rotierter Name] -> comment), muss VOR
 *   wp_handle_comment_submission laufen, das den Inhalt aus 'comment' liest.
 * - preprocess_comment Prio 1: Context bauen, Engine EINMAL ausfuehren,
 *   Verdict zwischenspeichern. Zone block (ausserhalb des Beobachtungsmodus):
 *   Log + Stats + wp_die() mit 403 VOR jedem DB-Write.
 * - pre_comment_approved Prio 99: Zone quarantine -> 'spam'; bestehende
 *   Kern-Urteile ('spam'/'trash', z.B. Disallowed-List) respektieren,
 *   nie nach oben korrigieren.
 * - comment_post: Score-Breakdown als Comment-Meta (_wb_score/_wb_reasons),
 *   quarantined/flagged in Log + Stats.
 * - transition_comment_status: Lern-Feedback light (Allowlist, Stats).
 * - xmlrpc_methods: Option, pingback.ping und wp.newComment zu entfernen.
 *
 * Fail-open (Produktversprechen Nr. 6): wirft die Bewertung eine Exception,
 * laeuft der Kommentar unbewertet in die normale WP-Verarbeitung.
 */
class CommentChannel {

	protected \Closure $engine_factory;

	protected Settings $settings;

	protected SpamLogRepository $log;

	protected StatsRepository $stats;

	protected IpHasher $hasher;

	protected FieldRotation $rotation;

	protected Allowlist $allowlist;

	protected ObservationMode $observation;

	protected CommentLookup $lookup;

	protected RequestSnapshot $snapshots;

	/**
	 * Zwischengespeichertes Verdict des aktuellen Requests.
	 *
	 * @var Verdict|null
	 */
	protected ?Verdict $verdict = null;

	/**
	 * Honeypot-Wert (Inhalt des originalen 'comment'-Felds beim Feld-Tausch).
	 * Bewusst untypisiert: ein Array im Feld ist genauso ein Bot-Signal.
	 *
	 * @var mixed
	 */
	protected $honeypot_value = '';

	/**
	 * War der rotierte Feldname im POST vorhanden (Feld-Tausch intakt)?
	 */
	protected bool $swap_seen = false;

	/**
	 * Log-Basisdaten des bewerteten Requests (channel, ip_hmac, email_domain, excerpt).
	 *
	 * @var array<string, string>
	 */
	protected array $log_base = array();

	/**
	 * @param \Closure          $engine_factory Liefert die ScoreEngine (lazy, damit die
	 *                                          Regel-Listen nicht auf jedem Request laden).
	 * @param Settings          $settings       Wellenbrecher-Settings.
	 * @param SpamLogRepository $log            Spam-Log.
	 * @param StatsRepository   $stats          Tageszaehler.
	 * @param IpHasher          $hasher         IP-HMAC-Helfer.
	 * @param FieldRotation     $rotation       Rotierende Feldnamen.
	 * @param Allowlist         $allowlist      Rehabilitations-Allowlist.
	 * @param ObservationMode   $observation    Beobachtungsmodus.
	 * @param CommentLookup     $lookup         Kommentar-Abfragen (Trust + localdb).
	 * @param RequestSnapshot   $snapshots      Server-Snapshot-Fabrik.
	 */
	public function __construct(
		\Closure $engine_factory,
		Settings $settings,
		SpamLogRepository $log,
		StatsRepository $stats,
		IpHasher $hasher,
		FieldRotation $rotation,
		Allowlist $allowlist,
		ObservationMode $observation,
		CommentLookup $lookup,
		RequestSnapshot $snapshots
	) {
		$this->engine_factory = $engine_factory;
		$this->settings       = $settings;
		$this->log            = $log;
		$this->stats          = $stats;
		$this->hasher         = $hasher;
		$this->rotation       = $rotation;
		$this->allowlist      = $allowlist;
		$this->observation    = $observation;
		$this->lookup         = $lookup;
		$this->snapshots      = $snapshots;
	}

	/**
	 * Registriert alle Hooks des Kanals.
	 *
	 * @return void
	 */
	public function register(): void {
		add_action( 'init', array( $this, 'map_swapped_fields' ) );
		add_filter( 'preprocess_comment', array( $this, 'on_preprocess' ), 1 );
		add_filter( 'pre_comment_approved', array( $this, 'on_pre_approved' ), 99, 2 );
		add_action( 'comment_post', array( $this, 'on_comment_post' ), 10, 2 );
		add_action( 'transition_comment_status', array( $this, 'on_transition' ), 10, 3 );
		add_filter( 'xmlrpc_methods', array( $this, 'filter_xmlrpc_methods' ) );
	}

	/**
	 * Feld-Tausch-Mapping (init, vor wp_handle_comment_submission): steht der
	 * echte Inhalt unter dem rotierten Namen (heute ODER gestern akzeptiert),
	 * wird er zurueck nach 'comment' gemappt; der bisherige 'comment'-Wert ist
	 * der Honeypot-Wert.
	 *
	 * @return void
	 */
	public function map_swapped_fields(): void {
		if ( empty( $_POST ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Spam-Pruefung oeffentlicher Submits, kein Nonce-Kontext.
			return;
		}

		$now   = time();
		$names = array(
			$this->rotation->name( FieldRotation::PURPOSE_FIELD, $now ),
			$this->rotation->name( FieldRotation::PURPOSE_FIELD, $now - DAY_IN_SECONDS ),
		);

		foreach ( $names as $name ) {
			if ( ! isset( $_POST[ $name ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
				continue;
			}
			$this->swap_seen      = true;
			$this->honeypot_value = $_POST['comment'] ?? ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- Roh-Signal fuer die Engine, wird nie ausgegeben.
			$_POST['comment']     = $_POST[ $name ]; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- WP-Kern sanitisiert den Kommentarinhalt selbst.
			unset( $_POST[ $name ] );
			break;
		}
	}

	/**
	 * preprocess_comment Prio 1: bewerten, Verdict merken, Zone block
	 * (ausserhalb des Beobachtungsmodus) sofort mit 403 beenden.
	 *
	 * @param array<string, mixed> $commentdata Rohe Kommentardaten.
	 * @return array<string, mixed>
	 */
	public function on_preprocess( $commentdata ) {
		if ( ! is_array( $commentdata ) ) {
			return $commentdata;
		}

		try {
			$context       = $this->build_context( $commentdata );
			$this->verdict = ( $this->engine_factory )()->assess( $context );

			$this->log_base = array(
				'channel'      => $context->channel,
				'ip_hmac'      => $this->hasher->hash( $context->server( 'remote_addr' ) ),
				'email_domain' => Text::email_domain( $context->field( 'author_email' ) ),
				'excerpt'      => mb_substr( $context->field( 'content' ), 0, 200 ),
			);
		} catch ( \Throwable $e ) {
			// Fail-open: ein interner Fehler laesst den Kommentar durch.
			error_log( 'Wellenbrecher: Kommentar-Bewertung uebersprungen (' . $e->getMessage() . ')' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			$this->verdict = null;
			return $commentdata;
		}

		if ( Verdict::ZONE_BLOCK === $this->verdict->zone && ! $this->observation->is_active() ) {
			$this->record( SpamLogRepository::VERDICT_BLOCKED, null );
			// Bewusst knapp, verraet keine Erkennungsdetails (Bauplan Kap. 7.1).
			wp_die(
				esc_html__( 'Ihr Kommentar konnte nicht angenommen werden.', 'wellenbrecher' ),
				'',
				array( 'response' => 403 )
			);
		}

		return $commentdata;
	}

	/**
	 * pre_comment_approved Prio 99: Zone quarantine -> 'spam'. Kern-Urteile
	 * ('spam'/'trash', WP_Error aus dem Flood-Check) bleiben unangetastet.
	 *
	 * @param mixed                $approved    Bisheriges Urteil (1|0|'spam'|'trash'|WP_Error).
	 * @param array<string, mixed> $commentdata Kommentardaten.
	 * @return mixed
	 */
	public function on_pre_approved( $approved, $commentdata ) {
		if ( null === $this->verdict ) {
			return $approved;
		}
		if ( is_wp_error( $approved ) || 'spam' === $approved || 'trash' === $approved ) {
			return $approved;
		}
		if ( $this->observation->is_active() ) {
			// Beobachtungsmodus: alles laeuft in die normale WP-Moderation.
			return $approved;
		}
		if ( Verdict::ZONE_QUARANTINE === $this->verdict->zone ) {
			return 'spam';
		}
		return $approved;
	}

	/**
	 * Nach dem Insert: Score-Breakdown als Comment-Meta, quarantined/flagged
	 * in Log + Stats.
	 *
	 * @param int|string $comment_id Kommentar-ID.
	 * @param mixed      $approved   Urteil beim Insert.
	 * @return void
	 */
	public function on_comment_post( $comment_id, $approved = null ): void {
		if ( null === $this->verdict ) {
			return;
		}
		$verdict    = $this->verdict;
		$comment_id = (int) $comment_id;

		if ( Verdict::ZONE_PASS === $verdict->zone && 0 === $verdict->score ) {
			return;
		}

		add_comment_meta( $comment_id, '_wb_score', $verdict->score, true );
		add_comment_meta( $comment_id, '_wb_reasons', $verdict->reasons, true );

		if ( Verdict::ZONE_PASS === $verdict->zone ) {
			return;
		}

		if ( $this->observation->is_active() ) {
			$this->record( SpamLogRepository::VERDICT_FLAGGED, $comment_id );
		} elseif ( Verdict::ZONE_QUARANTINE === $verdict->zone ) {
			$this->record( SpamLogRepository::VERDICT_QUARANTINED, $comment_id );
		}
	}

	/**
	 * Lern-Feedback light: spam -> approved traegt die E-Mail in die
	 * Allowlist ein (TTL 90 Tage); approved -> spam entfernt den Eintrag
	 * und zaehlt nur die Statistik (kein Bayes, das ist W5).
	 *
	 * @param string $new_status Neuer Status.
	 * @param string $old_status Alter Status.
	 * @param object $comment    Kommentar-Objekt.
	 * @return void
	 */
	public function on_transition( $new_status, $old_status, $comment ): void {
		$email = is_object( $comment ) ? trim( (string) ( $comment->comment_author_email ?? '' ) ) : '';

		if ( 'approved' === $new_status && 'spam' === $old_status ) {
			if ( '' !== $email ) {
				$this->allowlist->add( $email );
			}
			return;
		}

		if ( 'spam' === $new_status && 'approved' === $old_status ) {
			if ( '' !== $email ) {
				$this->allowlist->remove( $email );
			}
			$type = is_object( $comment ) ? (string) ( $comment->comment_type ?? '' ) : '';
			$this->stats->increment( $this->channel_for( $type ), 'manual_spam' );
		}
	}

	/**
	 * XML-RPC-Option (Bauplan Kap. 7.1): entfernt pingback.ping und
	 * wp.newComment, wenn das Setting aktiv ist. Die Datei selbst wird
	 * nie geblockt (Jetpack/Apps brauchen XML-RPC).
	 *
	 * @param mixed $methods Registrierte XML-RPC-Methoden.
	 * @return mixed
	 */
	public function filter_xmlrpc_methods( $methods ) {
		if ( ! is_array( $methods ) || empty( $this->settings->get( 'xmlrpc.disable_comments', false ) ) ) {
			return $methods;
		}
		unset( $methods['pingback.ping'], $methods['wp.newComment'] );
		return $methods;
	}

	/**
	 * Das zwischengespeicherte Verdict (fuer Tests und Diagnose).
	 *
	 * @return Verdict|null
	 */
	public function verdict(): ?Verdict {
		return $this->verdict;
	}

	/**
	 * Baut den Engine-Context aus Kommentardaten, $_SERVER und $_POST.
	 *
	 * @param array<string, mixed> $commentdata Rohe Kommentardaten.
	 * @return Context
	 */
	protected function build_context( array $commentdata ): Context {
		$channel = $this->channel_for( (string) ( $commentdata['comment_type'] ?? '' ) );

		$server = $this->snapshots->build(
			$_SERVER,
			(string) $this->settings->get( 'proxy.trusted_header', '' )
		);

		$fields = array(
			'content'      => (string) ( $commentdata['comment_content'] ?? '' ),
			'author_name'  => (string) ( $commentdata['comment_author'] ?? '' ),
			'author_url'   => (string) ( $commentdata['comment_author_url'] ?? '' ),
			'author_email' => (string) ( $commentdata['comment_author_email'] ?? '' ),
		);

		$signals = array(
			'token'                 => $this->post_value( 'wb_token' ),
			'js_timestamp'          => $this->post_value( 'wb_js' ),
			'events'                => $this->post_value( 'wb_events' ),
			'honeypot'              => $this->honeypot_value,
			'original_field_posted' => $this->original_field_posted( $channel ),
			// Proof-of-Work-Loesung (Pro); ohne registrierte PowRule ungenutzt.
			'pow_solution'          => $this->post_value( 'wb_pow' ),
		);

		$email   = $fields['author_email'];
		$user_id = (int) ( $commentdata['user_id'] ?? $commentdata['user_ID'] ?? 0 );

		$user = array(
			'can_moderate'         => $this->can_moderate(),
			'has_approved_comment' => '' !== $email && $this->lookup->has_approved_comment( $email ),
			'has_history'          => $this->user_has_history( $user_id ),
			// Verifizierter Woo-Kaeufer (nur Kanal woo_review, nur mit aktivem
			// WooCommerce; ohne Woo bleibt es false, alles laeuft wie bisher).
			'is_verified_buyer'    => 'woo_review' === $channel
				&& $this->lookup->is_verified_buyer( $email, $user_id, (int) ( $commentdata['comment_post_ID'] ?? 0 ) ),
			'is_allowlisted'       => '' !== $email && $this->allowlist->contains( $email ),
		);

		$meta = array(
			'post_id'          => (int) ( $commentdata['comment_post_ID'] ?? 0 ),
			// Trackbacks/Pingbacks, XML-RPC und REST haben kein Formular:
			// Token-/JS-Regeln neutralisieren (sonst False Positives).
			'skip_token_rules' => 'trackback' === $channel
				|| ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
				|| ( defined( 'REST_REQUEST' ) && REST_REQUEST ),
		);

		return new Context( $channel, $fields, $server, $signals, $user, $meta );
	}

	/**
	 * comment_type-Zuordnung: normale Kommentare -> comment, Trackbacks/
	 * Pingbacks -> trackback, review -> woo_review (laeuft ohne Zusatzarbeit
	 * mit).
	 *
	 * @param string $type comment_type aus den Kommentardaten.
	 * @return string
	 */
	protected function channel_for( string $type ): string {
		if ( 'trackback' === $type || 'pingback' === $type ) {
			return 'trackback';
		}
		if ( 'review' === $type ) {
			return 'woo_review';
		}
		return 'comment';
	}

	/**
	 * Feld-Tausch umgangen? Nur ein Treffer, wenn der Tausch auf dieser Site
	 * nachweislich kuerzlich gerendert wurde (Options-Zeitstempel aus
	 * CommentFormFields, Fenster 2 Tage wie die Feldnamen-Generationen).
	 * Rendert ein Theme das Feld selbst, gibt es weder Honeypot- noch
	 * Feld-Tausch-Signal; alle uebrigen Regeln laufen normal.
	 *
	 * @param string $channel Kanal.
	 * @return bool
	 */
	protected function original_field_posted( string $channel ): bool {
		if ( $this->swap_seen ) {
			return false;
		}
		if ( ! in_array( $channel, array( 'comment', 'woo_review' ), true ) ) {
			return false;
		}
		if ( ! isset( $_POST['comment'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			return false;
		}
		$last = (int) get_option( CommentFormFields::SWAP_RENDER_OPTION, 0 );
		return $last > 0 && ( time() - $last ) < 2 * DAY_IN_SECONDS;
	}

	/**
	 * Darf der aktuelle Nutzer moderieren (Engine-Gate Stufe 0)?
	 *
	 * @return bool
	 */
	protected function can_moderate(): bool {
		return current_user_can( 'moderate_comments' ) || current_user_can( 'hs_manage_spam' );
	}

	/**
	 * Eingeloggter Nutzer mit Beitrags-Historie (Bestell-Historie kommt in
	 * W4 mit WooCommerce).
	 *
	 * @param int $user_id Nutzer-ID.
	 * @return bool
	 */
	protected function user_has_history( int $user_id ): bool {
		if ( $user_id <= 0 || ! function_exists( 'count_user_posts' ) ) {
			return false;
		}
		return (int) count_user_posts( $user_id ) > 0;
	}

	/**
	 * Signal-Wert aus $_POST (nur Skalare, sanitisiert).
	 *
	 * @param string $key POST-Schluessel.
	 * @return string
	 */
	protected function post_value( string $key ): string {
		$value = $_POST[ $key ] ?? ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Spam-Signale oeffentlicher Submits.
		if ( ! is_scalar( $value ) ) {
			return '';
		}
		return sanitize_text_field( wp_unslash( (string) $value ) );
	}

	/**
	 * Schreibt Log + Stats + Event fuer das aktuelle Verdict ueber den zentralen
	 * SpamRecorder (W3: Event-Verdrahtung an einer Stelle). Ein Log-Fehler stoppt
	 * nie den Ablauf.
	 *
	 * @param string   $verdict    blocked|quarantined|flagged.
	 * @param int|null $comment_id Kommentar-ID (null vor dem Insert).
	 * @return void
	 */
	protected function record( string $verdict, ?int $comment_id ): void {
		if ( null === $this->verdict ) {
			return;
		}
		$base = array(
			'channel'      => $this->log_base['channel'] ?? 'comment',
			'ip_hmac'      => $this->log_base['ip_hmac'] ?? '',
			'email_domain' => $this->log_base['email_domain'] ?? '',
			'excerpt'      => $this->log_base['excerpt'] ?? '',
		);
		if ( null !== $comment_id ) {
			$base['comment_id'] = $comment_id;
		}
		( new SpamRecorder( $this->log, $this->stats ) )->record( $verdict, $this->verdict, $base );
	}
}
