"""
Extractor de Edictos Societarios — Constitución y Cambios de Directorio
via Vertex AI (Service Account)
========================================================================
Requisitos:
    pip install google-auth requests python-dotenv

Configuración:
    service-account.json en la misma carpeta que este script

Uso:
    python3 procesar_boletin_vertex.py boletin.pdf
    python3 procesar_boletin_vertex.py boletin.pdf --model gemini-2.5-flash
    python3 procesar_boletin_vertex.py boletin.pdf --estimate
    python3 procesar_boletin_vertex.py boletin.pdf --out directorio_salida
"""

import sys
import os
import re
import io
import socket
import json
import logging
import time
import base64
import pathlib
import unicodedata
from datetime import datetime
import requests
import pypdf
from google.oauth2 import service_account
from google.auth.transport.requests import Request

# Asegura que `helpers` se pueda importar aunque cron ejecute desde otro cwd
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent.parent))
from helpers.singleton import single_instance
from helpers.db import ejecutar_sp
from helpers.logging_setup import setup_logging, setup_logging_db
from helpers.db_resumen import guardar_dia_faltante

ID_PROCESO = int(os.getenv("ID_PROCESO_VERTEX", 0))

_run_id = setup_logging("vertex")

# ── Configuración ──────────────────────────────────────────────
PROJECT_ID   = "fidevertex"
LOCATION     = "us-central1"
SA_KEY_FILE  = str(pathlib.Path(__file__).resolve().parent.parent.parent / "service-account.json")

# Cada entrada puede ser un string (usa LOCATION global) o un dict con location propia.
MODELS_FALLBACK = [
    {"model": "gemini-3-flash-preview", "location": "global"},
    {"model": "gemini-2.5-flash",       "location": "us-central1"},
]
MAX_RETRIES     = 5
TEXT_BATCH_SIZE = 25
PDF_MAX_MB      = 19.0   # límite de la API; PDFs más grandes se procesan en chunks
PDF_CHUNK_MB    = 15.0   # tamaño objetivo por chunk
PDF_MAX_PAGES   = 40     # máximo de páginas por chunk (evita truncamiento en Pass 1)

# ── Precios (USD por 1M tokens) ────────────────────────────────
MODEL_PRICING = {
    "gemini-3.1-flash-lite-preview": (0.10,  0.40),
    "gemini-3-flash-preview":        (0.15,  0.60),
    "gemini-2.5-flash":              (0.30,  2.50),
    "gemini-2.5-flash-lite":         (0.10,  0.40),
    "gemini-2.5-pro":                (1.25, 10.00),
    "gemini-2.0-flash":              (0.10,  0.40),
}

# ── Contexto de dominio ────────────────────────────────────────
CONTEXTO = """
En Argentina, el Boletín Oficial es la publicación oficial donde se dan a publicidad actos jurídicos
que requieren notificación pública para tener validez frente a terceros.

Un EDICTO DE CONSTITUCIÓN se publica cuando se crea una sociedad comercial (S.A., S.R.L., etc.),
exigido por la Ley General de Sociedades 19.550. Contiene: denominación y tipo societario, domicilio,
objeto social, capital social, datos de socios/accionistas fundadores, órganos de administración y
fiscalización, y fecha del estatuto constitutivo.

Un EDICTO DE CAMBIO DE DIRECTORIO se publica cuando una sociedad modifica su órgano de administración
(Directorio en S.A., Gerencia en S.R.L.) por designaciones, renuncias, remociones o renovaciones.
Contiene: nombre de la sociedad, directores/gerentes salientes y entrantes, cargos, duración del
mandato y fecha de la asamblea que resolvió el cambio.

Un EDICTO DE DISOLUCIÓN se publica cuando una sociedad decide disolverse y entrar en proceso de
liquidación voluntaria. Contiene: nombre de la sociedad, fecha de la asamblea que resolvió la
disolución, datos del liquidador designado y domicilio especial.

Un EDICTO DE CESIÓN DE CUOTAS se publica cuando uno o más socios de una S.R.L. transfieren su
participación a terceros o a otros socios. Contiene: nombre de la sociedad, cedente/s, cesionario/s,
cantidad de cuotas cedidas y precio o condiciones de la cesión.

Un EDICTO DE LLAMADO A ASAMBLEA se publica para convocar a los socios o accionistas a una asamblea
ordinaria o extraordinaria. Contiene: nombre de la sociedad, tipo de asamblea, fecha, hora y lugar
de celebración, y orden del día.

Un EDICTO DE AUMENTO DE CAPITAL se publica cuando una sociedad resuelve incrementar su capital social.
Contiene: nombre de la sociedad, monto del capital anterior, monto del nuevo capital, tipo de aumento
(nuevos aportes, capitalización de reservas, etc.) y fecha de la asamblea que lo resolvió.
"""

# ── Prompt Pass 1: detectar edictos ───────────────────────────
PROMPT_META = f"""
{CONTEXTO}

Analizá este boletín oficial y realizá dos tareas:

1. Encontrá la fecha de publicación del boletín (suele estar en el encabezado o tapa).
   Devolvela en formato ISO: YYYY-MM-DD. Si no la encontrás, usá null.

2. Encontrá ÚNICAMENTE los edictos de estos tipos:
   - CONSTITUCIÓN DE SOCIEDAD: cuando se crea una nueva S.A., S.R.L. u otro tipo societario.
   - CAMBIO DE DIRECTORIO / AUTORIDADES: cuando una sociedad designa, renueva o remueve directores, gerentes u otras autoridades.
   - DISOLUCIÓN: cuando una sociedad resuelve disolverse y entrar en liquidación voluntaria.
   - CESIÓN DE CUOTAS: cuando socios de una S.R.L. transfieren su participación.
   - LLAMADO A ASAMBLEA: convocatoria a asamblea ordinaria o extraordinaria.
   - AUMENTO DE CAPITAL: cuando una sociedad incrementa su capital social.
   Ignorá completamente cualquier otro tipo de edicto.

Para cada edicto encontrado, extraé:
- "razon_social": nombre completo de la sociedad tal como aparece en el PDF
- "tipo": exactamente uno de los valores del enum (ver schema)
- "texto_inicio": las primeras 30 palabras del texto del edicto, para poder identificarlo después
- "provincia": provincia argentina a la que pertenece el edicto, en mayúsculas y sin acentos
  (ej: "BUENOS AIRES", "CORDOBA", "SANTA FE", "ENTRE RIOS", "RIO NEGRO", "SANTIAGO DEL ESTERO",
  "TIERRA DEL FUEGO", "SAN LUIS", "SAN JUAN", "LA RIOJA", "LA PAMPA", "CIUDAD AUTONOMA DE BUENOS AIRES").
  Si no podés determinarla con certeza, devolvé null.
"""

SCHEMA_META = {
    "type": "OBJECT",
    "properties": {
        "fecha_publicacion": {
            "type": "STRING",
            "nullable": True,
            "description": "Fecha de publicación del boletín en formato YYYY-MM-DD, null si no se encuentra"
        },
        "edictos": {
            "type": "ARRAY",
            "items": {
                "type": "OBJECT",
                "properties": {
                    "razon_social": {
                        "type": "STRING",
                        "description": "Nombre completo de la sociedad"
                    },
                    "tipo": {
                        "type": "STRING",
                        "enum": ["constitución", "cambio_directorio", "disolución", "cesión_cuotas", "llamado_asamblea", "aumento_capital"],
                        "description": "Tipo de edicto societario"
                    },
                    "texto_inicio": {
                        "type": "STRING",
                        "description": "Primeras 30 palabras del texto del edicto"
                    },
                    "provincia": {
                        "type": "STRING",
                        "nullable": True,
                        "description": "Provincia argentina en mayúsculas y sin acentos. Null si no se puede determinar."
                    },
                },
                "required": ["razon_social", "tipo", "texto_inicio", "provincia"]
            }
        }
    },
    "required": ["fecha_publicacion", "edictos"]
}

# ── Prompt Pass 2: obtener texto completo de edictos ──────────
def build_prompt_rescue(meta: dict) -> str:
    return f"""
{CONTEXTO}

Buscá en el boletín oficial el edicto de la siguiente sociedad y devolvé su texto completo:

Razón social: {meta['razon_social']}
Tipo de edicto: {meta['tipo']}
Inicio del texto (para ubicarlo): "{meta.get('texto_inicio', '')}"

IMPORTANTE:
- Copiá el texto íntegro del edicto, palabra por palabra, sin resumir ni recortar.
- La razón social puede aparecer con variaciones menores de puntuación o mayúsculas.
- Si el edicto no aparece en el boletín, devolvé una lista vacía.
"""

def build_prompt_texto(edictos_meta: list[dict]) -> str:
    items = "\n".join(
        f"- {e['razon_social']} | inicio: \"{e.get('texto_inicio', '')[:120]}\""
        for e in edictos_meta
    )
    return f"""
{CONTEXTO}

Del boletín oficial, extraé el texto COMPLETO e ÍNTEGRO de los siguientes edictos societarios.
Para identificar cada edicto usá tanto la razón social como el fragmento de texto de inicio:

{items}

Para cada uno devolvé:
- "razon_social": el nombre exactamente como figura en el PDF
- "texto_edicto": el texto íntegro del edicto, palabra por palabra, sin resumir ni recortar

Si no encontrás alguno, omitilo del resultado.
"""

SCHEMA_TEXTO = {
    "type": "ARRAY",
    "items": {
        "type": "OBJECT",
        "properties": {
            "razon_social": {
                "type": "STRING",
                "description": "Nombre de la sociedad tal como figura en el PDF"
            },
            "texto_edicto": {
                "type": "STRING",
                "description": "Texto completo e íntegro del edicto"
            },
        },
        "required": ["razon_social", "texto_edicto"]
    }
}


# ── Auth ────────────────────────────────────────────────────────
def get_token():
    creds = service_account.Credentials.from_service_account_file(
        SA_KEY_FILE,
        scopes=["https://www.googleapis.com/auth/cloud-platform"],
    )
    creds.refresh(Request())
    return creds.token


def vertex_url(model: str, action: str = "generateContent", location: str = None) -> str:
    loc = location or LOCATION
    host = "aiplatform.googleapis.com" if loc == "global" else f"{loc}-aiplatform.googleapis.com"
    return (
        f"https://{host}/v1"
        f"/projects/{PROJECT_ID}/locations/{loc}"
        f"/publishers/google/models/{model}:{action}"
    )


def resolve_model(entry) -> tuple[str, str]:
    """Devuelve (model_id, location) tanto si entry es str como dict."""
    if isinstance(entry, dict):
        return entry["model"], entry.get("location", LOCATION)
    return entry, LOCATION


def make_parts(pdf_b64: str, prompt: str) -> list:
    return [
        {"inline_data": {"mime_type": "application/pdf", "data": pdf_b64}},
        {"text": prompt},
    ]


# ── Reparación de JSON truncado ────────────────────────────────
def _try_repair_json(text: str):
    """
    Intenta reparar un JSON truncado por MAX_TOKENS:
    - Si la última cadena no está cerrada, la cierra.
    - Si quedan arrays/objetos abiertos, los cierra en orden.
    Sólo recurre a esto cuando finishReason indica truncamiento.
    """
    s = text.rstrip().rstrip(",")
    # ¿Terminamos dentro de un string? Contamos comillas no escapadas.
    in_string = False
    escape = False
    stack = []
    for ch in s:
        if escape:
            escape = False
            continue
        if ch == "\\":
            escape = True
            continue
        if ch == '"':
            in_string = not in_string
            continue
        if in_string:
            continue
        if ch in "[{":
            stack.append(ch)
        elif ch in "]}":
            if stack:
                stack.pop()

    repaired = s
    if in_string:
        repaired += '"'

    # Si el último carácter significativo es ':' o ',', recortamos hasta el último elemento válido.
    # Recorta tokens incompletos al final del último item.
    # Estrategia simple: ir recortando el último item hasta que cierre bien.
    while stack:
        opener = stack.pop()
        closer = "]" if opener == "[" else "}"
        # Si quedó una coma colgando antes del cierre, sacarla
        repaired = repaired.rstrip().rstrip(",")
        # Si quedó "key": al final sin valor, recortar hasta la última coma o apertura
        repaired = re.sub(r',\s*"[^"]*"\s*:\s*$', "", repaired)
        repaired = re.sub(r'"[^"]*"\s*:\s*$', "", repaired)
        repaired += closer

    try:
        return json.loads(repaired)
    except json.JSONDecodeError:
        # Segundo intento: ir recortando el último elemento de un array
        # (típico caso: el último edicto quedó incompleto)
        m = re.search(r',\s*\{[^{}]*$', repaired)
        if m:
            candidate = repaired[:m.start()]
            # Cerrar lo que quede abierto
            opens = candidate.count("[") - candidate.count("]")
            opens_obj = candidate.count("{") - candidate.count("}")
            candidate += "}" * max(0, opens_obj) + "]" * max(0, opens)
            try:
                return json.loads(candidate)
            except json.JSONDecodeError:
                pass
        return None


# ── API call con retry y fallback ──────────────────────────────
def call_api(pdf_bytes: bytes, prompt: str, schema: dict, models: list[str]):
    pdf_b64 = base64.b64encode(pdf_bytes).decode("utf-8")
    body = {
        "contents": [{"role": "user", "parts": make_parts(pdf_b64, prompt)}],
        "generation_config": {
            "response_mime_type": "application/json",
            "response_schema": schema,
            "max_output_tokens": 65535,
            "thinking_config": {"thinking_budget": 0},
        },
    }

    last_error = None
    for entry in models:
        model, loc = resolve_model(entry)
        for attempt in range(1, MAX_RETRIES + 1):
            try:
                token = get_token()
                resp = requests.post(
                    vertex_url(model, location=loc),
                    headers={
                        "Authorization": f"Bearer {token}",
                        "Content-Type": "application/json; charset=utf-8",
                    },
                    json=body,
                    timeout=300,
                )
            except requests.RequestException as e:
                last_error = e
                delay = 15.0 * attempt
                logging.warning(f"REINTENTO | error_red intento={attempt}/{MAX_RETRIES} delay={delay:.0f}s: {e}")
                time.sleep(delay)
                continue

            if resp.status_code == 429:
                retry_after = float(resp.headers.get("Retry-After", 30))
                logging.warning(f"REINTENTO | rate_limit intento={attempt}/{MAX_RETRIES} delay={retry_after:.0f}s")
                time.sleep(retry_after)
                continue

            if resp.status_code in (500, 503):
                delay = 15.0 * attempt
                logging.warning(f"REINTENTO | servidor_no_disponible status={resp.status_code} intento={attempt}/{MAX_RETRIES} delay={delay:.0f}s")
                time.sleep(delay)
                continue

            if resp.status_code != 200:
                last_error = Exception(f"Error {resp.status_code}: {resp.text[:400]}")
                logging.error(f"ERROR_API | {last_error}")
                break

            data = resp.json()
            try:
                candidate = data["candidates"][0]
                finish_reason = candidate.get("finishReason") or candidate.get("finish_reason")
                text = candidate["content"]["parts"][0]["text"].strip()
                text = re.sub(r"^```json\s*", "", text)
                text = re.sub(r"\s*```$", "", text)
            except (KeyError, IndexError) as e:
                last_error = Exception(f"Respuesta inesperada: {e} — {str(data)[:300]}")
                logging.error(f"ERROR_RESPUESTA | {last_error}")
                break

            try:
                return json.loads(text), model
            except json.JSONDecodeError as e:
                last_error = e
                truncated = finish_reason in ("MAX_TOKENS", "LENGTH")
                tail = text[-200:].replace("\n", "\\n")
                logging.warning(f"JSON_INVALIDO | intento={attempt}/{MAX_RETRIES} finish_reason={finish_reason} len={len(text)} tail=...{tail}: {e}")
                if truncated:
                    repaired = _try_repair_json(text)
                    if repaired is not None:
                        logging.info(f"JSON_REPARADO | finish_reason={finish_reason}")
                        return repaired, model
                if attempt < MAX_RETRIES:
                    time.sleep(5)
                    continue
                break

        last_error = last_error or Exception(f"Reintentos agotados para {model}")
        if len(models) > 1:
            logging.info(f"MODELO_FALLBACK | probando siguiente modelo")

    raise Exception(f"No se pudo completar la llamada. Último error: {last_error}")


# ── Helpers ─────────────────────────────────────────────────────
def _normalizar(s: str) -> str:
    s = unicodedata.normalize("NFKD", s)
    s = "".join(c for c in s if not unicodedata.combining(c))
    s = s.upper()
    s = re.sub(r'[.\-]', '', s)
    s = re.sub(r'\s+', ' ', s).strip()
    return s


def _split_pdf_chunks(pdf_bytes: bytes) -> list[bytes]:
    """Divide el PDF en chunks respetando límite de MB y de páginas."""
    total_size = len(pdf_bytes)
    reader = pypdf.PdfReader(io.BytesIO(pdf_bytes))
    n_pages = len(reader.pages)

    bytes_per_page = total_size / max(n_pages, 1)
    pages_per_chunk_mb = max(1, int(PDF_CHUNK_MB * 1024 * 1024 / bytes_per_page))
    pages_per_chunk = min(pages_per_chunk_mb, PDF_MAX_PAGES)

    chunks = []
    for start in range(0, n_pages, pages_per_chunk):
        end = min(start + pages_per_chunk, n_pages)
        writer = pypdf.PdfWriter()
        for p in range(start, end):
            writer.add_page(reader.pages[p])
        buf = io.BytesIO()
        writer.write(buf)
        chunks.append(buf.getvalue())
        logging.info(f"CHUNK | páginas {start + 1}-{end} de {n_pages} ({len(chunks[-1]) / 1024 / 1024:.1f}MB)")

    return chunks


def _merge_chunk_results(chunk_results: list[tuple[list, str | None, str]]) -> tuple[list, str | None, str]:
    """Combina los resultados de múltiples chunks eliminando duplicados por razón social."""
    seen = set()
    edictos_merged = []
    fecha = None
    model_used = ""

    for edictos, fecha_chunk, model in chunk_results:
        model_used = model
        if fecha_chunk and not fecha:
            fecha = fecha_chunk
        for e in edictos:
            key = _normalizar(e.get("razon_social", ""))
            if key not in seen:
                seen.add(key)
                edictos_merged.append(e)

    return edictos_merged, fecha, model_used


# ── Lógica two-pass ────────────────────────────────────────────
def _extract_from_bytes(pdf_bytes: bytes, models: list[str]) -> tuple[list, str | None, str]:
    """Pipeline two-pass sobre pdf_bytes ya en memoria."""
    logging.info("PASS_1 | detectando edictos societarios y fecha del boletín")
    pass1, model_used = call_api(pdf_bytes, PROMPT_META, SCHEMA_META, models)
    fecha_publicacion = pass1.get("fecha_publicacion")
    meta_list = pass1.get("edictos", [])
    logging.info(f"PASS_1_OK | fecha={fecha_publicacion or 'no encontrada'} edictos={len(meta_list)} modelo={model_used}")

    if not meta_list:
        return [], fecha_publicacion, model_used

    logging.info(f"PASS_2 | obteniendo textos en batches de {TEXT_BATCH_SIZE}")
    textos_map = {}
    batches = [meta_list[i:i + TEXT_BATCH_SIZE] for i in range(0, len(meta_list), TEXT_BATCH_SIZE)]

    for i, batch in enumerate(batches, 1):
        logging.info(f"PASS_2_BATCH | {i}/{len(batches)} ({len(batch)} edictos)")
        texto_list, model_used = call_api(
            pdf_bytes, build_prompt_texto(batch), SCHEMA_TEXTO, models
        )
        logging.info(f"PASS_2_BATCH_OK | {len(texto_list)} textos modelo={model_used}")
        for item in texto_list:
            textos_map[item["razon_social"].strip()] = item.get("texto_edicto", "")

    textos_map_norm = {_normalizar(k): v for k, v in textos_map.items()}

    def find_texto(razon_social: str) -> str:
        key = _normalizar(razon_social)
        if key in textos_map_norm:
            return textos_map_norm[key]
        for k, v in textos_map_norm.items():
            if key in k or k in key:
                return v
        return ""

    resultado = []
    faltantes = []
    for meta in meta_list:
        texto = find_texto(meta["razon_social"])
        resultado.append({
            "razon_social": meta["razon_social"],
            "tipo": meta["tipo"],
            "provincia": meta.get("provincia"),
            "texto_edicto": texto,
        })
        if not texto:
            faltantes.append((len(resultado) - 1, meta))

    # ── Pass 3: rescate individual para edictos sin texto ──
    if faltantes:
        logging.info(f"PASS_3 | rescatando {len(faltantes)} edicto(s) con texto vacío")
        for idx, meta in faltantes:
            razon = meta["razon_social"]
            try:
                texto_list, model_used = call_api(
                    pdf_bytes, build_prompt_rescue(meta), SCHEMA_TEXTO, models
                )
                if texto_list:
                    resultado[idx]["texto_edicto"] = texto_list[0].get("texto_edicto", "")
                    status = "OK" if resultado[idx]["texto_edicto"] else "vacío"
                else:
                    status = "no encontrado"
                logging.info(f"PASS_3_RESCATE | razon='{razon}' status={status} modelo={model_used}")
            except Exception as e:
                logging.error(f"PASS_3_ERROR | razon='{razon}': {e}")

    return resultado, fecha_publicacion, model_used


def extract_sociedades(pdf_path: pathlib.Path, models: list[str]) -> tuple[list, str | None, str]:
    pdf_bytes = pdf_path.read_bytes()
    size_mb = len(pdf_bytes) / (1024 * 1024)
    n_pages = len(pypdf.PdfReader(io.BytesIO(pdf_bytes)).pages)

    if size_mb <= PDF_MAX_MB and n_pages <= PDF_MAX_PAGES:
        return _extract_from_bytes(pdf_bytes, models)

    # PDF grande o con muchas páginas: dividir en chunks
    if size_mb > PDF_MAX_MB:
        logging.info(f"PDF_CHUNKING | {size_mb:.1f}MB supera límite de {PDF_MAX_MB}MB — dividiendo en chunks de ~{PDF_CHUNK_MB}MB")
    else:
        logging.info(f"PDF_CHUNKING | {n_pages} páginas supera límite de {PDF_MAX_PAGES} — dividiendo en chunks de {PDF_MAX_PAGES} páginas")
    chunks = _split_pdf_chunks(pdf_bytes)
    logging.info(f"PDF_CHUNKING | {len(chunks)} chunks generados")

    chunk_results = []
    for idx, chunk_bytes in enumerate(chunks, 1):
        chunk_mb = len(chunk_bytes) / (1024 * 1024)
        logging.info(f"CHUNK_INICIO | {idx}/{len(chunks)} ({chunk_mb:.1f}MB)")
        try:
            result = _extract_from_bytes(chunk_bytes, models)
            chunk_results.append(result)
            logging.info(f"CHUNK_OK | {idx}/{len(chunks)} edictos={len(result[0])}")
        except Exception as e:
            logging.error(f"CHUNK_ERROR | {idx}/{len(chunks)}: {e}")

    return _merge_chunk_results(chunk_results)


# ── Estimación de costo ────────────────────────────────────────
def estimate_cost(pdf_path: pathlib.Path, models: list[str]):
    model, loc = resolve_model(models[0])
    price_in, price_out = MODEL_PRICING.get(model, (0.30, 2.50))
    pdf_b64 = base64.b64encode(pdf_path.read_bytes()).decode("utf-8")

    print(f"Modelo: {model}  |  ${price_in}/1M in · ${price_out}/1M out")
    print(f"{'─' * 55}")

    def count_tokens(prompt: str) -> int:
        body = {"contents": [{"role": "user", "parts": make_parts(pdf_b64, prompt)}]}
        token = get_token()
        resp = requests.post(
            vertex_url(model, "countTokens", location=loc),
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json; charset=utf-8",
            },
            json=body,
            timeout=120,
        )
        if resp.status_code != 200:
            raise Exception(f"countTokens error {resp.status_code}: {resp.text[:300]}")
        return resp.json().get("totalTokens", 0)

    tok_pass1 = count_tokens(PROMPT_META)
    print(f"  Pass 1 (metadata, PDF completo):   {tok_pass1:>10,} tokens")

    ejemplo_batch = [
        {"razon_social": "EJEMPLO S.A.", "texto_inicio": "Se constituye la sociedad..."},
        {"razon_social": "OTRA SOCIEDAD S.R.L.", "texto_inicio": "Por escritura pública..."},
    ]
    tok_por_batch = count_tokens(build_prompt_texto(ejemplo_batch))
    batches_estimados = 3
    tok_pass2_total = tok_por_batch * batches_estimados
    print(f"  Pass 2 (~{batches_estimados} batches, {TEXT_BATCH_SIZE} edictos c/u): {tok_pass2_total:>10,} tokens est.")

    total_input = tok_pass1 + tok_pass2_total
    total_output_est = 65536
    cost_in  = total_input      / 1_000_000 * price_in
    cost_out = total_output_est / 1_000_000 * price_out
    print(f"{'─' * 55}")
    print(f"  Total tokens entrada (est.):       {total_input:>10,}")
    print(f"  Costo entrada:                     ${cost_in:.6f}")
    print(f"  Costo output (est. máximo):        ${cost_out:.6f}")
    print(f"  COSTO TOTAL ESTIMADO:              ${cost_in + cost_out:.6f}")


def _fecha_del_nombre(nombre: str):
    """Extrae 'YYYY-MM-DD' del nombre de archivo boletin_YYYY-MM-DD.pdf. Retorna None si no encuentra."""
    import re
    m = re.search(r'(\d{4}-\d{2}-\d{2})', nombre)
    return m.group(1) if m else None


# ── Output ─────────────────────────────────────────────────────
def save_json(edictos, fecha_publicacion, pdf_name, output_dir=None):
    stem = pathlib.Path(pdf_name).stem
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"sociedades_{stem}_{timestamp}.json"
    base = pathlib.Path(output_dir) if output_dir else pathlib.Path(".")
    base.mkdir(parents=True, exist_ok=True)
    filepath = base / filename
    output = {
        "pdf_origen": pdf_name,
        "fecha_publicacion": fecha_publicacion,
        "fecha_procesamiento": datetime.now().isoformat(),
        "total": len(edictos),
        "edictos": edictos,
    }
    filepath.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding="utf-8")
    return filepath


def print_results(edictos, fecha_publicacion, pdf_name):
    if not edictos:
        print("\nNo se encontraron edictos societarios.")
        return

    constituciones = [e for e in edictos if e["tipo"] == "constitución"]
    cambios        = [e for e in edictos if e["tipo"] == "cambio_directorio"]

    print(f"\n{'=' * 90}")
    print(f"  EDICTOS SOCIETARIOS: {len(edictos)}  "
          f"(constituciones: {len(constituciones)}  |  cambios de directorio: {len(cambios)})")
    print(f"  PDF: {pdf_name}")
    print(f"  Fecha publicación: {fecha_publicacion or 'no encontrada'}")
    print(f"{'=' * 90}\n")

    print(f"{'#':<4} {'RAZÓN SOCIAL':<45} {'TIPO'}")
    print(f"{'-'*4} {'-'*45} {'-'*20}")
    for i, e in enumerate(edictos, 1):
        razon = (e.get("razon_social") or "N/A")[:44]
        tipo  = e.get("tipo", "N/A")
        print(f"{i:<4} {razon:<45} {tipo}")

    print(f"\n{'=' * 90}")
    print("  DETALLE")
    print(f"{'=' * 90}")
    for i, e in enumerate(edictos, 1):
        texto = e.get("texto_edicto", "")
        preview = texto[:400] + "..." if len(texto) > 400 else texto
        print(f"\n  [{i}] {e.get('razon_social', 'N/A')}  ({e.get('tipo', '')})")
        print(f"      Texto: {preview}")


# ── Main ───────────────────────────────────────────────────────
def main():
    args = sys.argv[1:]

    explicit_model  = None
    estimate        = False
    output_dir      = str(pathlib.Path(__file__).resolve().parent.parent.parent / "files" / "boletines_json")
    filtro_provincia = None

    if "--model" in args:
        idx = args.index("--model")
        explicit_model = args[idx + 1]
        args = args[:idx] + args[idx + 2:]

    if "--estimate" in args:
        estimate = True
        args.remove("--estimate")

    if "--out" in args:
        idx = args.index("--out")
        output_dir = args[idx + 1]
        args = args[:idx] + args[idx + 2:]

    if "--provincia" in args:
        idx = args.index("--provincia")
        filtro_provincia = args[idx + 1].lower()
        args = args[:idx] + args[idx + 2:]

    # ── Resolver input_path ──
    if args:
        input_path = pathlib.Path(args[0])
        if not input_path.exists():
            print(f"No se encontró: {input_path}")
            sys.exit(1)
    else:
        input_path = pathlib.Path(__file__).resolve().parent.parent.parent / "files" / "boletines"
        if not input_path.exists():
            print("Uso: python3 procesar_boletin_vertex.py [<boletin.pdf|directorio>] [--model modelo] [--estimate]")
            sys.exit(1)

    sa_path = pathlib.Path(SA_KEY_FILE)
    if not sa_path.exists():
        print(f"No se encontró el archivo de service account: {SA_KEY_FILE}")
        sys.exit(1)

    if explicit_model:
        matching = next((m for m in MODELS_FALLBACK if resolve_model(m)[0] == explicit_model), explicit_model)
        models = [matching] + [m for m in MODELS_FALLBACK if resolve_model(m)[0] != explicit_model]
    else:
        models = MODELS_FALLBACK

    # ── Detectar modo batch o individual ──
    batch_mode = False
    prov_dirs  = []
    if input_path.is_file():
        pdfs = [input_path]
    else:
        prov_dirs = sorted(d for d in input_path.iterdir() if d.is_dir() and (d / "sin_procesar").is_dir())
        if filtro_provincia:
            prov_dirs = [d for d in prov_dirs if filtro_provincia in d.name.lower()]
        if prov_dirs:
            batch_mode = True
            pdfs = []
            for prov_dir in prov_dirs:
                pdfs.extend(sorted((prov_dir / "sin_procesar").glob("*.pdf")))
        else:
            pdfs = sorted(input_path.glob("*.pdf"))

    if not pdfs:
        print("No se encontraron PDFs para procesar.")
        sys.exit(0)

    model_labels = " → ".join(f"{resolve_model(m)[0]} ({resolve_model(m)[1]})" for m in models)
    print(f"Modelos: {model_labels}")
    print(f"Proyecto: {PROJECT_ID}")
    if batch_mode:
        print(f"Modo: batch ({len(prov_dirs)} provincias)")
    print(f"PDFs a procesar: {len(pdfs)}")
    print("=" * 60)

    logging.info(f"Inicio. PDFs a procesar: {len(pdfs)}")

    id_log = None
    ok_count = 0
    error_count = 0

    if not estimate:
        try:
            result = ejecutar_sp('PRS_iniciar', [
                ID_PROCESO,
                socket.gethostbyname(socket.gethostname()),
                os.getpid(),
                socket.gethostname(),
                12,
                _run_id,
            ])
            if result:
                id_log = result[0].get('id_log')
                setup_logging_db(id_log)
        except Exception as e:
            logging.warning(f"PRS_iniciar falló: {e}")

    try:
        for i, pdf_path in enumerate(pdfs, 1):
            size_mb = pdf_path.stat().st_size / (1024 * 1024)

            # Determinar carpetas de destino
            if batch_mode:
                prov_dir         = pdf_path.parent.parent  # boletines/<provincia>
                dest_procesado   = prov_dir / "procesado"
                dest_sin_edictos = prov_dir / "sin_edictos"
                dest_error       = prov_dir / "error"
                for d in [dest_procesado, dest_sin_edictos, dest_error]:
                    d.mkdir(exist_ok=True)
                label = f"{prov_dir.name}/{pdf_path.name}"
            else:
                dest_procesado = dest_sin_edictos = dest_error = None
                label = pdf_path.name

            logging.info(f"PDF_INICIO | [{i}/{len(pdfs)}] {label} size={size_mb:.1f}MB")

            if estimate:
                estimate_cost(pdf_path, models)
                continue

            try:
                edictos, fecha_publicacion, _ = extract_sociedades(pdf_path, models)
                print_results(edictos, fecha_publicacion, pdf_path.name)

                if batch_mode:
                    if edictos:
                        json_out = pathlib.Path(output_dir) / prov_dir.name
                        json_path = save_json(edictos, fecha_publicacion, pdf_path.name, str(json_out))
                        pdf_path.rename(dest_procesado / pdf_path.name)
                        logging.info(f"PDF_OK | {label} edictos={len(edictos)} fecha={fecha_publicacion} json={json_path.name} → procesado/")
                    else:
                        pdf_path.rename(dest_sin_edictos / pdf_path.name)
                        logging.info(f"PDF_SIN_EDICTOS | {label} → sin_edictos/")
                        if fecha_publicacion:
                            fuente = prov_dir.name.upper().replace('_', ' ')
                            guardar_dia_faltante(fuente, fecha_publicacion, 'sin_edictos')
                else:
                    # Si el PDF viene de .../sin_procesar/, usar la carpeta padre como subcarpeta de provincia
                    if pdf_path.parent.name == "sin_procesar":
                        json_out = pathlib.Path(output_dir) / pdf_path.parent.parent.name
                    else:
                        json_out = pathlib.Path(output_dir)
                    json_path = save_json(edictos, fecha_publicacion, pdf_path.name, str(json_out))
                    procesado_dir = pdf_path.parent.parent / "procesado" if pdf_path.parent.name == "sin_procesar" else pdf_path.parent / "procesado"
                    procesado_dir.mkdir(exist_ok=True)
                    pdf_path.rename(procesado_dir / pdf_path.name)
                    logging.info(f"PDF_OK | {label} edictos={len(edictos)} fecha={fecha_publicacion} json={json_path}")

                ok_count += 1

            except Exception as e:
                logging.error(f"PDF_ERROR | {label}: {e}")
                if batch_mode:
                    pdf_path.rename(dest_error / pdf_path.name)
                    logging.info(f"PDF_MOVIDO | {label} → error/")
                    fecha_str = _fecha_del_nombre(pdf_path.name)
                    if fecha_str:
                        fuente = prov_dir.name.upper().replace('_', ' ')
                        guardar_dia_faltante(fuente, fecha_str, 'error_descarga')
                error_count += 1

    finally:
        logging.info(f"Fin. OK: {ok_count} | Error: {error_count} | Total: {len(pdfs)}")
        if id_log is not None:
            try:
                ejecutar_sp('PRS_finalizar', [id_log, None, len(pdfs), ok_count, error_count])
            except Exception as e:
                logging.warning(f"PRS_finalizar falló: {e}")


if __name__ == "__main__":
    with single_instance("procesar_boletin_vertex"):
        main()
