<?php
namespace EncypherProvenance;

if (! defined('ABSPATH')) {
    exit;
}

/**
 * Bounded IPTC ninjs 2.2 editor record controlling editorial-provenance opt-in.
 */
final class EditorialMetadata
{
    public const META_KEY = '_encypher_editorial_metadata';
    public const SIGNED_HASH_META_KEY = '_encypher_signed_editorial_metadata_hash';
    public const RECORD_SCHEMA_VERSION = 1;
    public const NINJS_TYPE = 'text';
    public const NINJS_REPRESENTATIONTYPE = 'partial';

    private const MAX_HEADLINES = 3;
    private const MAX_DESCRIPTIONS = 3;
    private const MAX_SUBJECTS = 12;
    private const MAX_GENRES = 6;
    private const MAX_HEADLINE_LENGTH = 300;
    private const MAX_DESCRIPTION_LENGTH = 1000;
    private const MAX_BY_LENGTH = 200;
    private const MAX_SLUGLINE_LENGTH = 200;
    private const MAX_LOCATED_LENGTH = 200;
    private const MAX_CONCEPT_NAME_LENGTH = 160;
    private const MAX_URI_LENGTH = 500;
    private const MAX_LANGUAGE_LENGTH = 35;

    public static function register_post_meta(): void
    {
        register_post_meta('', self::META_KEY, [
            'show_in_rest' => true,
            'single' => true,
            'type' => 'string',
            'default' => '',
            'sanitize_callback' => [self::class, 'sanitize_meta_value'],
            'auth_callback' => static function (
                $allowed = false,
                $meta_key = '',
                $object_id = 0,
                $user_id = 0,
                $cap = ''
            ): bool {
                $post_id = (int) $object_id;
                return $post_id > 0 && current_user_can('edit_post', $post_id);
            },
        ]);

        register_post_meta('', self::SIGNED_HASH_META_KEY, [
            'show_in_rest' => false,
            'single' => true,
            'type' => 'string',
            'auth_callback' => static function (): bool {
                return false;
            },
        ]);
    }

    /** @param mixed $value */
    public static function sanitize_meta_value($value): string
    {
        if (! is_string($value) || '' === trim($value)) {
            return '';
        }
        $decoded = json_decode($value, true);
        if (! is_array($decoded)) {
            return '';
        }
        $normalized = self::normalize_record($decoded);
        return null === $normalized ? '' : self::encode_json($normalized);
    }

    /**
     * @param array<string,mixed> $record
     * @return array<string,mixed>|null
     */
    public static function normalize_record(array $record): ?array
    {
        $version = $record['schema_version'] ?? self::RECORD_SCHEMA_VERSION;
        if (! is_int($version) && ! (is_string($version) && ctype_digit($version))) {
            return null;
        }
        if ((int) $version !== self::RECORD_SCHEMA_VERSION) {
            return null;
        }

        $normalized = [
            'schema_version' => self::RECORD_SCHEMA_VERSION,
            'include_in_provenance' => ! empty($record['include_in_provenance']),
        ];
        $headlines = self::normalize_value_list($record['headlines'] ?? null, self::MAX_HEADLINES, self::MAX_HEADLINE_LENGTH);
        if ([] !== $headlines) {
            $normalized['headlines'] = $headlines;
        }
        $by = self::normalize_text($record['by'] ?? null, self::MAX_BY_LENGTH);
        if ('' !== $by) {
            $normalized['by'] = $by;
        }
        $descriptions = self::normalize_value_list($record['descriptions'] ?? null, self::MAX_DESCRIPTIONS, self::MAX_DESCRIPTION_LENGTH);
        if ([] !== $descriptions) {
            $normalized['descriptions'] = $descriptions;
        }
        $language = self::normalize_language($record['language'] ?? null);
        if ('' !== $language) {
            $normalized['language'] = $language;
        }
        $slugline = self::normalize_text($record['slugline'] ?? null, self::MAX_SLUGLINE_LENGTH);
        if ('' !== $slugline) {
            $normalized['slugline'] = $slugline;
        }
        $located = self::normalize_text($record['located'] ?? null, self::MAX_LOCATED_LENGTH);
        if ('' !== $located) {
            $normalized['located'] = $located;
        }
        $subjects = self::normalize_concept_list($record['subjects'] ?? null, self::MAX_SUBJECTS);
        if ([] !== $subjects) {
            $normalized['subjects'] = $subjects;
        }
        $genres = self::normalize_concept_list($record['genres'] ?? null, self::MAX_GENRES);
        if ([] !== $genres) {
            $normalized['genres'] = $genres;
        }
        return $normalized;
    }

    /** @param mixed $value */
    private static function normalize_text($value, int $max_length): string
    {
        if (! is_string($value) || 1 !== preg_match('//u', $value)) {
            return '';
        }
        $text = (string) preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $value);
        $text = trim((string) preg_replace('/\s+/u', ' ', $text));
        return '' === $text ? '' : self::truncate($text, $max_length);
    }

    private static function truncate(string $text, int $max_length): string
    {
        return function_exists('mb_substr')
            ? mb_substr($text, 0, $max_length)
            : substr($text, 0, $max_length);
    }

    /** @return array<int,array{value:string}> */
    private static function normalize_value_list($value, int $max_items, int $max_length): array
    {
        if (! is_array($value)) {
            return [];
        }
        $out = [];
        foreach ($value as $entry) {
            if (count($out) >= $max_items) {
                break;
            }
            $raw = is_array($entry) ? ($entry['value'] ?? null) : $entry;
            $text = self::normalize_text($raw, $max_length);
            if ('' !== $text) {
                $out[] = ['value' => $text];
            }
        }
        return $out;
    }

    /** @return array<int,array<string,string>> */
    private static function normalize_concept_list($value, int $max_items): array
    {
        if (! is_array($value)) {
            return [];
        }
        $out = [];
        foreach ($value as $entry) {
            if (count($out) >= $max_items) {
                break;
            }
            $raw_name = is_array($entry) ? ($entry['name'] ?? null) : $entry;
            $name = self::normalize_text($raw_name, self::MAX_CONCEPT_NAME_LENGTH);
            if ('' === $name) {
                continue;
            }
            $concept = ['name' => $name];
            $uri = is_array($entry) ? self::normalize_http_url($entry['uri'] ?? null) : '';
            if ('' !== $uri) {
                $concept['uri'] = $uri;
            }
            $out[] = $concept;
        }
        return $out;
    }

    /** @param mixed $value */
    public static function normalize_http_url($value): string
    {
        if (! is_string($value)) {
            return '';
        }
        $url = trim($value);
        if ('' === $url || strlen($url) > self::MAX_URI_LENGTH) {
            return '';
        }
        $parse = function_exists('wp_parse_url') ? 'wp_parse_url' : 'parse_url';
        $parsed_scheme = $parse($url, PHP_URL_SCHEME);
        $parsed_host = $parse($url, PHP_URL_HOST);
        $scheme = is_string($parsed_scheme) ? strtolower($parsed_scheme) : '';
        $host = is_string($parsed_host) ? $parsed_host : '';
        if (! in_array($scheme, ['http', 'https'], true) || '' === $host) {
            return '';
        }
        return 1 === preg_match('/[\x00-\x20\x7F<>"\']/', $url) ? '' : $url;
    }

    /** @param mixed $value */
    public static function normalize_language($value): string
    {
        if (! is_string($value)) {
            return '';
        }
        $tag = trim($value);
        if ('' === $tag || strlen($tag) > self::MAX_LANGUAGE_LENGTH) {
            return '';
        }
        $pattern = '/^[A-Za-z]{2,3}(-[A-Za-z]{3}){0,3}'
            . '(-[A-Za-z]{4})?'
            . '(-([A-Za-z]{2}|[0-9]{3}))?'
            . '(-([0-9A-Za-z]{5,8}|[0-9][0-9A-Za-z]{3}))*'
            . '(-[0-9A-WY-Za-wy-z](-[0-9A-Za-z]{2,8})+)*$/';
        return 1 === preg_match($pattern, $tag) ? $tag : '';
    }

    /** @return array<string,mixed> */
    public static function record_for_post(int $post_id): array
    {
        $raw = get_post_meta($post_id, self::META_KEY, true);
        if (! is_string($raw) || '' === trim($raw)) {
            return [];
        }
        $decoded = json_decode($raw, true);
        if (! is_array($decoded)) {
            return [];
        }
        $normalized = self::normalize_record($decoded);
        return null === $normalized ? [] : $normalized;
    }

    /** Build the server-owned ninjs assertion payload for signing. */
    public static function build_assertion_data(int $post_id): ?array
    {
        $record = self::record_for_post($post_id);
        if (empty($record['include_in_provenance'])) {
            return null;
        }
        $editorial = [];
        foreach (['headlines', 'by', 'descriptions', 'language', 'slugline', 'located', 'subjects', 'genres'] as $key) {
            if (! empty($record[$key])) {
                $editorial[$key] = $record[$key];
            }
        }
        if ([] === $editorial) {
            return null;
        }
        $data = [];
        $uri = self::normalize_http_url((string) get_permalink($post_id));
        if ('' !== $uri) {
            $data['uri'] = $uri;
        }
        $data['type'] = self::NINJS_TYPE;
        $data['representationtype'] = self::NINJS_REPRESENTATIONTYPE;
        foreach (['headlines', 'by', 'descriptions', 'language', 'slugline', 'located', 'subjects', 'genres'] as $key) {
            if (isset($editorial[$key])) {
                $data[$key] = $editorial[$key];
            }
        }
        return $data;
    }

    public static function is_opted_in(int $post_id): bool
    {
        return ! empty(self::record_for_post($post_id)['include_in_provenance']);
    }

    private static function encode_json($value): string
    {
        $flags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
        $encoded = function_exists('wp_json_encode')
            ? wp_json_encode($value, $flags)
            // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- standalone test fallback; WordPress runtime always provides wp_json_encode().
            : json_encode($value, $flags);
        return is_string($encoded) ? $encoded : '';
    }
}
