import os
import sys
import time
import difflib
import threading
from pathlib import Path
from collections import defaultdict
from contextlib import contextmanager

sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))

from helpers.db import conectar, conectar_readonly, ejecutar_sp_dict, ejecutar_sp, ejecutar_sp_raw


# --- CONFIGURACIÓN ---

TIMEOUT_QUERY    = 20    # segundos para queries normales (padrón, DNI, relaciones)
TIMEOUT_OBTENER  = 120   # segundos para el SP EDT_homonimia OBTENER

SLEEP_ENTRE_PERSONAS = 0.05
SLEEP_ENTRE_GRUPOS   = 0.1

# Poner CACHE=0 en el entorno para desactivar el caché y comparar tiempos.
# Correr el mismo lote con CACHE=1 y CACHE=0 da la medición real del ahorro.
CACHE_ACTIVO = os.environ.get('CACHE', '1') != '0'

# SQLSTATEs que dejan la conexión en estado dudoso: hay que descartarla y reabrir.
# HYT00/HYT01 = query timeout, 08xxx = link caído.
SQLSTATES_FATALES = {'HYT00', 'HYT01', '08S01', '08001', '08003', '08007'}


def _es_error_fatal(e):
    args = getattr(e, 'args', None)
    return bool(args) and args[0] in SQLSTATES_FATALES


# ============================================================================
# PASO 0 - INSTRUMENTACIÓN
# ============================================================================
# Acumula tiempo por tipo de operación para saber dónde se va el run.
# El lock ya está puesto para que esto siga funcionando si más adelante
# paralelizamos por grupo con un ThreadPoolExecutor.

_lock_metricas = threading.Lock()

TIEMPOS  = defaultdict(lambda: {'n': 0, 'seg': 0.0, 'min': None, 'max': 0.0})
CONTEOS  = defaultdict(int)


@contextmanager
def medir(nombre):
    t0 = time.perf_counter()
    try:
        yield
    finally:
        dt = time.perf_counter() - t0
        with _lock_metricas:
            m = TIEMPOS[nombre]
            m['n']   += 1
            m['seg'] += dt
            m['max']  = max(m['max'], dt)
            m['min']  = dt if m['min'] is None else min(m['min'], dt)


def contar(nombre, cantidad=1):
    with _lock_metricas:
        CONTEOS[nombre] += cantidad


def imprimir_metricas(segundos_totales):
    print()
    print('=' * 78)
    print(f'MÉTRICAS DEL RUN   (caché {"ACTIVO" if CACHE_ACTIVO else "DESACTIVADO"})')
    print('=' * 78)

    print()
    print(f'Tiempo total del run: {segundos_totales:.1f}s')
    print()
    print(f'{"OPERACIÓN":<34}{"N":>6}{"TOTAL":>10}{"PROM":>9}{"MIN":>9}{"MAX":>9}{"%":>7}')
    print('-' * 78)

    suma_db = sum(v['seg'] for v in TIEMPOS.values())
    for nombre, v in sorted(TIEMPOS.items(), key=lambda x: -x[1]['seg']):
        prom = v['seg'] / v['n'] if v['n'] else 0
        pct  = (v['seg'] / segundos_totales * 100) if segundos_totales else 0
        print(f'{nombre:<34}{v["n"]:>6}{v["seg"]:>9.1f}s{prom*1000:>8.0f}ms'
              f'{(v["min"] or 0)*1000:>8.0f}ms{v["max"]*1000:>8.0f}ms{pct:>6.1f}%')

    print('-' * 78)
    print(f'{"TOTAL EN BASE DE DATOS":<34}{"":>6}{suma_db:>9.1f}s'
          f'{"":>9}{"":>9}{"":>9}'
          f'{(suma_db/segundos_totales*100 if segundos_totales else 0):>6.1f}%')

    print()
    print('CACHÉ')
    print('-' * 78)
    for prefijo in ('padron', 'dni'):
        hits   = CONTEOS[f'cache_{prefijo}_hit']
        misses = CONTEOS[f'cache_{prefijo}_miss']
        total  = hits + misses
        ratio  = (hits / total * 100) if total else 0
        print(f'  {prefijo:<10} consultas={total:<6} hits={hits:<6} misses={misses:<6} '
              f'aprovechamiento={ratio:.1f}%')

    print()
    print('RESOLUCIÓN DE PERSONAS  (qué paso de la cascada resolvió cada una)')
    print('-' * 78)
    total_personas = CONTEOS['persona_total']
    for etiqueta, clave in (
        ('Ya tenía id_padron', 'persona_ya_resuelta'),
        ('Paso 1 - por DNI', 'persona_por_dni'),
        ('Paso 2 - por histórico', 'persona_por_historico'),
        ('Paso 3 - por nombre+pcia', 'persona_por_padron'),
        ('Sin resolver', 'persona_sin_resolver'),
    ):
        n   = CONTEOS[clave]
        pct = (n / total_personas * 100) if total_personas else 0
        print(f'  {etiqueta:<28} {n:>6}  ({pct:>5.1f}%)')
    print(f'  {"TOTAL":<28} {total_personas:>6}')

    print()
    print('RESOLUCIÓN DE SOCIEDADES')
    print('-' * 78)
    for etiqueta, clave in (
        ('Ya tenía id_padron', 'soc_ya_resuelta'),
        ('Resuelta por CUIT', 'soc_por_cuit'),
        ('Resuelta por nombre único', 'soc_por_nombre'),
        ('Saltada - sin resultados', 'soc_sin_resultados'),
        ('Saltada - múltiples candidatos', 'soc_multiple'),
    ):
        print(f'  {etiqueta:<32} {CONTEOS[clave]:>6}')

    print()
    print('ERRORES')
    print('-' * 78)
    for clave in ('timeout_padron', 'timeout_dni', 'error_padron', 'error_dni',
                  'error_persona', 'error_grupo', 'error_relaciones'):
        if CONTEOS[clave]:
            print(f'  {clave:<32} {CONTEOS[clave]:>6}')
    print('=' * 78)


# ============================================================================
# PASO 1 - CACHÉ EN MEMORIA
# ============================================================================
# Los mismos socios y las mismas razones sociales se repiten mucho entre
# edictos del mismo CUIT. Sin caché, cada aparición dispara otra consulta
# al padrón. Guardamos también los resultados vacíos: saber que un nombre
# no está en el padrón es tan útil como saber que sí.

_lock_cache   = threading.Lock()
_cache_padron = {}
_cache_dni    = {}


def _cache_get(cache, clave, etiqueta):
    if not CACHE_ACTIVO:
        return False, None
    with _lock_cache:
        if clave in cache:
            contar(f'cache_{etiqueta}_hit')
            return True, cache[clave]
    contar(f'cache_{etiqueta}_miss')
    return False, None


def _cache_set(cache, clave, valor):
    if not CACHE_ACTIVO:
        return
    with _lock_cache:
        cache[clave] = valor


# --- SESIÓN DE BASE DE DATOS ---
#
# Reemplaza el patrón original de "abrir una conexión nueva por cada query
# dentro de un thread con join(timeout)". Ese patrón tenía dos problemas:
#
#   1. join(timeout) no cancela nada. Python no puede matar un thread, así que
#      al saltar el TimeoutError la query seguía corriendo en el servidor con
#      sus locks tomados. Acá usamos conn.timeout (SQL_ATTR_QUERY_TIMEOUT), que
#      manda una attention request y cancela del lado de SQL Server.
#
#   2. Una conexión nueva por query son cientos o miles de logins por run.
class Sesion:

    def __init__(self, factory, nombre, autocommit):
        self._factory    = factory
        self._nombre     = nombre
        self._autocommit = autocommit
        self._conn       = None

    @property
    def conn(self):
        if self._conn is None:
            with medir('abrir_conexion'):
                self._conn = self._factory()
                self._conn.autocommit = self._autocommit
        return self._conn

    def reset(self):
        if self._conn is not None:
            try:
                self._conn.close()
            except Exception:
                pass
            self._conn = None
            print(f'[{self._nombre}] conexión descartada, se reabrirá en el próximo uso')

    @contextmanager
    def _con_timeout(self, segundos):
        c = self.conn
        try:
            c.timeout = segundos
        except Exception:
            pass
        try:
            yield c
        except Exception as e:
            if _es_error_fatal(e):
                self.reset()
            raise
        else:
            try:
                c.timeout = 0
            except Exception:
                pass

    def query(self, sql, params=(), timeout=TIMEOUT_QUERY):
        with self._con_timeout(timeout) as c:
            cur = c.cursor()
            try:
                cur.execute(sql, params)
                return cur.fetchall()
            finally:
                try:
                    cur.close()
                except Exception:
                    pass

    def sp_dict(self, nombre, params, timeout=TIMEOUT_QUERY):
        with self._con_timeout(timeout) as c:
            return ejecutar_sp_dict(nombre, params, c) or []

    def sp_raw(self, nombre, params, timeout=TIMEOUT_QUERY):
        with self._con_timeout(timeout) as c:
            return ejecutar_sp_raw(nombre, params, c) or []

    def sp(self, nombre, params, timeout=TIMEOUT_QUERY):
        with self._con_timeout(timeout) as c:
            return ejecutar_sp(nombre, params, c)

    def commit(self):
        if self._conn is not None and not self._autocommit:
            try:
                with medir('commit'):
                    self._conn.commit()
            except Exception as e:
                print(f'[{self._nombre}] error en commit: {e}')
                self.reset()

    def rollback(self):
        if self._conn is not None and not self._autocommit:
            try:
                self._conn.rollback()
            except Exception:
                self.reset()

    def close(self):
        self.reset()


# --- AGRUPACIÓN ---

# El SP devuelve filas planas mezclando la sociedad y sus personas/vinculadas.
# Esta función las organiza en dos niveles:
#   - Nivel 1: id_tabla (el CUIT de EDT_cuits_a_procesar)
#   - Nivel 2: id_fide (cada edicto de ese CUIT)
# Dentro de cada edicto, el registro de edictos_ia va siempre primero.
def agrupar_datos(filas):
    orden_tabla  = []
    grupos_tabla = {}
    for fila in filas:
        id_tabla = fila['id_tabla']
        if id_tabla not in grupos_tabla:
            orden_tabla.append(id_tabla)
            grupos_tabla[id_tabla] = {'orden': [], 'grupos': {}}
        g = grupos_tabla[id_tabla]
        id_fide = fila['id_fide']
        if id_fide not in g['grupos']:
            g['orden'].append(id_fide)
            g['grupos'][id_fide] = []
        g['grupos'][id_fide].append(fila)

    result = []
    for id_tabla in orden_tabla:
        g = grupos_tabla[id_tabla]
        fides = []
        for id_fide in g['orden']:
            filas_fide = sorted(g['grupos'][id_fide], key=lambda f: 0 if _es_sociedad(f) else 1)
            fides.append((id_fide, filas_fide))
        result.append((id_tabla, fides))
    return result


def _es_sociedad(fila):
    return (fila['tabla'] or '').strip().replace('editos_ia', 'edictos_ia') == 'edictos_ia'


# --- NORMALIZACIÓN Y COMPARACIÓN DE NOMBRES ---

# Si el nombre viene como "Apellido, Nombre", lo invierte a "Nombre Apellido"
# para que no rompa la búsqueda full-text del SP, que no acepta comas.
def _normalizar_nombre(nombre):
    if not nombre:
        return nombre
    if ',' in nombre:
        partes = [p.strip() for p in nombre.split(',', 1)]
        nombre = f'{partes[1]} {partes[0]}'
    return ' '.join(nombre.split())


# Compara dos nombres tolerando orden distinto ("Juan Pérez" vs "PEREZ JUAN").
# Primero por conjunto de palabras; si 60%+ coinciden, lo acepta.
# Si no, cae a similitud de cadena clásica.
def _similitud_nombres(a, b):
    palabras_a = set(a.upper().split())
    palabras_b = set(b.upper().split())
    if palabras_a and palabras_b:
        coincidencia = len(palabras_a & palabras_b) / max(len(palabras_a), len(palabras_b))
        if coincidencia >= 0.6:
            return coincidencia
    return difflib.SequenceMatcher(None, a.upper(), b.upper()).ratio()


# --- BÚSQUEDAS (con caché e instrumentación) ---

# Busca un nombre o CUIT en el padrón AFIP.
# `etiqueta` sirve para separar en las métricas la búsqueda de sociedad por
# nombre, la de sociedad por CUIT y la de personas: tienen costos distintos.
def buscar_en_padron(sesion_read, nombre, cod_prov=None, etiqueta='padron'):
    clave = ((_normalizar_nombre(nombre) or '').upper(), cod_prov)

    hit, valor = _cache_get(_cache_padron, clave, 'padron')
    if hit:
        return valor

    try:
        with medir(etiqueta):
            resultado = sesion_read.query(
                'EXEC [dbo].[buscador_paises_padron] ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?',
                ('buscador', None, nombre, 'AR', cod_prov,
                 None, None, None, None, None, None, None)
            )
    except Exception as e:
        if _es_error_fatal(e):
            contar('timeout_padron')
            print(f'PADRON_TIMEOUT | nombre={nombre!r}')
        else:
            contar('error_padron')
            print(f'PADRON_ERROR | nombre={nombre!r} error={e}')
        # No cacheamos errores: pudo ser transitorio y queremos reintentar.
        return []

    _cache_set(_cache_padron, clave, resultado)
    return resultado


# Intenta encontrar el CUIT de una persona usando su DNI + nombre.
# Verifica que el nombre del padrón coincida (similitud >= 60%).
def resolver_via_dni(sesion_read, nombre, dni):
    if not dni:
        return None, None
    dni_limpio = ''.join(filter(str.isdigit, str(dni)))
    if not dni_limpio or len(dni_limpio) > 8:
        return None, None

    nombre_normalizado = _normalizar_nombre(nombre)
    clave = (nombre_normalizado.upper(), dni_limpio)

    hit, valor = _cache_get(_cache_dni, clave, 'dni')
    if hit:
        return valor

    try:
        with medir('dni_nombre'):
            with sesion_read._con_timeout(TIMEOUT_QUERY) as c:
                cur = c.cursor()
                try:
                    cur.execute('{CALL sp_buscar_cuit_dni_nombre (?,?)}',
                                (nombre_normalizado, dni_limpio))
                    cols = [col[0] for col in cur.description]
                    rows = cur.fetchall()
                finally:
                    try:
                        cur.close()
                    except Exception:
                        pass
    except Exception as e:
        if _es_error_fatal(e):
            contar('timeout_dni')
            print(f'DNI_TIMEOUT | nombre={nombre_normalizado!r} dni={dni_limpio!r}')
        else:
            contar('error_dni')
            print(f'DNI_ERROR | nombre={nombre_normalizado!r} dni={dni_limpio!r} error={e}')
        return None, None

    resultado = (None, None)

    if rows:
        row = dict(zip(cols, rows[0]))
        cuit_padron = row.get('cuit')
        if cuit_padron:
            similitud = _similitud_nombres(nombre_normalizado, row.get('nombre') or '')
            if similitud >= 0.6:
                resultado = (str(cuit_padron), row.get('id_padron'))
            else:
                contar('dni_nombre_no_match')
                print(f'DNI_NOMBRE_NO_MATCH | nombre={nombre_normalizado!r} '
                      f'padron={row.get("nombre")!r} sim={similitud:.2f}')

    _cache_set(_cache_dni, clave, resultado)
    return resultado


# --- PROCESAMIENTO ---

# Resuelve la sociedad del edicto.
# Devuelve True si se puede seguir con las personas, False si hay que saltarlo.
def procesar_sociedad(sesion_read, sesion_write, id_fide, fila_soc):
    tabla            = 'edictos_ia'
    nombre           = (fila_soc['razon_social'] or '').replace('¥', 'Ñ')  # encoding corrupto
    cuit_soc         = (fila_soc.get('cuit') or '').strip()
    codigo_provincia = fila_soc['pcia_fuente']
    id_padron        = fila_soc['id_padron']

    print(tabla,nombre,cuit_soc,codigo_provincia,id_padron,'debug')
    #print(tabla, id_fide, nombre)

    if id_padron is not None:
        contar('soc_ya_resuelta')
        return True

    validar_id_padron = sesion_read.sp_raw('EDT_homonimia_id_padron', (id_fide,))

    if validar_id_padron and validar_id_padron[0][0] is not None:

        print('id_padron devuelto por el sistema, actualziando')

        with medir('sp_relleno_cuit'):
            print(f'Actualziando id_padron: {validar_id_padron[0][0]}')
            sesion_write.sp_dict('EDT_homonimia_relleno_cuit', (validar_id_padron[0][0], cuit_soc))
            sesion_write.commit()
        return True

    if cuit_soc and len(cuit_soc) >= 11:
        # Tiene CUIT pero le falta el id_padron → lo buscamos por CUIT
        print(f'Sociedad ya tiene CUIT {cuit_soc}, buscando id_padron')
        resultado_soc = buscar_en_padron(sesion_read, cuit_soc, etiqueta='padron_soc_cuit')
        if len(resultado_soc) == 1:
            r = resultado_soc[0]
            print(f'id_padron encontrado: {r[2]} - {r[4]}')
            with medir('sp_relleno_cuit'):
                sesion_write.sp_dict('EDT_homonimia_relleno_cuit', (r[2], cuit_soc))
            sesion_write.commit()
            contar('soc_por_cuit')
            print(f'id_padron asignado: {r[2]}')
        # El CUIT es único: no tiene sentido iterar múltiples resultados acá.
        return True

    # Sin CUIT → búsqueda por nombre y provincia (homonimia)
    resultado_soc = buscar_en_padron(sesion_read, nombre, codigo_provincia,etiqueta='padron_soc_nombre')

    print(nombre, codigo_provincia)

    print(f'Cantidad de resultados sociedad: {len(resultado_soc)}')

    if not resultado_soc:
        contar('soc_sin_resultados')
        print('Sin resultados para sociedad, saltando edicto')
        return False

    if len(resultado_soc) > 1:
        contar('soc_multiple')
        print('Multiple resultado sociedad, saltando edicto')
        return False

    print('Resultado unico sociedad')
    r               = resultado_soc[0]
    id_padron       = r[2]
    cuit_soc        = r[3]
    nombre_completo = r[4]
    print(cuit_soc, nombre_completo)
    with medir('sp_asignar_cuit'):
        sesion_write.sp('EDT_homonimia_asignar_cuit', (tabla, id_fide, cuit_soc, id_padron))
    sesion_write.commit()
    contar('soc_por_nombre')
    print(f'Cuit asignado: {nombre_completo} - {cuit_soc}')
    return True


def procesar_persona(sesion_read, sesion_write, fila):
    tabla_p     = (fila['tabla'] or '').strip()
    id_registro = fila['id']            # id de la fila en socios/accionistas/vinculadas
    nombre_p    = fila['razon_social']
    dni_p       = fila.get('dni')
    id_padron   = fila.get('id_padron')
    asignado    = False

    contar('persona_total')
    print(tabla_p, id_registro, nombre_p)

    if id_padron is not None:
        contar('persona_ya_resuelta')
        return

    # PASO 1: por DNI + nombre (más preciso)
    print('INTENTO ASIGNAR POR DNI Y NOMBRE...')
    print(nombre_p, dni_p)
    cuit_dni, id_padron_dni = resolver_via_dni(sesion_read, nombre_p, dni_p)
    if cuit_dni:
        with medir('sp_asignar_cuit'):
            sesion_write.sp('EDT_homonimia_asignar_cuit',
                            (tabla_p, id_registro, cuit_dni, id_padron_dni))
        sesion_write.commit()
        contar('persona_por_dni')
        print(f'Asignado por DNI: {cuit_dni} id_padron={id_padron_dni}')
        asignado = True

    # PASO 2: relaciones históricas
    if not asignado:
        print('INTENTO ASIGNAR POR HISTORICO...')
        print(tabla_p, id_registro)
        try:
            with medir('sp_relaciones'):
                relaciones = sesion_write.sp_raw('EDT_homonimia_relaciones',
                                                 (tabla_p, id_registro))
            sesion_write.commit()
        except Exception as e:
            contar('error_relaciones')
            print(f'Error en relaciones históricas: {e}')
            sesion_write.rollback()
            relaciones = []

        if relaciones and relaciones[0][0] is not None and relaciones[0][1] is not None:
            contar('persona_por_historico')
            print(f'Asignado por relaciones: id_padron={relaciones[0][0]} cuit={relaciones[0][1]}')
            asignado = True
        else:
            print('Sin match en relaciones históricas')

    # PASO 3: padrón por nombre + provincia. Solo si hay un único candidato.
    if not asignado:
        print('INTENTO ASIGNAR POR NOMBRE Y PROVINCIA...')
        cod_prov         = fila.get('pcia_fuente')
        resultado_padron = buscar_en_padron(sesion_read, nombre_p, cod_prov,
                                            etiqueta='padron_persona')
        print(f'Padron por nombre+provincia: {len(resultado_padron)} resultados')
        if len(resultado_padron) == 1:
            r = resultado_padron[0]
            with medir('sp_asignar_cuit'):
                sesion_write.sp('EDT_homonimia_asignar_cuit',
                                (tabla_p, id_registro, r[3], r[2]))
            sesion_write.commit()
            contar('persona_por_padron')
            print(f'Candidato unico: {r[3]} - {r[4]}')
            asignado = True

    if not asignado:
        contar('persona_sin_resolver')


# --- MAIN ---

def main():
    limit = int(sys.argv[1]) if len(sys.argv) > 1 else 5
    t_inicio = time.perf_counter()

    # Lectura pura en autocommit: nunca abre transacción implícita.
    sesion_read = Sesion(conectar_readonly, 'READ', autocommit=True)

    # Escritura con commits explícitos, uno por operación.
    sesion_write = Sesion(conectar, 'WRITE', autocommit=False)
    
    try:
        # OBTENER escribe (marca registros como "en proceso"), así que va por la
        # conexión de escritura y se commitea inmediatamente. En el original iba
        # por la readonly sin commit y los locks quedaban tomados todo el run.
        try:
            with medir('sp_obtener'):
                filas = sesion_write.sp_dict('EDT_homonimia', ('OBTENER', limit),
                                             timeout=TIMEOUT_OBTENER)
            sesion_write.commit()
        except Exception as e:
            print(f'ERROR obtener_datos_homonimia: {e}')
            sesion_write.rollback()
            return 1

        grupos = agrupar_datos(filas)
        print(f'Grupos obtenidos: {len(grupos)}  |  filas: {len(filas)}')

        for id_tabla, fides in grupos:
            try:
                for id_fide, filas_fide in fides:

                    fila_soc       = next((f for f in filas_fide if _es_sociedad(f)), None)
                    filas_personas = [f for f in filas_fide if not _es_sociedad(f)]

                    if fila_soc is None:
                        continue

                    if not procesar_sociedad(sesion_read, sesion_write, id_fide, fila_soc):
                        print('---------------')
                        continue

                    print('---------------')

                    for fila in filas_personas:
                        try:
                            procesar_persona(sesion_read, sesion_write, fila)
                        except Exception as e:
                            contar('error_persona')
                            print(f'Error procesando persona {fila.get("id")}: {e}')
                            sesion_write.rollback()

                        print('---------------')
                        sesion_write.commit()   # no dormir con transacción abierta
                        time.sleep(SLEEP_ENTRE_PERSONAS)

                # Procesados todos los edictos del CUIT, lo marcamos como terminado.
                with medir('sp_procesado'):
                    sesion_write.sp_raw('EDT_homonimia', ('PROCESADO', None, id_tabla), timeout=TIMEOUT_OBTENER)
                sesion_write.commit()
                print(f'Grupo {id_tabla} marcado como procesado')
                time.sleep(SLEEP_ENTRE_GRUPOS)

            except Exception as e:
                contar('error_grupo')
                print(f'Error procesando grupo {id_tabla}: {e}')
                sesion_write.rollback()

    finally:
        sesion_read.close()
        sesion_write.close()
        imprimir_metricas(time.perf_counter() - t_inicio)

    return 0


if __name__ == '__main__':
    sys.exit(main())