<?php

declare(strict_types=1);

namespace SeoScope\Services;

/**
 * Normalized cache identity for dashboard SEO AI assistant rows in {@see \SeoScope\Repositories\DashboardCacheRepository}.
 *
 * `group_key` is varchar(32): SHA-256 prefix of a stable JSON envelope (M3 deliverable #3).
 */
final class SeoAiAssistantCacheKey
{
    /** Bump if the canonical JSON shape for {@see self::dashboardCacheGroupKey32()} changes. */
    private const KEY_ENVELOPE_VERSION = 1;

    public static function cacheTtlSeconds(array $config): int
    {
        $raw = ($config['seo_ai'] ?? [])['cache_ttl_seconds'] ?? 86400;
        $sec = is_int($raw) ? $raw : (int) $raw;

        return max(300, min(604800, $sec));
    }

    /**
     * @param array{
     *   preset:string,
     *   compare:bool,
     *   device:string,
     *   country:string,
     *   kpiStart:string,
     *   kpiEnd:string
     * } $layer
     * @param array<string, mixed> $assistantPayload `payload` from {@see SeoAiAssistantPayloadService::build()}
     */
    public static function dashboardCacheGroupKey32(
        int $propertyId,
        array $layer,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        array $assistantPayload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => self::KEY_ENVELOPE_VERSION,
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiAssistantPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'cmp' => !empty($layer['compare']) ? 1 : 0,
            'd' => (string) $layer['device'],
            'co' => (string) $layer['country'],
            'ds' => (string) $layer['kpiStart'],
            'de' => (string) $layer['kpiEnd'],
            'pf' => (string) $layer['preset'],
            'sv' => (int) ($assistantPayload['schema_version'] ?? 0),
            'dst' => (string) ($assistantPayload['digest_strategy'] ?? ''),
            'dat' => self::nullableString($assistantPayload['data_available_through'] ?? null),
            'dig' => self::seriesDigestFingerprint($assistantPayload),
            'kpi' => self::kpiContextFingerprint($assistantPayload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * Cache key for Quick Win → IQQI assistant (separate from dashboard aggregate insight).
     *
     * @param array<string, mixed> $iqqiPayload From {@see SeoAiIqqiPayloadService::build()} `payload`
     */
    public static function iqqiCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        int $minImpr,
        float $posMin,
        float $posMax,
        array $iqqiPayload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'quick_win_iqqi',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiIqqiPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'mi' => $minImpr,
            'pmin' => round($posMin, 2),
            'pmax' => round($posMax, 2),
            'sv' => (int) ($iqqiPayload['schema_version'] ?? 0),
            'opp' => self::queryOpportunitiesFingerprint($iqqiPayload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $iqqiPayload
     */
    public static function iqqiOpportunitiesFingerprint(array $iqqiPayload): string
    {
        return self::queryOpportunitiesFingerprint($iqqiPayload);
    }

    /**
     * @param array<string, mixed> $payload From {@see SeoAiCtrOpportunityPayloadService::build()} `payload`
     */
    public static function ctrOpportunityCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        int $minImpr,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'quick_win_ctr_opportunity',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiCtrOpportunityPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'mi' => $minImpr,
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'opp' => self::queryOpportunitiesFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload From {@see SeoAiLowCtrPagesPayloadService::build()} `payload`
     */
    public static function lowCtrPagesCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        int $minImpr,
        float $coeff,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'quick_win_low_ctr_pages',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiLowCtrPagesPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'mi' => $minImpr,
            'cf' => round($coeff, 4),
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'opp' => self::pageOpportunitiesFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload From {@see SeoAiTopicalGapsPayloadService::build()} `payload`
     */
    public static function topicalGapsCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        int $minImpr,
        float $posMin,
        int $maxClicks,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'quick_win_topical_gaps',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiTopicalGapsPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'mi' => $minImpr,
            'pmin' => round($posMin, 2),
            'mc' => $maxClicks,
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'opp' => self::queryOpportunitiesFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload From {@see SeoAiKeywordsPayloadService::build()} `payload`
     */
    public static function keywordsCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        string $show,
        string $searchQ,
        ?int $trackCategoryFilter,
        bool $compareEnabled,
        string $sort,
        string $dir,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'keywords_report',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiKeywordsPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'sh' => $show,
            'sq' => trim($searchQ),
            'tcf' => $trackCategoryFilter,
            'cmp' => $compareEnabled ? 1 : 0,
            'so' => $sort,
            'sd' => strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC',
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'kw' => self::keywordsListFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload From {@see SeoAiPagesPayloadService::build()} `payload`
     */
    public static function pagesCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        string $show,
        string $searchQ,
        ?int $trackCategoryFilter,
        bool $compareEnabled,
        string $sort,
        string $dir,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'pages_report',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiPagesPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'sh' => $show,
            'sq' => trim($searchQ),
            'tcf' => $trackCategoryFilter,
            'cmp' => $compareEnabled ? 1 : 0,
            'so' => $sort,
            'sd' => strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC',
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'pg' => self::pagesReportListFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload From {@see SeoAiCannibalizationPayloadService::build()} `payload`
     */
    public static function cannibalizationCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        int $minImpr,
        int $minPages,
        string $sort,
        string $dir,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'cannibalization_report',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiCannibalizationPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'mi' => $minImpr,
            'mp' => max(2, $minPages),
            'so' => $sort,
            'sd' => strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC',
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'cs' => self::cannibalizationCasesFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload From {@see SeoAiLongTailPayloadService::build()} `payload`
     */
    public static function longTailCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        int $minTerms,
        string $sort,
        string $dir,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'long_tail_report',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiLongTailPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'mt' => max(1, $minTerms),
            'so' => $sort,
            'sd' => strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC',
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'lt' => self::longTailQueriesFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload From {@see SeoAiDecayPayloadService::build()} `payload`
     */
    public static function decayCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $curStart,
        string $curEnd,
        string $prevStart,
        string $prevEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        int $minImpressions,
        float $clicksDropPct,
        float $imprDropPct,
        float $positionWorsen,
        string $sort,
        string $dir,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $canon = [
            'kev' => 2,
            'ctx' => 'decay_report',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiDecayPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'cs' => $curStart,
            'ce' => $curEnd,
            'ps' => $prevStart,
            'pe' => $prevEnd,
            'd' => $device,
            'co' => $country,
            'mi' => max(0, $minImpressions),
            'cd' => round(max(0.0, $clicksDropPct), 4),
            'id' => round(max(0.0, $imprDropPct), 4),
            'pw' => round(max(0.0, $positionWorsen), 4),
            'so' => $sort,
            'sd' => strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC',
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'dq' => self::decayQueriesFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function decayQueriesFingerprint(array $payload): string
    {
        $rows = $payload['queries'] ?? [];
        if (!is_array($rows)) {
            return substr(hash('sha256', 'decay_queries:null'), 0, 32);
        }
        $fp = [];
        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }
            $cur = $r['current_period'] ?? [];
            $fp[] = [
                'q' => (string) ($r['query'] ?? ''),
                'i' => is_array($cur) ? (int) ($cur['impressions'] ?? 0) : 0,
            ];
        }
        $json = json_encode($fp, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param list<string> $questionTerms Resolved question terms for matching
     * @param array<string, mixed> $payload From {@see SeoAiQuestionQueriesPayloadService::build()} `payload`
     */
    public static function questionQueriesCacheGroupKey32(
        int $propertyId,
        string $preset,
        string $rangeStart,
        string $rangeEnd,
        string $device,
        string $country,
        string $uiLocale,
        string $aiProvider,
        string $aiActiveModel,
        array $questionTerms,
        string $sort,
        string $dir,
        array $payload,
    ): string {
        $locale = strtolower(trim($uiLocale));
        if ($locale === '') {
            $locale = 'en';
        }

        $termsCanon = array_values(array_unique(array_map(
            static fn ($t) => strtolower(trim((string) $t)),
            $questionTerms,
        )));
        sort($termsCanon, SORT_STRING);

        $canon = [
            'kev' => 2,
            'ctx' => 'question_queries_report',
            'pid' => $propertyId,
            'lc' => $locale,
            'pv' => SeoAiQuestionQueriesPrompts::PROMPT_VERSION,
            'prov' => $aiProvider,
            'm' => $aiActiveModel,
            'pf' => $preset,
            'rs' => $rangeStart,
            're' => $rangeEnd,
            'd' => $device,
            'co' => $country,
            'qt' => $termsCanon,
            'so' => $sort,
            'sd' => strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC',
            'sv' => (int) ($payload['schema_version'] ?? 0),
            'qq' => self::questionQueriesFingerprint($payload),
        ];
        $json = json_encode($canon, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function questionQueriesFingerprint(array $payload): string
    {
        $rows = $payload['queries'] ?? [];
        if (!is_array($rows)) {
            return substr(hash('sha256', 'qq_queries:null'), 0, 32);
        }
        $fp = [];
        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }
            $fp[] = [
                'q' => (string) ($r['query'] ?? ''),
                'i' => (int) ($r['impressions'] ?? 0),
            ];
        }
        $json = json_encode($fp, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function longTailQueriesFingerprint(array $payload): string
    {
        $rows = $payload['queries'] ?? [];
        if (!is_array($rows)) {
            return substr(hash('sha256', 'lt_queries:null'), 0, 32);
        }
        $fp = [];
        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }
            $fp[] = [
                'q' => (string) ($r['query'] ?? ''),
                't' => (int) ($r['terms'] ?? 0),
                'i' => (int) ($r['impressions'] ?? 0),
            ];
        }
        $json = json_encode($fp, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function cannibalizationCasesFingerprint(array $payload): string
    {
        $rows = $payload['cases'] ?? [];
        if (!is_array($rows)) {
            return substr(hash('sha256', 'can_cases:null'), 0, 32);
        }
        $fp = [];
        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }
            $fp[] = [
                'q' => (string) ($r['query'] ?? ''),
                'pc' => (int) ($r['pages_count'] ?? 0),
                'i' => (int) ($r['total_impressions'] ?? 0),
            ];
        }
        $json = json_encode($fp, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function pagesReportListFingerprint(array $payload): string
    {
        $rows = $payload['pages'] ?? [];
        if (!is_array($rows)) {
            return substr(hash('sha256', 'pages_list:null'), 0, 32);
        }
        $fp = [];
        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }
            $fp[] = [
                'p' => (string) ($r['page'] ?? ''),
                'i' => (int) ($r['impressions'] ?? 0),
                'c' => (int) ($r['clicks'] ?? 0),
            ];
        }
        $json = json_encode($fp, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function keywordsListFingerprint(array $payload): string
    {
        $rows = $payload['keywords'] ?? [];
        if (!is_array($rows)) {
            return substr(hash('sha256', 'kw_list:null'), 0, 32);
        }
        $fp = [];
        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }
            $fp[] = [
                'q' => (string) ($r['query'] ?? ''),
                'i' => (int) ($r['impressions'] ?? 0),
                'c' => (int) ($r['clicks'] ?? 0),
            ];
        }
        $json = json_encode($fp, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function queryOpportunitiesFingerprint(array $payload): string
    {
        $rows = $payload['opportunities'] ?? [];
        if (!is_array($rows)) {
            return substr(hash('sha256', 'qw_opp:null'), 0, 32);
        }
        $fp = [];
        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }
            $fp[] = [
                'q' => (string) ($r['query'] ?? ''),
                's' => round((float) ($r['score'] ?? 0), 2),
                'i' => (int) ($r['impressions'] ?? 0),
            ];
        }
        $json = json_encode($fp, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $payload
     */
    private static function pageOpportunitiesFingerprint(array $payload): string
    {
        $rows = $payload['opportunities'] ?? [];
        if (!is_array($rows)) {
            return substr(hash('sha256', 'qw_page_opp:null'), 0, 32);
        }
        $fp = [];
        foreach ($rows as $r) {
            if (!is_array($r)) {
                continue;
            }
            $fp[] = [
                'p' => (string) ($r['page'] ?? ''),
                's' => round((float) ($r['score'] ?? 0), 2),
                'i' => (int) ($r['impressions'] ?? 0),
            ];
        }
        $json = json_encode($fp, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $assistantPayload
     */
    public static function seriesDigestFingerprint(array $assistantPayload): string
    {
        $digest = $assistantPayload['series_digest'] ?? null;
        if (!is_array($digest)) {
            return substr(hash('sha256', 'series_digest:null'), 0, 32);
        }

        $sorted = self::deepKsortedCopy($digest);
        $json = json_encode($sorted, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    /**
     * @param array<string, mixed> $assistantPayload
     */
    public static function kpiContextFingerprint(array $assistantPayload): string
    {
        $block = [
            'cur' => $assistantPayload['current_period_metrics'] ?? null,
            'prev' => $assistantPayload['previous_period'] ?? null,
            'cmp' => !empty($assistantPayload['compare_enabled']) ? 1 : 0,
        ];
        $sorted = self::deepKsortedCopy($block);
        $json = json_encode($sorted, JSON_UNESCAPED_SLASHES);

        return substr(hash('sha256', is_string($json) ? $json : ''), 0, 32);
    }

    private static function nullableString(mixed $v): ?string
    {
        if ($v === null) {
            return null;
        }
        $s = trim((string) $v);

        return $s !== '' ? $s : null;
    }

    /**
     * @param array<string, mixed> $data
     *
     * @return array<string, mixed>
     */
    private static function deepKsortedCopy(array $data): array
    {
        $copy = $data;
        ksort($copy, SORT_STRING);
        foreach ($copy as $k => $v) {
            if (is_array($v)) {
                /** @var array<string, mixed> $v */
                $copy[$k] = self::deepKsortedCopy($v);
            }
        }

        return $copy;
    }
}
