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

namespace Hafenstudios\Core\Support;

defined( 'ABSPATH' ) || exit;

/**
 * Selbst-Update-Mechanik der Flotte gegen die statischen JSON-Manifeste auf
 * hafenstudios.com (Vertrag: WELLENBRECHER-UPDATE-ENDPOINT.md, WB12). Kein
 * Update-Server-Produkt, kein Account, kein Tracking - eine Manifest-Datei plus
 * Release-ZIPs genuegen.
 *
 * Herkunft: bis 25.07.2026 lag diese Mechanik nur in Wellenbrecher. Seit dem
 * Pro-Launch brauchen auch Leadlotse und Kurato Updates (zahlende Kunden ohne
 * Update-Pfad waeren ein gebrochenes Versprechen), darum liegt sie jetzt im Core
 * und wird slug-basiert konfiguriert. Wellenbrecher\Support\SelfUpdater ist eine
 * duenne Ableitung, damit dessen Tests unveraendert weiterlaufen.
 *
 * Sicherheitskritisch und NICHT verhandelbar:
 * - Harte Host-Bindung: das Feld `package` MUSS mit https://hafenstudios.com/
 *   beginnen. Ein manipuliertes Manifest darf NIE auf einen fremden Host zeigen.
 * - Absolut fail-silent: jeder Netz-, JSON- oder Validierungsfehler fuehrt zu
 *   KEINEM Update-Angebot - nie eine Notice, nie wp_die. Der naechste Versuch
 *   laeuft erst nach Ablauf des Caches.
 *
 * Der WordPress-Kern feuert den Filter update_plugins_{hostname} seit 5.8 anhand
 * des "Update URI"-Headers. Installation und Verifikation uebernimmt der Kern
 * (der Nutzer klickt "Aktualisieren"); wir erzwingen keine Auto-Updates.
 */
class SelfUpdater {

	/**
	 * Harte Host-Bindung: das package MUSS mit diesem Praefix beginnen.
	 */
	public const PACKAGE_PREFIX = 'https://hafenstudios.com/';

	/**
	 * Manifest-Cache: 12 Stunden (Vertrag).
	 */
	public const CACHE_TTL = 12 * HOUR_IN_SECONDS;

	/**
	 * Maximale Manifest-Groesse (64 KB) - ein groesserer Body wird abgewiesen.
	 */
	public const MAX_MANIFEST_BYTES = 65536;

	/**
	 * Plugin-Slug (bestimmt Manifest-URL und Transient-Schluessel).
	 */
	protected string $slug;

	/**
	 * Plugin-Basename (Ordner/Datei.php), damit der Host-gebundene Filter nur
	 * das eigene Plugin bewertet.
	 */
	protected string $plugin_basename;

	/**
	 * Installierte Version (Vergleichsbasis).
	 */
	protected string $installed_version;

	/**
	 * @param string $slug              Plugin-Slug, z.B. 'leadlotse'.
	 * @param string $plugin_basename   Plugin-Basename (z.B. leadlotse/leadlotse.php).
	 * @param string $installed_version Aktuell installierte Version.
	 */
	public function __construct( string $slug, string $plugin_basename, string $installed_version ) {
		$this->slug              = $slug;
		$this->plugin_basename   = $plugin_basename;
		$this->installed_version = $installed_version;
	}

	/**
	 * Manifest-URL des Plugins.
	 *
	 * @return string
	 */
	public function manifest_url(): string {
		return self::PACKAGE_PREFIX . 'updates/' . $this->slug . '.json';
	}

	/**
	 * Transient-Schluessel des gecachten Manifests.
	 *
	 * @return string
	 */
	public function transient_key(): string {
		return $this->slug . '_update_manifest';
	}

	/**
	 * Haengt den Handler an den Host-gebundenen Kern-Filter.
	 *
	 * @return void
	 */
	public function register(): void {
		add_filter( 'update_plugins_hafenstudios.com', array( $this, 'check' ), 10, 3 );
	}

	/**
	 * Filter-Callback. Gibt bei einer echt neueren Version das Update-Objekt
	 * zurueck, sonst den unveraenderten Eingangswert (in aller Regel false).
	 * Faengt jeden Fehler ab (fail-silent).
	 *
	 * @param mixed                $update      Bisheriger Update-Wert (false|array|object).
	 * @param array<string, mixed> $plugin_data Plugin-Header (ungenutzt, Kern-Signatur).
	 * @param string               $plugin_file Plugin-Basename des geprueften Plugins.
	 * @return mixed false oder das Update-Array.
	 */
	public function check( $update, $plugin_data = array(), $plugin_file = '' ) {
		// Der Filter ist Host-, nicht Plugin-gebunden: nur das eigene Plugin bewerten.
		if ( '' !== (string) $plugin_file && (string) $plugin_file !== $this->plugin_basename ) {
			return $update;
		}

		try {
			$manifest = $this->manifest();
			if ( null === $manifest ) {
				return $update;
			}
			// Nur bei echt neuerer Version ein Angebot machen.
			if ( version_compare( $manifest['version'], $this->installed_version, '<=' ) ) {
				return $update;
			}

			return array(
				'id'           => $this->manifest_url(),
				'slug'         => $this->slug,
				'plugin'       => $this->plugin_basename,
				'new_version'  => $manifest['version'],
				'url'          => $manifest['url'],
				'package'      => $manifest['package'],
				'requires'     => $manifest['requires'],
				'requires_php' => $manifest['requires_php'],
				'tested'       => $manifest['tested'],
			);
		} catch ( \Throwable $e ) {
			// Fail-silent: kein Update, keine Notice, kein Fehler nach aussen.
			return $update;
		}
	}

	/**
	 * Holt (mit 12-h-Transient-Cache) und validiert das Manifest. Gibt ein
	 * normalisiertes Array zurueck oder null (jeder Fehler = null = kein Update).
	 * Nur ein gueltiges Manifest wird gecacht; Fehler werden bewusst NICHT
	 * gecacht, damit sich ein voruebergehender Ausfall schnell erholt.
	 *
	 * @return array{version:string,package:string,url:string,requires:string,requires_php:string,tested:string}|null
	 */
	protected function manifest(): ?array {
		$cached = get_transient( $this->transient_key() );
		if ( is_array( $cached ) ) {
			// Defensiv: auch den Cache-Inhalt gegen die Host-Bindung pruefen.
			return $this->validate( $cached );
		}

		$response = wp_remote_get(
			$this->manifest_url(),
			array(
				'timeout' => 5,
				'headers' => array( 'Accept' => 'application/json' ),
			)
		);

		if ( is_wp_error( $response ) ) {
			return null;
		}
		if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			return null;
		}

		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === $body || strlen( $body ) > self::MAX_MANIFEST_BYTES ) {
			return null;
		}

		$data = json_decode( $body, true );
		if ( ! is_array( $data ) ) {
			return null;
		}

		$valid = $this->validate( $data );
		if ( null === $valid ) {
			return null;
		}

		set_transient( $this->transient_key(), $valid, self::CACHE_TTL );
		return $valid;
	}

	/**
	 * Validiert und normalisiert ein rohes Manifest-Array. Pflicht: `version`
	 * als Versionsnummer parsebar, `package` mit harter Host-Bindung auf
	 * hafenstudios.com. Gibt null zurueck, sobald eine Pflichtpruefung scheitert.
	 *
	 * @param array<string, mixed> $data Rohes Manifest.
	 * @return array{version:string,package:string,url:string,requires:string,requires_php:string,tested:string}|null
	 */
	protected function validate( array $data ): ?array {
		$version = isset( $data['version'] ) && is_scalar( $data['version'] ) ? trim( (string) $data['version'] ) : '';
		$package = isset( $data['package'] ) && is_scalar( $data['package'] ) ? trim( (string) $data['package'] ) : '';

		// version muss als Versionsnummer parsebar sein (mit einer Ziffer beginnen).
		if ( '' === $version || ! preg_match( '/^\d+(\.\d+)*/', $version ) ) {
			return null;
		}
		// Harte Host-Bindung: package MUSS auf hafenstudios.com liegen.
		if ( '' === $package || 0 !== strpos( $package, self::PACKAGE_PREFIX ) ) {
			return null;
		}

		return array(
			'version'      => $version,
			'package'      => $package,
			'url'          => isset( $data['url'] ) && is_scalar( $data['url'] ) ? (string) $data['url'] : '',
			'requires'     => isset( $data['requires'] ) && is_scalar( $data['requires'] ) ? (string) $data['requires'] : '',
			'requires_php' => isset( $data['requires_php'] ) && is_scalar( $data['requires_php'] ) ? (string) $data['requires_php'] : '',
			'tested'       => isset( $data['tested'] ) && is_scalar( $data['tested'] ) ? (string) $data['tested'] : '',
		);
	}
}
