<?php

declare(strict_types=1);

namespace App\Services;

use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;

/**
 * Создаёт месячные RANGE-партиции для таблиц, партиционированных помесячно.
 *
 * Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
 * из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
 *
 * Используется:
 *   - cron `partitions:create-months` — N месяцев вперёд;
 *   - `partitions:drop-expired` — дропает старые партиции;
 *   - HistoricalImportService — под исторический диапазон дат CSV.
 *
 * Hole #2 (23.05.2026): расширен до 9 таблиц (+7 audit-таблиц).
 * Ключ партиционирования теперь задаётся per-table в PARTITIONED_TABLES map.
 */
class MonthlyPartitionManager
{
    /**
     * Connection used for partition DDL (CREATE / DROP).
     *
     * На проде партиционированные родители принадлежат `crm_migrator`;
     * `crm_supplier_worker` — член `crm_migrator` (см. db/02_grants.sql),
     * поэтому через `pgsql_supplier` создаёт/дропает партиции, а
     * дефолтный `crm_app_user` — нет. На dev/тестах `pgsql_supplier`
     * фоллбэчит на `postgres` (superuser) — DDL также проходит.
     *
     * Тесты, триггерящие CREATE/DROP через менеджер, должны подключать
     * `Tests\Concerns\SharesSupplierPdo`, иначе DDL уйдёт мимо
     * test-транзакции (см. trait doc).
     */
    public const DDL_CONNECTION = 'pgsql_supplier';

    /**
     * Таблицы, партиционированные помесячно.
     * Ключ → имя таблицы, значение → колонка-ключ партиционирования.
     *
     * @var array<string, string>
     */
    public const PARTITIONED_TABLES = [
        // Бизнес-таблицы (исходные)
        'deals' => 'received_at',
        'supplier_lead_costs' => 'received_at',
        // Audit-таблицы (hole #2, 23.05.2026)
        'auth_log' => 'created_at',
        'activity_log' => 'created_at',
        'tenant_operations_log' => 'created_at',
        // webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts (legacy direct webhook removal)
        'balance_transactions' => 'created_at',
        'pd_processing_log' => 'created_at',
        'saas_admin_audit_log' => 'created_at',
        // Slepok routing (Этап 2, 27.05.2026)
        'project_routing_snapshots' => 'snapshot_date',
        // Lead region resolution (Session 1, 31.05.2026)
        'lead_region_resolution_log' => 'received_at',
    ];

    /**
     * Гарантирует наличие месячных партиций таблицы для всех месяцев,
     * пересекающих [$from, $to] включительно.
     *
     * @return int Сколько партиций реально создано (0 — все уже были).
     */
    public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
    {
        $this->assertKnownTable($table);

        $month = $from->copy()->startOfMonth();
        $last = $to->copy()->startOfMonth();
        $created = 0;

        while ($month->lessThanOrEqualTo($last)) {
            $created += $this->ensureMonth($table, $month) ? 1 : 0;
            $month = $month->addMonth();
        }

        return $created;
    }

    /**
     * Создаёт одну месячную партицию. Возвращает true, если партиция создана,
     * false — если уже существовала.
     */
    public function ensureMonth(string $table, CarbonInterface $monthStart): bool
    {
        $this->assertKnownTable($table);

        // migrate:fresh resilience: skip gracefully if the partitioned parent table
        // does not exist yet. The initial schema-load migration runs
        // partitions:create-months before later delta-migrations create their own
        // partitioned tables (project_routing_snapshots, lead_region_resolution_log);
        // those migrations create their own partitions. Skipping a not-yet-existing
        // parent here avoids crashing the whole run — and is a targeted guard, so any
        // other DDL error still surfaces.
        $parentExists = DB::selectOne(
            "SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
            [$table],
        );

        if ($parentExists === null) {
            return false;
        }

        $partitionKey = self::PARTITIONED_TABLES[$table];
        $start = $monthStart->copy()->startOfMonth();
        $end = $start->copy()->addMonth();

        // Partition naming: <table>_y<YYYY>_m<MM>
        $partition = sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m'));

        $exists = DB::selectOne(
            "SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
            [$partition],
        );

        if ($exists !== null) {
            return false;
        }

        DB::connection(self::DDL_CONNECTION)->statement(sprintf(
            "CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
            $partition,
            $table,
            $start->format('Y-m-d'),
            $end->format('Y-m-d'),
        ));

        return true;
    }

    /**
     * Возвращает имя партиции для заданной таблицы и месяца.
     * Утилита для тестов и команды drop-expired.
     */
    public function partitionName(string $table, CarbonInterface $monthStart): string
    {
        $this->assertKnownTable($table);
        $start = $monthStart->copy()->startOfMonth();

        return sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m'));
    }

    /**
     * Возвращает список существующих партиций для таблицы через pg_inherits.
     *
     * @return list<string> Имена партиций (relname).
     */
    public function listPartitions(string $table): array
    {
        $this->assertKnownTable($table);

        $rows = DB::select(
            'SELECT c.relname
             FROM pg_inherits i
             JOIN pg_class c ON c.oid = i.inhrelid
             JOIN pg_class p ON p.oid = i.inhparent
             WHERE p.relname = ?
             ORDER BY c.relname',
            [$table],
        );

        return array_map(fn ($r) => $r->relname, $rows);
    }

    private function assertKnownTable(string $table): void
    {
        if (! array_key_exists($table, self::PARTITIONED_TABLES)) {
            throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
        }
    }
}
