<?php
/**
 * security.txt (RFC 9116) generator — an opt-in, gap-filling well-known provider.
 *
 * This is the reference example of the plugin's "extend, never override" contract
 * for /.well-known: it produces a security.txt ONLY when the site does not already
 * have one. It stands down — and lets discovery.json simply *index* the existing
 * document — whenever ANY of these holds:
 *
 *   1. A real /.well-known/security.txt is on disk. The web server serves it;
 *      WellKnown::route() streams it (real-file-wins); we never shadow it, so we
 *      don't even register a generator for it.
 *   2. Another plugin already claimed "security.txt" in the Registry. We hook the
 *      registration action LATE (priority 99, like the auto-discovery adapters),
 *      so any first-class provider at the default priority wins the slot, and
 *      Registry::add_well_known() additionally keeps the first claimant.
 *   3. The feature is off (the default). Activating Agentimus therefore never
 *      changes a site that already handles security.txt some other way.
 *   4. No Contact is configured. RFC 9116 REQUIRES at least one Contact; an
 *      invalid security.txt is worse than none, so we emit nothing.
 *
 * WordPress core ships no /.well-known endpoint, so there is no core document to
 * break here — these guards are about coexisting with the web server and other
 * plugins, which is exactly the posture core *would* want a plugin to take.
 *
 * @package Agentimus
 */

namespace Agentimus\Discovery;

use Agentimus\Cache;
use Agentimus\Settings;

defined( 'ABSPATH' ) || exit;

final class SecurityTxt {

	const NAME = 'security.txt';

	/** RFC 9116 says keep Expires under a year; six months is a comfortable default. */
	const DEFAULT_EXPIRES_DAYS = 182;

	/** @var Settings */
	private $settings;

	/**
	 * @param Settings $settings Settings store.
	 */
	public function __construct( Settings $settings ) {
		$this->settings = $settings;
	}

	/**
	 * Offer the document through the SAME public registration action a third-party
	 * provider uses — late, so explicit providers (priority 10) get first refusal.
	 */
	public function register() {
		add_action( AGENTIMUS_CANONICAL_HOOK, array( $this, 'provide' ), 99 );
	}

	/**
	 * Register the generated security.txt with the collector — unless something
	 * else already provides it (see the class docblock for the precedence rules).
	 *
	 * @param Registry $registry The collector.
	 */
	public function provide( Registry $registry ) {
		// Opt-in, and short-circuitable by a site that wants another owner.
		if ( ! $this->should_serve() ) {
			return;
		}
		// A real file always wins — don't register a generator that would shadow it.
		if ( $this->file_on_disk() ) {
			return;
		}
		// RFC 9116 requires a Contact; without one we'd emit an invalid document.
		if ( empty( $this->contacts() ) ) {
			return;
		}

		$registry->add_well_known(
			array(
				'name'         => self::NAME,
				// send() appends "; charset=UTF-8"; RFC 9116 mandates text/plain utf-8.
				'content_type' => 'text/plain',
				'callback'     => array( $this, 'body' ),
			)
		);
	}

	/**
	 * Whether to offer a generated security.txt. Off by default; filterable so a
	 * dedicated security.txt plugin can claim ownership with one line.
	 *
	 * @return bool
	 */
	public function should_serve() {
		/**
		 * Short-circuit Agentimus's security.txt entirely.
		 *
		 * @param bool $serve Whether to offer a generated document.
		 */
		return (bool) apply_filters( 'agentimus_serve_security_txt', $this->settings->enabled( 'enable_security_txt' ) );
	}

	/**
	 * The RFC 9116 document, assembled from identity + security settings. Cached an
	 * hour and busted by Cache::flush() on any settings/content change.
	 *
	 * @return string
	 */
	public function body() {
		$cached = Cache::get( Cache::SECURITY_TXT );
		if ( is_string( $cached ) && '' !== $cached ) {
			return $cached;
		}

		$sec   = (array) $this->settings->get( 'security', array() );
		$lines = array(
			'# Generated by Agentimus per RFC 9116. Set the fields under Settings',
			'# > Agentimus, or drop a real /.well-known/security.txt and this',
			'# generator stands aside automatically.',
			'',
		);

		// Contact + Expires are the two REQUIRED fields.
		foreach ( $this->contacts() as $contact ) {
			$lines[] = 'Contact: ' . $contact;
		}
		$lines[] = 'Expires: ' . $this->expires();

		// Canonical (RECOMMENDED) — where this document is meant to live.
		$lines[] = 'Canonical: ' . home_url( '/.well-known/security.txt' );

		$langs = $this->preferred_languages();
		if ( '' !== $langs ) {
			$lines[] = 'Preferred-Languages: ' . $langs;
		}

		// Optional URL fields — emitted only when configured.
		foreach ( array(
			'Policy'          => 'policy',
			'Acknowledgments' => 'acknowledgments',
			'Encryption'      => 'encryption',
			'Hiring'          => 'hiring',
		) as $field => $key ) {
			$url = isset( $sec[ $key ] ) ? esc_url_raw( (string) $sec[ $key ] ) : '';
			if ( '' !== $url ) {
				$lines[] = $field . ': ' . $url;
			}
		}

		$body = implode( "\n", $lines ) . "\n";

		/**
		 * Filter the final security.txt body — full control, e.g. to append a
		 * `Signature:` line pointing at an OpenPGP-signed copy.
		 *
		 * @param string $body The assembled document.
		 */
		$body = (string) apply_filters( 'agentimus_security_txt', $body );

		Cache::set( Cache::SECURITY_TXT, $body );
		return $body;
	}

	/**
	 * Contact field values (RFC 9116: at least one REQUIRED). Seeds from the
	 * identity contact email, then any extra contacts from the security settings.
	 * Each is normalised to a URI (bare emails become mailto:) and de-duplicated.
	 *
	 * @return string[]
	 */
	public function contacts() {
		$raw   = array();
		$email = (string) $this->settings->identity( 'contact_email', '' );
		if ( '' !== $email ) {
			$raw[] = $email;
		}
		$sec = (array) $this->settings->get( 'security', array() );
		if ( ! empty( $sec['contacts'] ) ) {
			$raw = array_merge( $raw, (array) $sec['contacts'] );
		}

		$out = array();
		foreach ( $raw as $value ) {
			$uri = $this->normalize_contact( $value );
			if ( '' !== $uri ) {
				$out[ $uri ] = $uri;
			}
		}
		return array_values( $out );
	}

	/**
	 * The Expires timestamp (RFC 9116: exactly one REQUIRED, SHOULD be < 1 year).
	 * Always a fresh future instant — the hour-long body cache cannot push it
	 * stale relative to its multi-month window.
	 *
	 * @return string ISO 8601 UTC, e.g. "2026-12-16T10:00:00Z".
	 */
	public function expires() {
		$sec  = (array) $this->settings->get( 'security', array() );
		$days = isset( $sec['expires_days'] ) ? (int) $sec['expires_days'] : self::DEFAULT_EXPIRES_DAYS;
		$days = max( 1, min( 365, $days ) );
		/**
		 * Filter the Expires window in days.
		 *
		 * @param int $days Days from now.
		 */
		$days = (int) apply_filters( 'agentimus_security_txt_expires_days', $days );
		$days = max( 1, min( 365, $days ) );
		return gmdate( 'Y-m-d\TH:i:s\Z', time() + ( $days * DAY_IN_SECONDS ) );
	}

	/**
	 * Normalise a contact to an RFC 9116 URI: bare emails → mailto:, otherwise an
	 * http(s)/mailto/tel URI (rejected if it is none of those).
	 *
	 * @param string $value Raw contact.
	 * @return string URI, or '' if invalid.
	 */
	private function normalize_contact( $value ) {
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return '';
		}
		if ( is_email( $value ) ) {
			return 'mailto:' . sanitize_email( $value );
		}
		// Require an explicit, allowed scheme — esc_url_raw() would otherwise invent
		// one for a bare word, emitting a bogus Contact like "https://not-a-uri".
		if ( ! preg_match( '#^(https?|mailto|tel):#i', $value ) ) {
			return '';
		}
		$uri = esc_url_raw( $value, array( 'https', 'http', 'mailto', 'tel' ) );
		return $uri ? $uri : '';
	}

	/**
	 * Preferred-Languages value — the explicit setting, else the site locale's
	 * primary subtag (en_US → en). Empty string omits the field.
	 *
	 * @return string
	 */
	private function preferred_languages() {
		$sec = (array) $this->settings->get( 'security', array() );
		if ( ! empty( $sec['preferred_languages'] ) ) {
			return sanitize_text_field( (string) $sec['preferred_languages'] );
		}
		$primary = strtok( str_replace( '_', '-', (string) get_bloginfo( 'language' ) ), '-' );
		return is_string( $primary ) ? $primary : '';
	}

	/**
	 * Whether a real security.txt file sits at the web root's /.well-known.
	 *
	 * @return bool
	 */
	private function file_on_disk() {
		return file_exists( \Agentimus\Paths::site_root() . '.well-known/' . self::NAME );
	}
}
