<?php

namespace App\Services\Forecasting;

use App\Models\Backtest;
use App\Services\Forecasting\Contracts\ForecastFeature;
use App\Services\Forecasting\Features\DeltaUlsdLag;
use App\Services\Forecasting\Features\DeltaUlspLag;
use App\Services\Forecasting\Features\IsPreBankHoliday;
use App\Services\Forecasting\Features\UlspMinusMa8;
use App\Services\Forecasting\Features\WeekOfYearTrig;
use App\Services\Forecasting\Models\RidgeRegressionModel;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use RuntimeException;

/**
 * Layer 1 — orchestrates the ridge model end-to-end:
 *
 *   1. Builds the canonical v1 feature spec (8 features).
 *   2. Trains the ridge model on every available BEIS Monday.
 *   3. Predicts for the upcoming Monday.
 *   4. Looks up the latest matching backtest for calibrated confidence.
 *   5. Returns a flat array keyed for the existing public JSON contract.
 *
 * Trained-model state is cached for 1 hour (key includes model_version)
 * so repeated request hits don't retrain. A new BEIS week or a feature
 * spec change rolls model_version, busting the cache automatically.
 */
final class WeeklyForecastService
{
    private const float DEFAULT_LAMBDA = 1.0;

    public function currentForecast(): array
    {
        $loader = new WeeklyPumpPriceLoader;
        $features = $this->buildFeatures($loader);
        $spec = new FeatureSpec('ridge-v1', $features);

        $cacheKey = 'forecast:current:'.$spec->modelVersion();

        return Cache::remember($cacheKey, 3600, function () use ($loader, $spec, $features): array {
            $model = new RidgeRegressionModel($spec, $loader, self::DEFAULT_LAMBDA);

            try {
                $model->train($this->collectTrainingMondays($loader));
            } catch (RuntimeException) {
                return $this->insufficientDataPayload($spec);
            }

            $targetMonday = $this->upcomingMonday();
            $prediction = $model->predict($targetMonday);

            $rawConfidence = $this->confidenceFromCalibration($spec, $prediction);
            $flaggedDutyChange = (new DutyChangeDetector)->isAdjacent($targetMonday);
            $confidence = $flaggedDutyChange ? (int) round($rawConfidence / 2) : $rawConfidence;

            $directionPublic = $this->mapDirection($prediction->direction);
            $action = $this->mapAction($directionPublic, $confidence);

            $trailingHitRate = (new AccuracyHistory)->trailingHitRate($spec->modelVersion());

            $reasoning = (new ReasoningGenerator)->generate(
                $model,
                $prediction,
                $features,
                $targetMonday,
                $confidence,
                $flaggedDutyChange,
                $trailingHitRate,
            );

            $this->persistForecast($spec, $targetMonday, $prediction, $confidence, $flaggedDutyChange, $reasoning);

            return [
                'fuel_type' => 'e10',
                'current_avg' => $this->nationalCurrentAverage(),
                'predicted_direction' => $directionPublic,
                'predicted_change_pence' => round($prediction->magnitudePence / 100, 1),
                'confidence_score' => $confidence,
                'confidence_label' => $this->confidenceLabel($confidence),
                'action' => $action,
                'reasoning' => $reasoning,
                'prediction_horizon_days' => 7,
                'region_key' => 'national',
                'methodology' => 'ridge_regression_v1',
                'model_version' => $spec->modelVersion(),
                'flagged_duty_change' => $flaggedDutyChange,
                'trailing_hit_rate' => $trailingHitRate,
                'weekly_summary' => $this->weeklySummary($loader),
                'signals' => $this->describeSignals($model, $prediction),
            ];
        });
    }

    /**
     * Build the canonical v1 feature list. Centralised here so
     * WeeklyForecastService and any retraining command share the same
     * spec.
     *
     * @return array<int, ForecastFeature>
     */
    private function buildFeatures(WeeklyPumpPriceLoader $loader): array
    {
        return [
            new DeltaUlspLag($loader, lag: 0),
            new DeltaUlspLag($loader, lag: 1),
            new DeltaUlspLag($loader, lag: 3),
            new DeltaUlsdLag($loader, lag: 0),
            new UlspMinusMa8($loader),
            new WeekOfYearTrig('sin'),
            new WeekOfYearTrig('cos'),
            new IsPreBankHoliday,
        ];
    }

    /** @return array<int, CarbonInterface> */
    private function collectTrainingMondays(WeeklyPumpPriceLoader $loader): array
    {
        return array_map(fn (string $d): CarbonInterface => Carbon::parse($d), $loader->allDates());
    }

    private function upcomingMonday(): CarbonInterface
    {
        $today = now()->startOfDay();

        return $today->isMonday() ? $today : $today->copy()->next(Carbon::MONDAY);
    }

    private function confidenceFromCalibration(FeatureSpec $spec, WeeklyPrediction $prediction): int
    {
        $latest = Backtest::query()
            ->where('model_version', $spec->modelVersion())
            ->orderByDesc('ran_at')
            ->first();

        if ($latest === null) {
            return 0; // no backtest yet → low (gate 2 will force no_signal)
        }

        $table = (array) ($latest->calibration_table ?? []);
        $bin = $this->bucketForMagnitude($prediction->magnitudePence);
        $hitRate = $table[$bin] ?? null;

        if ($hitRate === null) {
            return (int) round((float) ($latest->directional_accuracy ?? 0));
        }

        return (int) round(((float) $hitRate) * 100);
    }

    private function bucketForMagnitude(float $magnitudePence): string
    {
        $abs = abs($magnitudePence);

        return match (true) {
            $abs < 50.0 => '0.0-0.5p',
            $abs < 100.0 => '0.5-1.0p',
            default => '1.0p+',
        };
    }

    private function mapDirection(string $modelDirection): string
    {
        return match ($modelDirection) {
            'rising' => 'up',
            'falling' => 'down',
            default => 'stable',
        };
    }

    private function mapAction(string $publicDirection, int $confidence): string
    {
        if ($publicDirection === 'stable' || $confidence < 40) {
            return 'no_signal';
        }

        return $publicDirection === 'up' ? 'fill_now' : 'wait';
    }

    private function confidenceLabel(int $confidence): string
    {
        return match (true) {
            $confidence >= 70 => 'high',
            $confidence >= 40 => 'medium',
            default => 'low',
        };
    }

    /**
     * Graceful payload when the model can't train (e.g. fresh install,
     * not enough BEIS rows yet). Honest about not-knowing — verdict is
     * no_signal, confidence 0, reasoning explains why.
     *
     * @return array<string, mixed>
     */
    private function insufficientDataPayload(FeatureSpec $spec): array
    {
        return [
            'fuel_type' => 'e10',
            'current_avg' => $this->nationalCurrentAverage(),
            'predicted_direction' => 'stable',
            'predicted_change_pence' => 0.0,
            'confidence_score' => 0,
            'confidence_label' => 'low',
            'action' => 'no_signal',
            'reasoning' => 'Not enough historical BEIS data yet to train the forecast model — staying silent until the series fills in.',
            'prediction_horizon_days' => 7,
            'region_key' => 'national',
            'methodology' => 'ridge_regression_v1',
            'model_version' => $spec->modelVersion(),
            'weekly_summary' => [
                'latest_publication_date' => null,
                'latest_avg_pence' => null,
                'prior_avg_pence' => null,
                'latest_change_pence' => null,
            ],
            'signals' => [],
        ];
    }

    private function nationalCurrentAverage(): float
    {
        $avg = DB::table('station_prices_current')
            ->where('fuel_type', 'e10')
            ->avg('price_pence');

        return $avg === null ? 0.0 : round((float) $avg / 100, 1);
    }

    /** @return array<string, mixed> */
    private function weeklySummary(WeeklyPumpPriceLoader $loader): array
    {
        $dates = $loader->allDates();
        $latest = end($dates) ?: null;
        $prior = $latest === null ? null : ($dates[count($dates) - 2] ?? null);

        $todayPence = $latest === null ? null : $loader->ulspPence($latest);
        $priorPence = $prior === null ? null : $loader->ulspPence($prior);

        return [
            'latest_publication_date' => $latest,
            'latest_avg_pence' => $todayPence === null ? null : round($todayPence / 100, 1),
            'prior_avg_pence' => $priorPence === null ? null : round($priorPence / 100, 1),
            'latest_change_pence' => $todayPence !== null && $priorPence !== null
                ? round(($todayPence - $priorPence) / 100, 1)
                : null,
        ];
    }

    /**
     * Backward-compat 'signals' key. Now describes which features carried
     * the most weight in this week's prediction (z-score × β contribution).
     *
     * @return array<string, array<string, mixed>>
     */
    private function describeSignals(RidgeRegressionModel $model, WeeklyPrediction $prediction): array
    {
        $coeffs = $model->coefficients();
        if ($coeffs === null) {
            return [];
        }

        return [
            'ridge_v1' => [
                'enabled' => true,
                'direction' => $prediction->direction,
                'magnitude_pence' => round($prediction->magnitudePence / 100, 2),
                'feature_count' => count($coeffs['features'] ?? []),
                'lambda' => $coeffs['lambda'] ?? null,
            ],
        ];
    }

    /**
     * Persist the forecast row so Phase 6's outcome resolver can pair
     * it with the actual ULSP when the next BEIS week lands.
     * Idempotent on (forecast_for, model_version) via UPSERT.
     */
    private function persistForecast(
        FeatureSpec $spec,
        CarbonInterface $targetMonday,
        WeeklyPrediction $prediction,
        int $confidence,
        bool $flaggedDutyChange,
        string $reasoning,
    ): void {
        DB::table('weekly_forecasts')->upsert(
            [[
                'forecast_for' => $targetMonday->toDateString(),
                'model_version' => $spec->modelVersion(),
                'direction' => $prediction->direction,
                'magnitude_pence' => (int) round($prediction->magnitudePence),
                'ridge_confidence' => max(0, min(100, $confidence)),
                'flagged_duty_change' => $flaggedDutyChange,
                'reasoning' => $reasoning,
                'generated_at' => now(),
                'created_at' => now(),
                'updated_at' => now(),
            ]],
            ['forecast_for', 'model_version'],
            ['direction', 'magnitude_pence', 'ridge_confidence', 'flagged_duty_change', 'reasoning', 'generated_at', 'updated_at'],
        );
    }
}
