using System.Collections.Concurrent;
using System.Globalization;
using System.IO.Compression;
using System.Text;
using RutasDBUS.Modelos.Geocodificacion;

namespace RutasDBUS.Servicios.Geocodificacion;

public sealed class GeocodificadorLocalServicio : IGeocodificadorLocalServicio
{
    private const string RutaCallejeroZipRelativa = "Modelos/Callejero/GFA_DSET_AD_CSV.zip";
    private const string NombreCsvB5m = "GFA_DSET_AD.csv";
    private readonly IWebHostEnvironment _entorno;
    private readonly SemaphoreSlim _semaforoCarga = new(1, 1);
    private readonly ConcurrentDictionary<string, LugarGeocodificadoCercano> _cacheCercanos = new(StringComparer.Ordinal);
    private IReadOnlyList<EntradaGeocodificada>? _indice;

    public GeocodificadorLocalServicio(IWebHostEnvironment entorno)
    {
        _entorno = entorno;
    }

    public async Task<IReadOnlyList<LugarGeocodificado>> BuscarAsync(
        string texto,
        int maxResultados = 8,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(texto))
            return Array.Empty<LugarGeocodificado>();

        var indice = await ObtenerIndiceAsync(cancellationToken);
        if (indice.Count == 0)
            return Array.Empty<LugarGeocodificado>();

        var consulta = NormalizarParaBusqueda(texto);
        var tokens = consulta
            .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
            .Where(token => token.Length > 0)
            .Distinct(StringComparer.Ordinal)
            .ToArray();

        if (tokens.Length == 0)
            return Array.Empty<LugarGeocodificado>();

        maxResultados = Math.Clamp(maxResultados, 1, 30);

        return indice
            .Select(entrada => new
            {
                Entrada = entrada,
                Puntuacion = Puntuar(entrada, consulta, tokens)
            })
            .Where(x => x.Puntuacion < int.MaxValue)
            .OrderBy(x => x.Puntuacion)
            .ThenBy(x => x.Entrada.Nombre, StringComparer.CurrentCultureIgnoreCase)
            .Take(maxResultados)
            .Select(x => x.Entrada.Lugar)
            .ToList();
    }

    public async Task<int> ContarLugaresAsync(CancellationToken cancellationToken = default)
    {
        var indice = await ObtenerIndiceAsync(cancellationToken);
        return indice.Count;
    }

    public async Task<LugarGeocodificadoCercano?> BuscarMasCercanoAsync(
        double latitud,
        double longitud,
        CancellationToken cancellationToken = default)
    {
        if (!double.IsFinite(latitud) || !double.IsFinite(longitud))
            return null;

        var clave = ClaveCercano(latitud, longitud);
        if (_cacheCercanos.TryGetValue(clave, out var cercanoCacheado))
            return cercanoCacheado;

        var indice = await ObtenerIndiceAsync(cancellationToken);
        if (indice.Count == 0)
            return null;

        EntradaGeocodificada? mejorEntrada = null;
        var mejorDistancia = double.MaxValue;

        foreach (var entrada in indice)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var lugar = entrada.Lugar;
            var distancia = DistanciaHaversineMetros(latitud, longitud, lugar.Latitud, lugar.Longitud);
            if (distancia >= mejorDistancia)
                continue;

            mejorDistancia = distancia;
            mejorEntrada = entrada;
        }

        if (mejorEntrada is null)
            return null;

        var cercano = new LugarGeocodificadoCercano
        {
            Lugar = mejorEntrada.Lugar,
            DistanciaMetros = mejorDistancia
        };

        _cacheCercanos.TryAdd(clave, cercano);
        return cercano;
    }

    private async Task<IReadOnlyList<EntradaGeocodificada>> ObtenerIndiceAsync(CancellationToken cancellationToken)
    {
        if (_indice is not null)
            return _indice;

        await _semaforoCarga.WaitAsync(cancellationToken);
        try
        {
            if (_indice is not null)
                return _indice;

            _indice = CargarIndice(cancellationToken);
            return _indice;
        }
        finally
        {
            _semaforoCarga.Release();
        }
    }

    private IReadOnlyList<EntradaGeocodificada> CargarIndice(CancellationToken cancellationToken)
    {
        var rutaZip = Path.Combine(
            _entorno.ContentRootPath,
            RutaCallejeroZipRelativa.Replace('/', Path.DirectorySeparatorChar));

        if (!File.Exists(rutaZip))
            return Array.Empty<EntradaGeocodificada>();

        using var zip = ZipFile.OpenRead(rutaZip);
        var entradaCsv = zip.Entries.FirstOrDefault(entrada =>
            entrada.FullName.EndsWith(NombreCsvB5m, StringComparison.OrdinalIgnoreCase));

        if (entradaCsv is null)
            return Array.Empty<EntradaGeocodificada>();

        using var stream = entradaCsv.Open();
        using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);

        var cabecera = reader.ReadLine();
        if (string.IsNullOrWhiteSpace(cabecera))
            return Array.Empty<EntradaGeocodificada>();

        var columnas = ParsearCsv(cabecera);
        var indices = columnas
            .Select((nombre, indice) => new { nombre, indice })
            .ToDictionary(x => x.nombre.Trim(), x => x.indice, StringComparer.OrdinalIgnoreCase);

        var resultado = new List<EntradaGeocodificada>(70000);
        var vistos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        while (!reader.EndOfStream)
        {
            cancellationToken.ThrowIfCancellationRequested();

            var linea = reader.ReadLine();
            if (string.IsNullOrWhiteSpace(linea))
                continue;

            var campos = ParsearCsv(linea);
            if (!TryCrearEntradaB5m(campos, indices, vistos, out var entrada))
                continue;

            resultado.Add(entrada);
        }

        return resultado;
    }

    private static bool TryCrearEntradaB5m(
        IReadOnlyList<string> campos,
        IReadOnlyDictionary<string, int> indices,
        ISet<string> vistos,
        out EntradaGeocodificada entrada)
    {
        entrada = default!;

        var coordenadas = Valor(campos, indices, "CEN_LONLAT");
        if (!TryParsearLonLat(coordenadas, out var lon, out var lat))
            return false;

        var municipio = PrimerValor(
            Valor(campos, indices, "MUNI_ES"),
            Valor(campos, indices, "MUNI_EU"));

        var nombre = PrimerValor(
            Valor(campos, indices, "NAME_ES"),
            Valor(campos, indices, "NAME"),
            Valor(campos, indices, "NAME_EU"));

        var calle = PrimerValor(
            Valor(campos, indices, "STREET_ES"),
            Valor(campos, indices, "STREET_EU"));

        var portal = PrimerValor(
            Valor(campos, indices, "HOUSE_NUMBER"),
            Valor(campos, indices, "BIS"));

        var edificio = PrimerValor(
            Valor(campos, indices, "NAME_BUILDING_ES"),
            Valor(campos, indices, "NAME_BUILDING_EU"));

        if (string.IsNullOrWhiteSpace(nombre) &&
            string.IsNullOrWhiteSpace(calle) &&
            string.IsNullOrWhiteSpace(edificio))
        {
            return false;
        }

        var id = Valor(campos, indices, "B5MCODE");
        if (string.IsNullOrWhiteSpace(id))
            id = $"{lat:0.000000}|{lon:0.000000}|{nombre}|{edificio}";

        if (!vistos.Add(id))
            return false;

        var etiqueta = ConstruirEtiqueta(nombre, calle, portal, edificio, municipio);
        if (string.IsNullOrWhiteSpace(etiqueta))
            return false;

        var subtitulo = ConstruirSubtitulo(calle, portal, edificio, municipio);
        var tipo = string.IsNullOrWhiteSpace(edificio)
            ? "Direccion"
            : "Lugar";

        var lugar = new LugarGeocodificado
        {
            Id = "b5m:" + id,
            Nombre = etiqueta,
            Subtitulo = subtitulo,
            Tipo = tipo,
            Fuente = "B5M",
            Municipio = municipio,
            Latitud = lat,
            Longitud = lon
        };

        var textoBusqueda = string.Join(' ', new[]
        {
            etiqueta,
            subtitulo,
            nombre,
            calle,
            portal,
            edificio,
            municipio
        }.Where(valor => !string.IsNullOrWhiteSpace(valor)));

        entrada = new EntradaGeocodificada(
            lugar,
            etiqueta,
            municipio,
            NormalizarParaBusqueda(textoBusqueda),
            NormalizarParaBusqueda(etiqueta),
            NormalizarParaBusqueda(calle),
            NormalizarParaBusqueda(edificio),
            NormalizarParaBusqueda(portal));

        return true;
    }

    private static int Puntuar(EntradaGeocodificada entrada, string consulta, IReadOnlyList<string> tokens)
    {
        foreach (var token in tokens)
        {
            if (!entrada.TextoBusqueda.Contains(token, StringComparison.Ordinal))
                return int.MaxValue;
        }

        var puntuacion = 500;

        if (entrada.EtiquetaNormalizada.Equals(consulta, StringComparison.Ordinal))
            puntuacion -= 260;
        else if (entrada.EtiquetaNormalizada.StartsWith(consulta, StringComparison.Ordinal))
            puntuacion -= 220;
        else if (!string.IsNullOrWhiteSpace(entrada.EdificioNormalizado) &&
                 entrada.EdificioNormalizado.StartsWith(consulta, StringComparison.Ordinal))
            puntuacion -= 210;
        else if (!string.IsNullOrWhiteSpace(entrada.CalleNormalizada) &&
                 entrada.CalleNormalizada.StartsWith(consulta, StringComparison.Ordinal))
            puntuacion -= 190;
        else if (entrada.TextoBusqueda.StartsWith(consulta, StringComparison.Ordinal))
            puntuacion -= 150;

        if (tokens.Count > 1 && tokens.All(token =>
                entrada.EtiquetaNormalizada.Contains(token, StringComparison.Ordinal)))
        {
            puntuacion -= 45;
        }

        if (!string.IsNullOrWhiteSpace(entrada.PortalNormalizado) &&
            tokens.Any(token => token.Equals(entrada.PortalNormalizado, StringComparison.Ordinal)))
        {
            puntuacion -= 30;
        }

        if (EsDonostia(entrada.Municipio))
            puntuacion -= 55;

        puntuacion += Math.Min(60, entrada.EtiquetaNormalizada.Length / 4);
        return Math.Max(0, puntuacion);
    }

    private static bool EsDonostia(string municipio)
    {
        var normalizado = NormalizarParaBusqueda(municipio);
        return normalizado.Contains("san sebastian", StringComparison.Ordinal) ||
               normalizado.Contains("donostia", StringComparison.Ordinal);
    }

    private static string ConstruirEtiqueta(string nombre, string calle, string portal, string edificio, string municipio)
    {
        if (!string.IsNullOrWhiteSpace(edificio))
        {
            var direccion = ConstruirDireccionCorta(calle, portal);
            return string.IsNullOrWhiteSpace(direccion)
                ? edificio.Trim()
                : $"{edificio.Trim()} - {direccion}";
        }

        if (!string.IsNullOrWhiteSpace(nombre))
            return QuitarMunicipioInicial(nombre, municipio).Trim();

        return ConstruirDireccionCorta(calle, portal);
    }

    private static string ConstruirSubtitulo(string calle, string portal, string edificio, string municipio)
    {
        var partes = new List<string>();
        var direccion = ConstruirDireccionCorta(calle, portal);

        if (!string.IsNullOrWhiteSpace(edificio) &&
            !string.IsNullOrWhiteSpace(direccion))
        {
            partes.Add(direccion);
        }

        if (!string.IsNullOrWhiteSpace(municipio))
            partes.Add(municipio.Trim());

        return string.Join(" · ", partes);
    }

    private static string ConstruirDireccionCorta(string calle, string portal)
    {
        if (string.IsNullOrWhiteSpace(calle))
            return portal.Trim();

        calle = calle.Trim();
        portal = portal.Trim();

        return string.IsNullOrWhiteSpace(portal)
            ? calle
            : $"{calle} {portal}";
    }

    private static string QuitarMunicipioInicial(string nombre, string municipio)
    {
        if (string.IsNullOrWhiteSpace(nombre) || string.IsNullOrWhiteSpace(municipio))
            return nombre;

        nombre = nombre.Trim();
        municipio = municipio.Trim();

        return nombre.StartsWith(municipio + " ", StringComparison.OrdinalIgnoreCase)
            ? nombre[municipio.Length..].Trim()
            : nombre;
    }

    private static bool TryParsearLonLat(string texto, out double longitud, out double latitud)
    {
        longitud = 0;
        latitud = 0;

        var partes = (texto ?? "").Split(',', StringSplitOptions.TrimEntries);
        if (partes.Length < 2)
            return false;

        return double.TryParse(partes[0], NumberStyles.Float, CultureInfo.InvariantCulture, out longitud) &&
               double.TryParse(partes[1], NumberStyles.Float, CultureInfo.InvariantCulture, out latitud) &&
               double.IsFinite(longitud) &&
               double.IsFinite(latitud);
    }

    private static string Valor(IReadOnlyList<string> campos, IReadOnlyDictionary<string, int> indices, string columna)
    {
        if (!indices.TryGetValue(columna, out var indice) ||
            indice < 0 ||
            indice >= campos.Count)
        {
            return "";
        }

        return campos[indice].Trim();
    }

    private static string PrimerValor(params string[] valores)
        => valores.FirstOrDefault(valor => !string.IsNullOrWhiteSpace(valor))?.Trim() ?? "";

    private static string ClaveCercano(double latitud, double longitud)
        => $"{latitud.ToString("0.000000", CultureInfo.InvariantCulture)}|{longitud.ToString("0.000000", CultureInfo.InvariantCulture)}";

    private static double DistanciaHaversineMetros(double lat1, double lon1, double lat2, double lon2)
    {
        const double radioTierraMetros = 6371000.0;
        var dLat = GradosARadianes(lat2 - lat1);
        var dLon = GradosARadianes(lon2 - lon1);
        var rLat1 = GradosARadianes(lat1);
        var rLat2 = GradosARadianes(lat2);

        var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
                Math.Cos(rLat1) * Math.Cos(rLat2) *
                Math.Sin(dLon / 2) * Math.Sin(dLon / 2);

        a = Math.Clamp(a, 0.0, 1.0);
        var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
        return radioTierraMetros * c;
    }

    private static double GradosARadianes(double grados)
        => grados * Math.PI / 180.0;

    private static List<string> ParsearCsv(string linea)
    {
        var campos = new List<string>();
        var actual = new StringBuilder(linea.Length);
        var entreComillas = false;

        for (var i = 0; i < linea.Length; i++)
        {
            var ch = linea[i];
            if (ch == '"')
            {
                if (entreComillas && i + 1 < linea.Length && linea[i + 1] == '"')
                {
                    actual.Append('"');
                    i++;
                }
                else
                {
                    entreComillas = !entreComillas;
                }

                continue;
            }

            if (ch == ',' && !entreComillas)
            {
                campos.Add(actual.ToString());
                actual.Clear();
                continue;
            }

            actual.Append(ch);
        }

        campos.Add(actual.ToString());
        return campos;
    }

    private static string NormalizarParaBusqueda(string texto)
    {
        if (string.IsNullOrWhiteSpace(texto))
            return "";

        texto = texto.Trim().ToLowerInvariant();
        const char marcadorEnye = '\uE000';
        texto = texto.Replace('\u00f1', marcadorEnye);

        var formD = texto.Normalize(NormalizationForm.FormD);
        var sb = new StringBuilder(formD.Length);
        foreach (var ch in formD)
        {
            var uc = CharUnicodeInfo.GetUnicodeCategory(ch);
            if (uc == UnicodeCategory.NonSpacingMark)
                continue;

            if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch))
            {
                sb.Append(ch);
            }
            else
            {
                sb.Append(' ');
            }
        }

        return string.Join(' ', sb
            .ToString()
            .Normalize(NormalizationForm.FormC)
            .Replace(marcadorEnye, '\u00f1')
            .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
    }

    private sealed record EntradaGeocodificada(
        LugarGeocodificado Lugar,
        string Nombre,
        string Municipio,
        string TextoBusqueda,
        string EtiquetaNormalizada,
        string CalleNormalizada,
        string EdificioNormalizado,
        string PortalNormalizado);
}
