<?php

declare(strict_types=1);

namespace SeoScope\Services;

/**
 * Builds the structured payload for the SEO AI Assistant (M1): GSC KPI context,
 * in-period series digest (half-split when under 28 days; fixed 7-day buckets when 28+ days), and deterministic SEO health (never LLM-driven).
 *
 * CTR and position daily values are aggregated like Search Console aggregates:
 * CTR = sum(clicks)/sum(impressions), position weighted by impressions when summed.
 */
final class SeoAiAssistantPayloadService
{
    /** @see self::build() */
    public const SCHEMA_VERSION = 1;

    public const DIGEST_STRATEGY_HALF = 'half_split_v1';

    public const DIGEST_STRATEGY_WEEKLY = 'weekly_buckets_v1';

    /** @deprecated Use DIGEST_STRATEGY_HALF — kept for external references */
    public const DIGEST_STRATEGY = self::DIGEST_STRATEGY_HALF;

    /** M1 Deliverable #2: use weekly aggregation when series has at least this many calendar days */
    private const WEEKLY_DIGEST_MIN_DAYS = 28;

    /** Days per bucket in {@see self::DIGEST_STRATEGY_WEEKLY} */
    private const WEEKLY_BUCKET_DAYS = 7;

    public const HEALTH_GOOD = 'good';

    public const HEALTH_WARNING = 'warning';

    public const HEALTH_CRITICAL = 'critical';

    /** M1 #3 — map total penalty to badge */
    private const HEALTH_SCORE_CRITICAL = 8.0;

    private const HEALTH_SCORE_WARNING = 3.0;

    /**
     * M1 #3: without period compare, impression totals below this (but greater than zero) add a neutral-warning bias
     * (sample too small for a confident “good” organic picture).
     */
    private const THIN_DATA_MAX_IMPRESSIONS_NO_COMPARE = 35;

    /** Chosen so total penalty reaches {@see self::HEALTH_SCORE_WARNING} when only this rule fires */
    private const THIN_DATA_PENALTY = 3.0;

    /**
     * @param array{
     *   kpi: ?array{clicks?:int|float,impressions?:int|float,ctr?:float,position?:float},
     *   kpi_prev: ?array{clicks?:int|float,impressions?:int|float,ctr?:float,position?:float},
     *   kpi_start: string,
     *   kpi_end: string,
     *   kpi_prev_start: ?string,
     *   kpi_prev_end: ?string,
     *   preset: string,
     *   device: string,
     *   country: string,
     *   compare_enabled: bool,
     *   daily_series: list<array{date?:string,clicks?:int|float,impressions?:int|float,ctr?:float,position?:float}>,
     *   data_available_through: ?string,
     * } $input
     *
     * @return array{
     *   ready: bool,
     *   seo_health: self::HEALTH_*,
     *   health_penalty_score: float,
     *   payload: array<string, mixed>,
     * }
     */
    public function build(array $input): array
    {
        $kpi = isset($input['kpi']) && is_array($input['kpi']) ? $this->normalizeKpiBlock($input['kpi']) : null;
        $kpiPrev = isset($input['kpi_prev']) && is_array($input['kpi_prev']) ? $this->normalizeKpiBlock($input['kpi_prev']) : null;
        $dailySeries = isset($input['daily_series']) && is_array($input['daily_series']) ? $input['daily_series'] : [];

        $compare = !empty($input['compare_enabled']) && $kpiPrev !== null;

        $ready = $kpi !== null && ($kpi['impressions'] > 0 || $kpi['clicks'] > 0);

        $seriesDigest = $this->buildSeriesDigest($dailySeries);
        $digestStrategy = self::DIGEST_STRATEGY_HALF;
        if (is_array($seriesDigest) && (($seriesDigest['strategy'] ?? '') === self::DIGEST_STRATEGY_WEEKLY)) {
            $digestStrategy = self::DIGEST_STRATEGY_WEEKLY;
        }

        $modelNotes = [
            'ctr_meaning' => 'CTR is clicks divided by impressions for the selected period aggregate (same idea as Search Console totals).',
            'position_meaning' => 'Average position is aggregated with impression weighting when combining days (GSC-style).',
            'constraints' => 'Do not cite specific queries, URLs, or countries beyond the filters object. Interpret only these aggregates.',
        ];

        $payload = [
            'schema_version' => self::SCHEMA_VERSION,
            'digest_strategy' => $digestStrategy,
            'filters' => [
                'preset' => (string) ($input['preset'] ?? 'last_28_days'),
                'device' => (string) ($input['device'] ?? 'all'),
                'country' => (string) ($input['country'] ?? 'all'),
            ],
            'period' => [
                'start' => (string) ($input['kpi_start'] ?? ''),
                'end' => (string) ($input['kpi_end'] ?? ''),
            ],
            'compare_enabled' => $compare,
            'previous_period' => null,
            'current_period_metrics' => $kpi,
            'series_digest' => $seriesDigest,
            'data_available_through' => ($input['data_available_through'] ?? null) !== null && (string) $input['data_available_through'] !== ''
                ? (string) $input['data_available_through']
                : null,
            'model_notes' => $modelNotes,
        ];

        if ($compare && $kpi !== null && $kpiPrev !== null) {
            $payload['previous_period'] = [
                'start' => (string) ($input['kpi_prev_start'] ?? ''),
                'end' => (string) ($input['kpi_prev_end'] ?? ''),
                'metrics' => $kpiPrev,
            ];
        }

        $payload['analytics_ready'] = $ready;
        $badge = $this->computeHealthBadge($payload);
        $health = $badge['seo_health'];
        $penalty = $badge['health_penalty_score'];

        $payload['seo_health_deterministic'] = $health;

        return [
            'ready' => $ready,
            'seo_health' => $health,
            'health_penalty_score' => $penalty,
            'payload' => $payload,
        ];
    }

    /**
     * M1 Deliverable #3: deterministic SEO health from the same payload shape returned under `payload` by {@see self::build()}.
     * Rules: period-over-period deltas when `compare_enabled` and `previous_period.metrics` exist; lower position = better;
     * intra-period digest decay; optional thin-sample bias without compare. Never uses an LLM.
     *
     * @param array<string, mixed> $payload
     *
     * @return array{seo_health: self::HEALTH_*, health_penalty_score: float}
     */
    public function computeHealthBadge(array $payload): array
    {
        $kpi = isset($payload['current_period_metrics']) && is_array($payload['current_period_metrics'])
            ? $this->normalizeKpiBlock($payload['current_period_metrics'])
            : null;

        $ready = $kpi !== null && ($kpi['impressions'] > 0 || $kpi['clicks'] > 0);

        $compare = false;
        $kpiPrev = null;
        if (!empty($payload['compare_enabled'])
            && isset($payload['previous_period']) && is_array($payload['previous_period'])
            && isset($payload['previous_period']['metrics']) && is_array($payload['previous_period']['metrics'])
        ) {
            $kpiPrev = $this->normalizeKpiBlock($payload['previous_period']['metrics']);
            $compare = true;
        }

        $digest = isset($payload['series_digest']) && is_array($payload['series_digest'])
            ? $payload['series_digest']
            : null;

        [$health, $penalty] = $this->computeDeterministicHealth($kpi, $kpiPrev, $compare, $digest, $ready);

        return [
            'seo_health' => $health,
            'health_penalty_score' => $penalty,
        ];
    }

    /**
     * @return array{clicks:int,impressions:int,ctr:float,position:float}
     */
    private function normalizeKpiBlock(array $row): array
    {
        return [
            'clicks' => (int) round((float) ($row['clicks'] ?? 0)),
            'impressions' => (int) round((float) ($row['impressions'] ?? 0)),
            'ctr' => (float) ($row['ctr'] ?? 0.0),
            'position' => (float) ($row['position'] ?? 0.0),
        ];
    }

    /**
     * M1 Deliverable #2: compact shape of the performance series — half-split for short ranges,
     * fixed 7-day buckets (chronological) when days_total >= {@see self::WEEKLY_DIGEST_MIN_DAYS}.
     *
     * @param list<array{date?:string,clicks?:int|float,impressions?:int|float,ctr?:float,position?:float}> $dailySeries
     *
     * @return array<string, mixed>|null
     */
    private function buildSeriesDigest(array $dailySeries): ?array
    {
        $rows = $this->sortedSeriesRows($dailySeries);
        if ($rows === []) {
            return null;
        }

        $n = count($rows);
        if ($n >= self::WEEKLY_DIGEST_MIN_DAYS) {
            return $this->buildWeeklyBucketsDigest($rows);
        }

        return $this->buildHalfSplitDigest($rows);
    }

    /**
     * @param list<array{date:string,clicks:int,impressions:int,ctr:float,position:float}> $rows sorted asc by date
     *
     * @return array<string, mixed>
     */
    private function buildHalfSplitDigest(array $rows): array
    {
        $n = count($rows);
        $half = intdiv($n, 2);
        if ($half < 1) {
            $half = $n;
        }
        $first = array_slice($rows, 0, $half);
        $second = array_slice($rows, $half);

        if ($second === []) {
            $second = $first;
        }

        $a = $this->aggregateSegment($first);
        $b = $this->aggregateSegment($second);

        return [
            'strategy' => self::DIGEST_STRATEGY_HALF,
            'days_total' => $n,
            'first_half' => $a,
            'second_half' => $b,
            'second_vs_first' => [
                'clicks_ratio' => $this->safeRatio((float) $b['clicks'], (float) $a['clicks']),
                'impressions_ratio' => $this->safeRatio((float) $b['impressions'], (float) $a['impressions']),
            ],
        ];
    }

    /**
     * @param list<array{date:string,clicks:int,impressions:int,ctr:float,position:float}> $rows sorted asc by date
     *
     * @return array<string, mixed>
     */
    private function buildWeeklyBucketsDigest(array $rows): array
    {
        $n = count($rows);
        $chunks = array_chunk($rows, self::WEEKLY_BUCKET_DAYS);
        $buckets = [];
        $idx = 0;
        foreach ($chunks as $chunk) {
            ++$idx;
            $seg = $this->aggregateSegment($chunk);
            $buckets[] = array_merge(['bucket_index' => $idx], $seg);
        }

        $firstBucket = $buckets[0] ?? [];
        $lastBucket = $buckets[count($buckets) - 1] ?? [];

        return [
            'strategy' => self::DIGEST_STRATEGY_WEEKLY,
            'days_total' => $n,
            'bucket_days' => self::WEEKLY_BUCKET_DAYS,
            'week_buckets' => $buckets,
            'first_week' => $firstBucket,
            'last_week' => $lastBucket,
            'last_vs_first' => [
                'clicks_ratio' => $this->safeRatio(
                    (float) ($lastBucket['clicks'] ?? 0),
                    (float) ($firstBucket['clicks'] ?? 0)
                ),
                'impressions_ratio' => $this->safeRatio(
                    (float) ($lastBucket['impressions'] ?? 0),
                    (float) ($firstBucket['impressions'] ?? 0)
                ),
            ],
        ];
    }

    /**
     * @param list<array{date:string,clicks:int,impressions:int,ctr:float,position:float}> $rows
     *
     * @return array{start_date:string,end_date:string,clicks:int,impressions:int,ctr:float,avg_position:float,day_count:int}
     */
    private function aggregateSegment(array $rows): array
    {
        $clicks = 0;
        $impr = 0;
        $posWeight = 0.0;
        $start = '';
        $end = '';
        foreach ($rows as $r) {
            $c = (int) ($r['clicks'] ?? 0);
            $i = (int) ($r['impressions'] ?? 0);
            $p = (float) ($r['position'] ?? 0.0);
            $clicks += $c;
            $impr += $i;
            if ($i > 0) {
                $posWeight += $p * $i;
            }
            $d = (string) ($r['date'] ?? '');
            if ($d !== '') {
                if ($start === '' || $d < $start) {
                    $start = $d;
                }
                if ($end === '' || $d > $end) {
                    $end = $d;
                }
            }
        }
        $ctr = $impr > 0 ? $clicks / $impr : 0.0;
        $avgPos = $impr > 0 ? $posWeight / $impr : 0.0;

        return [
            'start_date' => $start,
            'end_date' => $end,
            'clicks' => $clicks,
            'impressions' => $impr,
            'ctr' => round($ctr, 6),
            'avg_position' => round($avgPos, 4),
            'day_count' => count($rows),
        ];
    }

    /**
     * @param list<array{date?:string,clicks?:int|float,impressions?:int|float,ctr?:float,position?:float}> $dailySeries
     *
     * @return list<array{date:string,clicks:int,impressions:int,ctr:float,position:float}>
     */
    private function sortedSeriesRows(array $dailySeries): array
    {
        $out = [];
        foreach ($dailySeries as $r) {
            if (!is_array($r)) {
                continue;
            }
            $d = (string) ($r['date'] ?? '');
            if ($d === '') {
                continue;
            }
            $out[] = [
                'date' => $d,
                'clicks' => (int) round((float) ($r['clicks'] ?? 0)),
                'impressions' => (int) round((float) ($r['impressions'] ?? 0)),
                'ctr' => (float) ($r['ctr'] ?? 0.0),
                'position' => (float) ($r['position'] ?? 0.0),
            ];
        }
        usort($out, static fn (array $a, array $b): int => strcmp($a['date'], $b['date']));

        return $out;
    }

    private function safeRatio(float $num, float $den): ?float
    {
        if ($den <= 0.0) {
            return null;
        }

        return round($num / $den, 4);
    }

    /**
     * @param array{clicks:int,impressions:int,ctr:float,position:float}|null $cur
     * @param array{clicks:int,impressions:int,ctr:float,position:float}|null $prev
     * @param array<string, mixed>|null $seriesDigest
     *
     * @return array{0: string, 1: float}
     */
    private function computeDeterministicHealth(?array $cur, ?array $prev, bool $compare, ?array $seriesDigest, bool $ready): array
    {
        if (!$ready || $cur === null) {
            return [self::HEALTH_WARNING, 0.0];
        }

        $penalty = 0.0;

        if (!$compare && $cur['impressions'] > 0 && $cur['impressions'] < self::THIN_DATA_MAX_IMPRESSIONS_NO_COMPARE) {
            $penalty += self::THIN_DATA_PENALTY;
        }

        if ($compare && $prev !== null) {
            $penalty += $this->periodComparePenalty($cur, $prev);
        }

        $penalty += $this->intraPeriodDigestPenalty($seriesDigest);

        if ($penalty >= self::HEALTH_SCORE_CRITICAL) {
            return [self::HEALTH_CRITICAL, $penalty];
        }
        if ($penalty >= self::HEALTH_SCORE_WARNING) {
            return [self::HEALTH_WARNING, $penalty];
        }

        return [self::HEALTH_GOOD, $penalty];
    }

    /**
     * @param array{clicks:int,impressions:int,ctr:float,position:float} $cur
     * @param array{clicks:int,impressions:int,ctr:float,position:float} $prev
     */
    private function periodComparePenalty(array $cur, array $prev): float
    {
        $p = 0.0;

        $dClicks = $this->relDelta((float) $cur['clicks'], (float) $prev['clicks']);
        $dImpr = $this->relDelta((float) $cur['impressions'], (float) $prev['impressions']);
        $dCtr = $this->relDelta((float) $cur['ctr'], (float) $prev['ctr'], 1e-8);
        $dPos = (float) $cur['position'] - (float) $prev['position'];

        if ($dClicks <= -0.30) {
            $p += 3.5;
        } elseif ($dClicks <= -0.15) {
            $p += 2.0;
        }

        if ($dImpr <= -0.25) {
            $p += 2.5;
        } elseif ($dImpr <= -0.10) {
            $p += 1.2;
        }

        if ($dCtr <= -0.22) {
            $p += 2.8;
        } elseif ($dCtr <= -0.10) {
            $p += 1.5;
        }

        if ($prev['position'] > 0.01 && $cur['position'] > 0.01) {
            if ($dPos >= 0.75) {
                $p += 3.5;
            } elseif ($dPos >= 0.35) {
                $p += 2.0;
            } elseif ($dPos >= 0.12) {
                $p += 1.0;
            }
        }

        // Spec: impressions up + CTR down => SERP appeal issue.
        if ($dImpr >= 0.05 && $dCtr <= -0.08) {
            $p += 2.2;
        }

        // Spec: clicks down + ranking worse.
        if ($dClicks <= -0.08 && $dPos >= 0.05 && $prev['position'] > 0.01) {
            $p += 2.5;
        }

        // Offset: broadly positive organic trend.
        if ($dImpr >= 0.06 && $dClicks >= 0.02 && ($dPos <= 0 || $dPos <= 0.05)) {
            $p -= 1.2;
        }
        if ($dClicks >= 0.12 && $dCtr >= 0) {
            $p -= 0.8;
        }
        if ($dPos <= -0.08 && $prev['position'] > 0.01) {
            $p -= 1.0;
        }

        return max(0.0, $p);
    }

    /**
     * Declining demand in the second half of the window (without needing compare mode).
     *
     * @param array<string, mixed>|null $seriesDigest
     */
    private function intraPeriodDigestPenalty(?array $seriesDigest): float
    {
        if ($seriesDigest === null) {
            return 0.0;
        }

        /** @var array{clicks_ratio?:?float}|null $sv */
        $sv = isset($seriesDigest['second_vs_first']) && is_array($seriesDigest['second_vs_first'])
            ? $seriesDigest['second_vs_first']
            : (isset($seriesDigest['last_vs_first']) && is_array($seriesDigest['last_vs_first'])
                ? $seriesDigest['last_vs_first']
                : null);
        if ($sv === null) {
            return 0.0;
        }

        $ratio = $sv['clicks_ratio'] ?? null;
        if ($ratio === null) {
            return 0.0;
        }

        $first = isset($seriesDigest['first_half']) && is_array($seriesDigest['first_half'])
            ? $seriesDigest['first_half']
            : (isset($seriesDigest['first_week']) && is_array($seriesDigest['first_week'])
                ? $seriesDigest['first_week']
                : null);
        if (!is_array($first)) {
            return 0.0;
        }

        $firstClicks = (int) ($first['clicks'] ?? 0);
        $firstImpr = (int) ($first['impressions'] ?? 0);
        $strategy = (string) ($seriesDigest['strategy'] ?? '');
        if ($strategy === self::DIGEST_STRATEGY_WEEKLY) {
            if ($firstClicks < 5 || $firstImpr < 50) {
                return 0.0;
            }
        } elseif ($strategy === self::DIGEST_STRATEGY_HALF) {
            if ($firstClicks < 8 || $firstImpr < 80) {
                return 0.0;
            }
        } else {
            return 0.0;
        }

        if ($ratio <= 0.55) {
            return 3.0;
        }
        if ($ratio <= 0.70) {
            return 2.0;
        }

        return 0.0;
    }

    private function relDelta(float $cur, float $prev, float $eps = 1.0): float
    {
        $den = max(abs($prev), $eps);

        return ($cur - $prev) / $den;
    }
}
