<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Throwable;

class TransferSqliteToMysql extends Command
{
    protected $signature = 'db:transfer-sqlite-to-mysql
        {--execute : Perform writes; without this flag the command is a dry run}
        {--truncate : Empty transferable target tables before importing}
        {--chunk=250 : Rows processed per batch}';

    protected $description = 'Idempotently transfer application data from SQLite to a separately configured MySQL database';

    private const EXCLUDED_TABLES = [
        'migrations',
        'cache',
        'cache_locks',
        'jobs',
        'job_batches',
        'failed_jobs',
        'password_reset_tokens',
        'sessions',
        'personal_access_tokens',
    ];

    private const NATURAL_KEYS = [
        'role_user' => ['role_id', 'user_id'],
        'permission_role' => ['permission_id', 'role_id'],
        'project_user' => ['project_id', 'user_id'],
        'sprint_task' => ['sprint_id', 'task_id'],
        'sprint_members' => ['sprint_id', 'user_id'],
        'meeting_user' => ['meeting_id', 'user_id'],
        'department_user' => ['department_id', 'user_id'],
    ];

    public function handle(): int
    {
        $sourcePath = config('database.connections.migration_sqlite.database');
        if (! is_string($sourcePath) || ! is_file($sourcePath)) {
            $this->error('SQLite source file does not exist.');

            return self::FAILURE;
        }

        if (config('database.connections.migration_mysql.driver') !== 'mysql') {
            $this->error('The migration target must use the mysql driver.');

            return self::FAILURE;
        }

        $source = DB::connection('migration_sqlite');
        $target = DB::connection('migration_mysql');

        try {
            $target->select('SELECT 1');
        } catch (Throwable $exception) {
            $this->error('MySQL target connection failed: '.$exception->getCode());

            return self::FAILURE;
        }

        $tables = $this->transferableTables($source);
        if ($tables === []) {
            $this->warn('No transferable tables were found.');

            return self::SUCCESS;
        }

        $this->table(['Mode', 'Source', 'Target', 'Tables'], [[
            $this->option('execute') ? 'EXECUTE' : 'DRY RUN',
            basename($sourcePath),
            (string) config('database.connections.migration_mysql.database'),
            count($tables),
        ]]);

        if (! $this->option('execute')) {
            foreach ($tables as $table) {
                $this->line(sprintf('%s: %d rows', $table, $source->table($table)->count()));
            }

            return self::SUCCESS;
        }

        $chunkSize = max(10, min((int) $this->option('chunk'), 2000));
        Schema::connection('migration_mysql')->disableForeignKeyConstraints();

        try {
            if ($this->option('truncate')) {
                foreach (array_reverse($tables) as $table) {
                    $target->table($table)->truncate();
                }
            }

            foreach ($tables as $table) {
                $this->transferTable($source, $target, $table, $chunkSize);
            }
        } finally {
            Schema::connection('migration_mysql')->enableForeignKeyConstraints();
        }

        $failed = false;
        foreach ($tables as $table) {
            $sourceCount = $source->table($table)->count();
            $targetCount = $target->table($table)->count();
            $sourceDigest = $this->tableDigest($source, $table);
            $targetDigest = $this->tableDigest($target, $table);
            $match = $sourceCount === $targetCount && hash_equals($sourceDigest, $targetDigest);
            $failed = $failed || ! $match;
            $this->line(sprintf(
                '%s source=%d target=%d content=%s %s',
                $table,
                $sourceCount,
                $targetCount,
                hash_equals($sourceDigest, $targetDigest) ? 'MATCH' : 'MISMATCH',
                $match ? 'OK' : 'MISMATCH'
            ));
        }

        if ($failed) {
            $this->error('Transfer completed with count mismatches.');

            return self::FAILURE;
        }

        $this->info('Transfer and row-count validation completed successfully.');

        return self::SUCCESS;
    }

    private function transferableTables(ConnectionInterface $source): array
    {
        $sourceTables = collect($source->select(
            "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
        ))->pluck('name');
        $targetTables = collect(Schema::connection('migration_mysql')->getTableListing())
            ->map(fn (string $table) => str_contains($table, '.') ? str($table)->afterLast('.')->toString() : $table);

        $missing = $sourceTables->diff($targetTables)->reject(
            fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true)
        );
        if ($missing->isNotEmpty()) {
            throw new RuntimeException('Target schema is missing tables: '.$missing->implode(', '));
        }

        return $sourceTables
            ->intersect($targetTables)
            ->reject(fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true))
            ->values()
            ->all();
    }

    private function transferTable(
        ConnectionInterface $source,
        ConnectionInterface $target,
        string $table,
        int $chunkSize
    ): void {
        $columns = collect(Schema::connection('migration_mysql')->getColumnListing($table));
        $keys = $this->keyColumns($source, $table);
        $orderColumn = $keys[0] ?? $columns->first();
        $processed = 0;

        $source->table($table)
            ->orderBy($orderColumn)
            ->chunk($chunkSize, function ($rows) use ($target, $table, $columns, $keys, &$processed) {
                $payload = collect($rows)->map(
                    fn ($row) => collect((array) $row)->only($columns)->all()
                )->all();

                if ($payload === []) {
                    return;
                }

                if ($keys !== []) {
                    $target->table($table)->upsert($payload, $keys, $columns->diff($keys)->all());
                } else {
                    foreach ($payload as $row) {
                        $target->table($table)->updateOrInsert($row, $row);
                    }
                }
                $processed += count($payload);
            });

        $this->info(sprintf('%s: %d rows transferred', $table, $processed));
    }

    private function keyColumns(ConnectionInterface $source, string $table): array
    {
        if (isset(self::NATURAL_KEYS[$table])) {
            return self::NATURAL_KEYS[$table];
        }

        return collect($source->select("PRAGMA table_info('".str_replace("'", "''", $table)."')"))
            ->filter(fn ($column) => (int) $column->pk > 0)
            ->sortBy(fn ($column) => (int) $column->pk)
            ->pluck('name')
            ->all();
    }

    private function tableDigest(ConnectionInterface $connection, string $table): string
    {
        $columns = Schema::connection($connection->getName())->getColumnListing($table);
        sort($columns);
        $order = $this->keyColumns(DB::connection('migration_sqlite'), $table);
        if ($order === []) {
            $order = $columns;
        }

        $query = $connection->table($table)->select($columns);
        foreach ($order as $column) {
            $query->orderBy($column);
        }

        $context = hash_init('sha256');
        foreach ($query->cursor() as $row) {
            $normalized = [];
            foreach ($columns as $column) {
                $normalized[$column] = $this->normalizeValue($row->{$column});
            }
            hash_update($context, json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n");
        }

        return hash_final($context);
    }

    private function normalizeValue(mixed $value): mixed
    {
        if ($value === null) {
            return null;
        }

        $string = (string) $value;
        if (preg_match('/^\d{4}-\d{2}-\d{2} 00:00:00$/', $string)) {
            return substr($string, 0, 10);
        }

        $decoded = json_decode($string, true);
        if (json_last_error() === JSON_ERROR_NONE && (str_starts_with($string, '{') || str_starts_with($string, '['))) {
            return $this->normalizeJson($decoded);
        }

        if (preg_match('/^-?\d+\.\d+$/', $string)) {
            return rtrim(rtrim($string, '0'), '.');
        }

        return $string;
    }

    private function normalizeJson(mixed $value): mixed
    {
        if (! is_array($value)) {
            return $value;
        }

        if (! array_is_list($value)) {
            ksort($value);
        }

        return array_map(fn ($item) => $this->normalizeJson($item), $value);
    }
}
