<?php

namespace App\Services;

use App\Exceptions\ImportEstacionesException;
use App\Imports\EstacionesImport;
use App\Models\EstacionImportOmitida;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;

class EstacionesImportService
{
    private const HEADING_ROW = 1;
    private const MAX_ROWS = 1000;
    private const REQUIRED_HEADINGS = [
        'direccion',
        'coordenadas',
        'ciudad',
        'departamento',
    ];

    /**
     * Procesa el Excel completo dentro de un flujo controlado.
     *
     * Se valida primero la estructura del archivo para fallar rápido y evitar
     * borrar datos vigentes con un input inválido o incompleto.
     *
     * @throws ImportEstacionesException
     */
    public function import(UploadedFile $file, array $mapping): array
    {
        try {
            $batchId = (string) Str::uuid();
            $import = new EstacionesImport();
            $import->import($file);

            $rows = $import->rows;
            $mapping = $this->normalizeMapping($mapping);
            $this->assertHeadings($rows, $mapping);
            $this->assertRowCount($rows);

            $cities = $this->makeCityLookup();
            [$preparedRows, $skippedRows] = $this->prepareRows($rows, $cities, $mapping);

            if (count($preparedRows) === 0) {
                throw new ImportEstacionesException('No se encontraron filas válidas para importar.');
            }

            DB::transaction(function () use ($preparedRows, $skippedRows, $batchId) {
                DB::query()->from('promociones_estaciones')->delete();
                DB::query()->from('estaciones_servicios')->delete();
                DB::query()->from('estaciones')->delete();
                // El resumen de omitidas debe reflejar sólo el lote vigente para
                // evitar que operación mezcle pendientes históricos con la
                // importación que acaba de ejecutar.
                DB::query()->from('estaciones_import_omitidas')->delete();
                DB::query()->from('estaciones')->insert($preparedRows);
                $this->storeSkippedRows($skippedRows, $batchId);
            });

            return [
                'message' => 'Importación de estaciones finalizada.',
                'batch_id' => $batchId,
                'total_rows' => $rows->count(),
                'imported_rows' => count($preparedRows),
                'skipped_rows' => count($skippedRows),
                'skipped' => $skippedRows,
            ];
        } catch (ImportEstacionesException $exception) {
            throw $exception;
        } catch (Throwable $exception) {
            throw new ImportEstacionesException(
                'No se pudo procesar el archivo de estaciones.'
            );
        }
    }

    /**
     * Reintenta únicamente filas omitidas ya revisadas manualmente.
     *
     * Este flujo no reemplaza estaciones existentes: agrega sólo las filas
     * corregidas para no destruir el resultado válido de la importación previa.
     *
     * @param array<int, array<string, mixed>> $rows
     *
     * @throws ImportEstacionesException
     */
    public function importSkippedRows(array $rows): array
    {
        try {
            $preparedRows = [];
            $skippedRows = [];

            foreach ($rows as $row) {
                $rowNumber = (int) ($row['row'] ?? 0);
                $direccion = trim((string) ($row['direccion'] ?? ''));
                $coordenadas = trim((string) ($row['coordenadas'] ?? ''));
                $ciudadId = (int) ($row['ciudad_id'] ?? 0);
                $ciudad = trim((string) ($row['ciudad'] ?? ''));
                $departamento = trim((string) ($row['departamento'] ?? ''));

                $ubicacion = $this->parseCoordinates($coordenadas);

                if ($ubicacion === null) {
                    $skippedRows[] = array_filter([
                        'row' => $rowNumber,
                        'direccion' => $direccion,
                        'coordenadas' => $coordenadas,
                        'ciudad' => $ciudad,
                        'departamento' => $departamento,
                        'reason' => 'Las coordenadas no tienen un formato válido.',
                    ], static fn ($value) => $value !== null && $value !== '');
                    continue;
                }

                $preparedRows[] = $this->buildInsertRow($direccion, $ciudadId, $ubicacion);
            }

            if ($preparedRows === []) {
                throw new ImportEstacionesException('No se encontraron filas corregidas válidas para cargar.');
            }

            DB::query()->from('estaciones')->insert($preparedRows);

            return [
                'message' => 'Las filas corregidas se cargaron correctamente.',
                'total_rows' => count($rows),
                'imported_rows' => count($preparedRows),
                'skipped_rows' => count($skippedRows),
                'skipped' => $skippedRows,
            ];
        } catch (ImportEstacionesException $exception) {
            throw $exception;
        } catch (Throwable $exception) {
            throw new ImportEstacionesException(
                'No se pudieron cargar las filas corregidas.'
            );
        }
    }

    /**
     * Resuelve una omitida persistida insertando la estación corregida.
     *
     * La fila puede traer una ciudad seleccionada manualmente o intentar
     * resolverse otra vez desde ciudad/departamento corregidos en pantalla.
     *
     * @throws ImportEstacionesException
     */
    public function resolveSkippedRecord(EstacionImportOmitida $record): array
    {
        $direccion = trim((string) $record->direccion);
        $coordenadas = trim((string) $record->coordenadas);
        $ciudad = trim((string) $record->ciudad);
        $departamento = trim((string) $record->departamento);

        if ($direccion === '') {
            throw new ImportEstacionesException('La dirección sigue siendo obligatoria para resolver la fila.');
        }

        $ubicacion = $this->parseCoordinates($coordenadas);

        if ($ubicacion === null) {
            throw new ImportEstacionesException('Las coordenadas no tienen un formato válido.');
        }

        $ciudadId = $record->resolved_ciudad_id;

        if ($ciudadId === null) {
            $cities = $this->makeCityLookup();
            $ciudadId = $this->resolveCityId($ciudad, $departamento, $cities);
        }

        if ($ciudadId === null) {
            throw new ImportEstacionesException('Debe seleccionar una ciudad o corregir ciudad y departamento antes de resolver.');
        }

        DB::transaction(function () use ($record, $direccion, $ciudadId, $ubicacion) {
            DB::query()->from('estaciones')->insert([
                $this->buildInsertRow($direccion, $ciudadId, $ubicacion),
            ]);

            $record->fill([
                'resolved_ciudad_id' => $ciudadId,
                'status' => 'resuelto',
                'resolved_at' => now(),
            ])->save();
        });

        return [
            'message' => 'La fila omitida se resolvió correctamente.',
            'id' => $record->id,
            'status' => 'resuelto',
        ];
    }

    /**
     * Valida el encabezado esperado usando las keys entregadas por el import.
     *
     * Esto evita procesar archivos con columnas corridas o plantillas
     * incorrectas, que son una causa frecuente de corrupción operativa.
     *
     * @throws ImportEstacionesException
     */
    private function assertHeadings(Collection $rows, array $mapping): void
    {
        $firstRow = $rows->first();

        if (!$firstRow instanceof Collection) {
            return;
        }

        $normalizedHeadings = $firstRow->keys()->all();
        $missingHeadings = array_values(array_diff(self::REQUIRED_HEADINGS, array_keys($mapping)));
        $invalidMappedHeadings = array_values(array_diff(array_values($mapping), $normalizedHeadings));

        if ($missingHeadings !== [] || $invalidMappedHeadings !== []) {
            throw new ImportEstacionesException(
                'El archivo no tiene el formato esperado para importar estaciones.'
            );
        }
    }

    /**
     * Pone un límite defensivo al volumen importable.
     *
     * El archivo operativo actual está muy por debajo de este umbral y el
     * límite evita ejecuciones costosas o cargas accidentales masivas.
     *
     * @throws ImportEstacionesException
     */
    private function assertRowCount(Collection $rows): void
    {
        if ($rows->count() > self::MAX_ROWS) {
            throw new ImportEstacionesException(
                'El archivo supera la cantidad máxima de filas permitidas para este importador.'
            );
        }
    }

    /**
     * Transforma las filas del Excel en inserts seguros y en un resumen
     * utilizable por la interfaz para explicar omisiones.
     *
     * @return array{0: array<int, array<string, mixed>>, 1: array<int, array<string, mixed>>}
     */
    private function prepareRows(Collection $rows, array $cities, array $mapping): array
    {
        $preparedRows = [];
        $skippedRows = [];

        foreach ($rows as $index => $row) {
            $excelRow = self::HEADING_ROW + $index + 1;

            if ($this->isEmptyRow($row)) {
                continue;
            }

            $direccion = trim((string) ($row[$mapping['direccion']] ?? ''));
            $coordenadas = trim((string) ($row[$mapping['coordenadas']] ?? ''));
            $ciudad = trim((string) ($row[$mapping['ciudad']] ?? ''));
            $departamento = trim((string) ($row[$mapping['departamento']] ?? ''));

            if ($direccion === '') {
                $skippedRows[] = $this->skipRow(
                    $row,
                    $excelRow,
                    'La dirección es obligatoria.',
                    $direccion,
                    $coordenadas,
                    $ciudad,
                    $departamento
                );
                continue;
            }

            $ubicacion = $this->parseCoordinates($coordenadas);

            if ($ubicacion === null) {
                $skippedRows[] = $this->skipRow(
                    $row,
                    $excelRow,
                    'Las coordenadas no tienen un formato válido.',
                    $direccion,
                    $coordenadas,
                    $ciudad,
                    $departamento
                );
                continue;
            }

            $ciudadId = $this->resolveCityId($ciudad, $departamento, $cities);

            if ($ciudadId === null) {
                $skippedRows[] = $this->skipRow(
                    $row,
                    $excelRow,
                    'No se encontró una ciudad normalizada que coincida con la fila.',
                    $direccion,
                    $coordenadas,
                    $ciudad,
                    $departamento
                );
                continue;
            }

            $preparedRows[] = $this->buildInsertRow($direccion, $ciudadId, $ubicacion);
        }

        return [$preparedRows, $skippedRows];
    }

    /**
     * Persiste omitidas para poder revisarlas después desde el admin.
     *
     * Se guarda una versión acotada y explicable de la fila, junto con el lote
     * al que pertenece, para no depender del popup temporal del frontend.
     *
     * @param array<int, array<string, mixed>> $skippedRows
     */
    private function storeSkippedRows(array $skippedRows, string $batchId): void
    {
        if ($skippedRows === []) {
            return;
        }

        $payload = array_map(function (array $row) use ($batchId) {
            return [
                'batch_id' => $batchId,
                'row_number' => (int) ($row['row'] ?? 0),
                'direccion' => $row['direccion'] ?? null,
                'coordenadas' => $row['coordenadas'] ?? null,
                'ciudad' => $row['ciudad'] ?? null,
                'departamento' => $row['departamento'] ?? null,
                'failed_field' => $this->detectFailedField((string) ($row['reason'] ?? '')),
                'reason' => (string) ($row['reason'] ?? 'Fila omitida.'),
                'row_data' => isset($row['row_data']) ? json_encode($row['row_data'], JSON_THROW_ON_ERROR) : null,
                'status' => 'pendiente',
                'created_at' => now(),
                'updated_at' => now(),
            ];
        }, $skippedRows);

        DB::query()->from('estaciones_import_omitidas')->insert($payload);
    }

    /**
     * Construye un lookup compacto por nombre y por dupla ciudad/departamento.
     *
     * Se seleccionan sólo columnas necesarias para mantener la consulta liviana
     * y evitar traer datos inútiles a memoria en cada importación.
     */
    private function makeCityLookup(): array
    {
        $rows = DB::query()
            ->from('ciudades as c')
            ->join('departamentos as d', 'd.id', '=', 'c.departamento_id')
            ->select([
                'c.id',
                'c.nombre as ciudad',
                'd.nombre as departamento',
            ])
            ->get();

        $aliasRows = DB::query()
            ->from('ciudades_aliases as ca')
            ->join('ciudades as c', 'c.id', '=', 'ca.ciudad_id')
            ->join('departamentos as d', 'd.id', '=', 'c.departamento_id')
            ->select([
                'c.id',
                'ca.nombre as ciudad',
                'd.nombre as departamento',
            ])
            ->get();

        $citiesByDepartment = [];
        $citiesByName = [];
        $records = [];

        foreach ($rows as $row) {
            $this->appendCityLookupRecord($citiesByDepartment, $citiesByName, $records, $row);
        }

        foreach ($aliasRows as $row) {
            $this->appendCityLookupRecord($citiesByDepartment, $citiesByName, $records, $row);
        }

        return [
            'by_department' => $citiesByDepartment,
            'by_name' => $citiesByName,
            'records' => $records,
        ];
    }

    /**
     * Resuelve la ciudad priorizando la combinación ciudad/departamento y
     * usando sólo el nombre cuando el match es inequívoco.
     */
    private function resolveCityId(string $city, string $department, array $cities): ?int
    {
        $cityKey = $this->normalizeText($city);
        $departmentKey = $this->normalizeText($department);

        if ($cityKey === '') {
            return null;
        }

        $key = "{$cityKey}|{$departmentKey}";

        if ($departmentKey !== '' && isset($cities['by_department'][$key])) {
            return $cities['by_department'][$key];
        }

        $matches = $cities['by_name'][$cityKey] ?? [];

        if (count($matches) === 1) {
            return $matches[0];
        }

        $departmentMatches = $this->findDepartmentCandidates($cityKey, $departmentKey, $cities);

        if (count($departmentMatches) === 1) {
            return $departmentMatches[0]['id'];
        }

        $similarMatch = $this->findSimilarCityCandidate($cityKey, $departmentKey, $cities);

        return $similarMatch['id'] ?? null;
    }

    /**
     * Busca coincidencias amplias dentro del mismo departamento cuando el Excel
     * trae variantes parciales del nombre oficial.
     *
     * Se acepta sólo si la coincidencia deja un único candidato claro para no
     * degradar integridad de datos por asignaciones dudosas.
     *
     * @return array<int, array{id:int, city:string, department:string}>
     */
    private function findDepartmentCandidates(string $cityKey, string $departmentKey, array $cities): array
    {
        if ($departmentKey === '') {
            return [];
        }

        return array_values(array_filter(
            $cities['records'] ?? [],
            static function (array $record) use ($cityKey, $departmentKey) {
                if ($record['department'] !== $departmentKey) {
                    return false;
                }

                return str_contains($record['city'], $cityKey)
                    || str_contains($cityKey, $record['city']);
            }
        ));
    }

    /**
     * Aplica una similitud defensiva sólo dentro del mismo departamento.
     *
     * El umbral alto y la diferencia mínima contra el segundo candidato evitan
     * matches agresivos cuando hay nombres parecidos en el mismo catálogo.
     *
     * @return array{id:int, city:string, department:string, score:float}|null
     */
    private function findSimilarCityCandidate(string $cityKey, string $departmentKey, array $cities): ?array
    {
        if ($departmentKey === '') {
            return null;
        }

        $scored = [];

        foreach ($cities['records'] ?? [] as $record) {
            if ($record['department'] !== $departmentKey) {
                continue;
            }

            similar_text($cityKey, $record['city'], $score);

            $scored[] = [
                ...$record,
                'score' => $score,
            ];
        }

        usort($scored, static fn (array $a, array $b) => $b['score'] <=> $a['score']);

        $best = $scored[0] ?? null;
        $second = $scored[1] ?? null;

        if ($best === null || $best['score'] < 82.0) {
            return null;
        }

        if ($second !== null && ($best['score'] - $second['score']) < 6.0) {
            return null;
        }

        return $best;
    }

    /**
     * Convierte el texto "lat,lng" en la estructura JSON esperada por la tabla.
     */
    private function parseCoordinates(string $coordinates): ?array
    {
        if ($coordinates === '') {
            return null;
        }

        $parts = preg_split('/\s*,\s*/', $coordinates);

        if (!is_array($parts) || count($parts) !== 2) {
            return null;
        }

        [$lat, $lng] = $parts;

        if (!is_numeric($lat) || !is_numeric($lng)) {
            return null;
        }

        return [
            'lat' => (float) $lat,
            'lng' => (float) $lng,
        ];
    }

    /**
     * Agrega nombres oficiales y aliases al mismo lookup de resolución.
     *
     * Se deduplican ids por nombre para evitar que un alias repetido degrade el
     * match por nombre inequívoco o la búsqueda por similitud.
     */
    private function appendCityLookupRecord(array &$byDepartment, array &$byName, array &$records, object $row): void
    {
        $cityKey = $this->normalizeText($row->ciudad);
        $departmentKey = $this->normalizeText($row->departamento);

        if ($cityKey === '' || $departmentKey === '') {
            return;
        }

        $byDepartment["{$cityKey}|{$departmentKey}"] = $row->id;

        if (!isset($byName[$cityKey])) {
            $byName[$cityKey] = [];
        }

        if (!in_array($row->id, $byName[$cityKey], true)) {
            $byName[$cityKey][] = $row->id;
        }

        foreach ($records as $record) {
            if (
                $record['id'] === $row->id &&
                $record['city'] === $cityKey &&
                $record['department'] === $departmentKey
            ) {
                return;
            }
        }

        $records[] = [
            'id' => $row->id,
            'city' => $cityKey,
            'department' => $departmentKey,
        ];
    }

    /**
     * Normaliza acentos, signos y espacios para robustecer el match con datos
     * cargados manualmente en catálogos normalizados.
     */
    private function normalizeText(?string $value): string
    {
        $normalized = Str::of((string) $value)
            ->ascii()
            ->lower()
            ->replaceMatches('/\bpdte\b/', 'presidente')
            ->replaceMatches('/\bgral\b/', 'general')
            ->replaceMatches('/\bmcal\b/', 'mariscal')
            ->replaceMatches('/\bsta\b/', 'santa')
            ->replaceMatches('/\bsto\b/', 'santo')
            ->replaceMatches('/[^a-z0-9]+/', ' ')
            ->trim()
            ->toString();

        return preg_replace('/\s+/', ' ', $normalized) ?? '';
    }

    /**
     * Detecta filas realmente vacías para no contarlas como omitidas.
     */
    private function isEmptyRow($row): bool
    {
        foreach ($row as $value) {
            if (trim((string) $value) !== '') {
                return false;
            }
        }

        return true;
    }

    /**
     * Arma un payload de omisión consumible por la UI sin exponer detalles
     * internos del procesamiento.
     */
    private function skipRow(
        Collection $sourceRow,
        int $row,
        string $reason,
        ?string $direccion = null,
        ?string $coordenadas = null,
        ?string $ciudad = null,
        ?string $departamento = null
    ): array {
        return array_filter([
            'row' => $row,
            'direccion' => $direccion,
            'coordenadas' => $coordenadas,
            'ciudad' => $ciudad,
            'departamento' => $departamento,
            'reason' => $reason,
            'row_data' => $this->serializeSourceRow($sourceRow),
        ], static fn ($value) => $value !== null && $value !== '');
    }

    /**
     * Conserva la fila original normalizada para poder regenerar una planilla de
     * corrección con la misma estructura que se importó.
     */
    private function serializeSourceRow(Collection $row): array
    {
        return $row->mapWithKeys(function ($value, $key) {
            return [(string) $key => trim((string) $value)];
        })->all();
    }

    /**
     * Construye el insert final con timestamps y columnas explícitas.
     *
     * Se evita depender de mutators del modelo para ganar rendimiento en la
     * carga masiva y para dejar el SQL resultante totalmente predecible.
     */
    private function buildInsertRow(string $direccion, int $ciudadId, array $ubicacion): array
    {
        $timestamp = now()->toDateTimeString();

        return [
            'titulo' => '',
            'slug' => str_slug($direccion),
            'estado' => true,
            'ciudad_id' => $ciudadId,
            'ubicacion' => json_encode($ubicacion, JSON_THROW_ON_ERROR),
            'direccion' => $direccion,
            'created_at' => $timestamp,
            'updated_at' => $timestamp,
        ];
    }

    /**
     * Resume el dato que falló para poder priorizar la corrección en UI.
     */
    private function detectFailedField(string $reason): ?string
    {
        $normalizedReason = Str::lower($reason);

        if (Str::contains($normalizedReason, 'coordenadas')) {
            return 'coordenadas';
        }

        if (Str::contains($normalizedReason, 'ciudad')) {
            return 'ciudad';
        }

        if (Str::contains($normalizedReason, 'departamento')) {
            return 'departamento';
        }

        if (Str::contains($normalizedReason, ['direccion', 'dirección'])) {
            return 'direccion';
        }

        return null;
    }

    /**
     * Normaliza las claves del mapping recibido desde frontend para desacoplar
     * el contrato de acentos, mayúsculas y formatos de encabezados visibles.
     */
    private function normalizeMapping(array $mapping): array
    {
        $normalized = [];

        foreach ($mapping as $target => $source) {
            $normalized[$this->normalizeText((string) $target)] = str_replace(' ', '_', $this->normalizeText((string) $source));
        }

        return $normalized;
    }
}
