package main

import (
	"database/sql"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"log"
	"math"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/yeka/zip"
)

type Estatus struct {
	// Información general de la ejecución
	DB             string   `json:"db"`
	Modulos        []string `json:"modulos"`
	Medicos        []int    `json:"medicos"`
	PacientesTotal int      `json:"pacientes_total"`

	// Duración de procesos
	FechaInicio                string  `json:"fecha_inicio"`
	FechaFin                   string  `json:"fecha_fin"`
	DuracionSegundos           float64 `json:"duracion_segundos"`
	DuracionSegundosPdfs       float64 `json:"duracion_segundos_pdfs"`
	DuracionSegundosCompresion float64 `json:"duracion_segundos_compresion"`

	// Recursos: pico de RAM del proceso y pesos finales (se llenan al completar).
	PicoRAMMB      float64 `json:"pico_ram_mb"`
	PesoPdfsMB     float64 `json:"peso_pdfs_mb"`
	PesoArchivosMB float64 `json:"peso_archivos_mb"`
	PesoZipMB      float64 `json:"peso_zip_mb"`

	// Progreso
	Progreso float64 `json:"progreso"`
	Estatus  string  `json:"estatus"`
	Mensaje  string  `json:"mensaje"`
}

type TotalModulo struct {
	Nombre string `json:"nombre"`
	Total  int    `json:"total"`
}

type ResultadoAlcance struct {
	DB            string                   `json:"db"`
	Alcance       map[string][]TotalModulo `json:"alcance,omitempty"`
	ArchivosTotal int                      `json:"archivos_total,omitempty"`
	PesoMB        float64                  `json:"peso_mb,omitempty"`
}

const rutaEstatus = "exportaciones/estatus.json"

var (
	estatus Estatus
	// estatusMu protege estatus y la escritura de estatus.json: el hilo principal
	// y la goroutine del watchdog (vigilarMemoria → logFatal) los tocan a la vez.
	estatusMu sync.Mutex
	// picoRAMBytes guarda el máximo de runtime.MemStats.Sys observado durante la
	// ejecución. Lo actualiza el muestreo de vigilarMemoria y lo lee guardarEstatusLocked.
	picoRAMBytes atomic.Uint64
)

func guardarEstatus() {
	estatusMu.Lock()
	defer estatusMu.Unlock()
	guardarEstatusLocked()
}

// guardarEstatusLocked serializa y escribe estatus.json. Asume estatusMu tomado.
func guardarEstatusLocked() {
	estatus.PicoRAMMB = bytesAMB(int64(picoRAMBytes.Load()))
	data, err := json.MarshalIndent(estatus, "", "\t")
	if err != nil {
		log.Fatal("Error al serializar estatus:", err)
	}
	if err := os.WriteFile(rutaEstatus, data, 0644); err != nil {
		log.Fatal("Error al guardar estatus:", err)
	}
}

// actualizarEstatus aplica fn sobre estatus y lo guarda, todo bajo estatusMu para
// que no se entrelace con el watchdog. fn debe limitarse a mutar estatus.
func actualizarEstatus(fn func()) {
	estatusMu.Lock()
	defer estatusMu.Unlock()
	fn()
	guardarEstatusLocked()
}

// redondear2 redondea un valor a 2 decimales.
func redondear2(v float64) float64 {
	return math.Round(v*100) / 100
}

// bytesAMB convierte bytes a megabytes redondeados a 2 decimales.
func bytesAMB(b int64) float64 {
	return redondear2(float64(b) / 1024 / 1024)
}

// dirStats recorre raíz y devuelve el número de archivos y su tamaño total en
// bytes. Si la ruta no existe o no es accesible, devuelve lo acumulado (0/0).
func dirStats(raiz string) (archivos int, bytes int64) {
	filepath.Walk(raiz, func(_ string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return nil
		}
		archivos++
		bytes += info.Size()
		return nil
	})
	return archivos, bytes
}

// resolverCarpetaCliente localiza la carpeta de archivos del cliente dbName dentro
// de clientesDir. Usa el nombre tal cual; si no existe (en Linux el FS distingue
// mayúsculas y la carpeta suele estar en mayúsculas, p. ej. MEDIS-DEMO frente a la
// BD medis-demo), reintenta con el nombre en mayúsculas. Devuelve "" si ninguna existe.
func resolverCarpetaCliente(clientesDir, dbName string) string {
	exacta := filepath.Join(clientesDir, dbName)
	if _, err := os.Stat(exacta); err == nil {
		return exacta
	}
	mayus := filepath.Join(clientesDir, strings.ToUpper(dbName))
	if _, err := os.Stat(mayus); err == nil {
		return mayus
	}
	return ""
}

func logFatal(err error) {
	logError("%s", err.Error())
	actualizarEstatus(func() {
		estatus.Estatus = "ERROR"
		estatus.Mensaje = err.Error()
	})
	os.Exit(1)
}

func leerParametros() (accion, db, password string, modulos []string, medicoIDs []int) {
	accionFlag := flag.String("accion", "", "acción a ejecutar: estatus, exportar, alcance, eliminar")
	dbFlag := flag.String("db", "", "nombre de la base de datos")
	modulosFlag := flag.String("modulos", "", "módulos a exportar separados por coma")
	medicFlag := flag.String("medic", "0", "Codigo médico")
	passwordFlag := flag.String("password", "", "contraseña para los ZIPs exportados")
	flag.Parse()

	if *accionFlag == "" {
		fmt.Fprintln(os.Stderr, "Error: -accion requerido (estatus, exportar, alcance, eliminar)")
		os.Exit(1)
	}
	accionesValidas := map[string]bool{"estatus": true, "exportar": true, "alcance": true, "eliminar": true}
	if !accionesValidas[*accionFlag] {
		fmt.Fprintln(os.Stderr, "Error: -accion debe ser 'estatus', 'exportar', 'alcance' o 'eliminar'")
		os.Exit(1)
	}

	if err := os.MkdirAll("exportaciones", 0755); err != nil {
		log.Fatal(err)
	}

	parsedModulos := []string{}
	if *modulosFlag != "" {
		parsedModulos = strings.Split(*modulosFlag, ",")
	}

	if *accionFlag == "exportar" {
		if *dbFlag == "" {
			fmt.Fprintln(os.Stderr, "Error: -db requerido para exportar")
			os.Exit(1)
		}
		if strings.ContainsAny(*dbFlag, "/?&#") {
			fmt.Fprintln(os.Stderr, "Error: -db contiene caracteres inválidos")
			os.Exit(1)
		}
		if *modulosFlag == "" {
			fmt.Fprintln(os.Stderr, "Error: -modulos requerido para exportar")
			os.Exit(1)
		}
		if *passwordFlag == "" {
			fmt.Fprintln(os.Stderr, "Error: -password requerido para exportar")
			os.Exit(1)
		}

		parsedMedicos := parsearMedicos(*medicFlag)

		loc, err := time.LoadLocation("America/Mexico_City")
		if err != nil {
			loc = time.UTC
		}
		fecha := time.Now().In(loc).Format("2006-01-02 03:04:05 PM")

		modulosParaEstatus := make([]string, 0)
		for _, m := range strings.Split(*modulosFlag, ",") {
			if m != "general" {
				modulosParaEstatus = append(modulosParaEstatus, m)
			}
		}
		estatus = Estatus{
			DB:             *dbFlag,
			Modulos:        modulosParaEstatus,
			Medicos:        parsedMedicos,
			PacientesTotal: 0,
			FechaInicio:    fecha,
			Progreso:       0,
			Estatus:        "EN_PROCESO",
			Mensaje:        "Iniciando",
		}
		guardarEstatus()

		return *accionFlag, *dbFlag, *passwordFlag, parsedModulos, parsedMedicos
	}

	if *accionFlag == "alcance" {
		if *dbFlag == "" {
			fmt.Fprintln(os.Stderr, "Error: -db requerido para alcance")
			os.Exit(1)
		}
		if strings.ContainsAny(*dbFlag, "/?&#") {
			fmt.Fprintln(os.Stderr, "Error: -db contiene caracteres inválidos")
			os.Exit(1)
		}
		return *accionFlag, *dbFlag, "", parsedModulos, nil
	}

	if *accionFlag == "eliminar" {
		if *dbFlag == "" {
			fmt.Fprintln(os.Stderr, "Error: -db requerido para eliminar")
			os.Exit(1)
		}
		if strings.ContainsAny(*dbFlag, "/?&#") {
			fmt.Fprintln(os.Stderr, "Error: -db contiene caracteres inválidos")
			os.Exit(1)
		}
		return *accionFlag, *dbFlag, "", parsedModulos, nil
	}

	return *accionFlag, *dbFlag, "", parsedModulos, nil
}

func parsearMedicos(medicStr string) []int {
	var ids []int
	for _, p := range strings.Split(medicStr, ",") {
		p = strings.TrimSpace(p)
		if p == "" || p == "0" {
			continue
		}
		id, err := strconv.Atoi(p)
		if err != nil || id <= 0 {
			fmt.Fprintf(os.Stderr, "Error: ID de médico inválido: %q\n", p)
			os.Exit(1)
		}
		ids = append(ids, id)
	}
	return ids
}

// marcadorFiltroAgenda señala en cada query de agenda (agenda.sql) el punto donde
// filtrarAgenda inyecta el WHERE por médico. Debe coincidir literalmente con el
// comentario de bloque embebido en el .sql.
const marcadorFiltroAgenda = "/* filtroMedico */"

func filtrarAgenda(query, col string, medicoIDs []int) (string, []any) {
	if len(medicoIDs) == 0 {
		return strings.Replace(query, marcadorFiltroAgenda, "", 1), nil
	}
	placeholders := strings.TrimRight(strings.Repeat("?,", len(medicoIDs)), ",")
	args := make([]any, len(medicoIDs))
	for i, id := range medicoIDs {
		args[i] = id
	}
	// Se incluye CODIGOMEDICO = 0 ("TODOS") para no perder los eventos globales que
	// también aplican al médico filtrado.
	filtro := fmt.Sprintf("WHERE (%s IN (%s) OR %s = 0)", col, placeholders, col)
	return strings.Replace(query, marcadorFiltroAgenda, filtro, 1), args
}

func codigosPacientesPorMedicos(db *sql.DB, medicoIDs []int) map[string]bool {
	placeholders := strings.TrimRight(strings.Repeat("?,", len(medicoIDs)), ",")
	args := make([]any, len(medicoIDs))
	for i, id := range medicoIDs {
		args[i] = id
	}

	tablas := []string{
		"NOTAS_EVOLUCION",
		"ANALISIS_CLINICOS_ENC",
		"ESTUDIOS_GABINETE_ENC",
		"REGISTROS_CRECIMIENTO",
		"REGISTROS_PRENATAL",
		"ENLACES_EXTERNOS",
		"HC_POR_FECHA",
		"DOC_FORMATOS",
	}

	subqueries := make([]string, len(tablas))
	allArgs := make([]any, 0, len(tablas)*len(medicoIDs))
	for i, t := range tablas {
		subqueries[i] = fmt.Sprintf("SELECT CODIGOPACIENTE FROM %s WHERE CODIGOMEDICO IN (%s)", t, placeholders)
		allArgs = append(allArgs, args...)
	}

	query := "SELECT DISTINCT CODIGOPACIENTE FROM (" + strings.Join(subqueries, " UNION ALL ") + ") t"
	_, rows := ejecutarQuery(db, query, allArgs...)

	codigos := make(map[string]bool, len(rows))
	for _, row := range rows {
		codigos[row["CODIGOPACIENTE"]] = true
	}
	return codigos
}

func consultarEstatus() Estatus {
	data, err := os.ReadFile(rutaEstatus)
	if err != nil {
		return Estatus{Estatus: "SIN_DATOS", Mensaje: "No hay exportaciones previas"}
	}
	var s Estatus
	if err := json.Unmarshal(data, &s); err != nil {
		return Estatus{Estatus: "ERROR", Mensaje: "Error leyendo estatus: " + err.Error()}
	}
	return s
}

var modulosDisponibles = []string{"general", "expedientes", "agenda", "caja", "archivos"}

func validarModulos(modulos []string) map[string]bool {
	validos := make(map[string]bool, len(modulosDisponibles))
	for _, m := range modulosDisponibles {
		validos[m] = true
	}
	set := make(map[string]bool)
	for _, m := range modulos {
		m = strings.TrimSpace(m)
		if !validos[m] {
			logFatal(fmt.Errorf("módulo inválido: %q (disponibles: %s)", m, strings.Join(modulosDisponibles, ", ")))
		}
		set[m] = true
	}
	return set
}

// muestrearRAM lee la memoria reservada del proceso (runtime.MemStats.Sys),
// actualiza el pico histórico y devuelve el valor actual en bytes. Sys es la
// cota superior segura (todo lo reservado del SO, incluido overhead del runtime),
// la misma métrica con la que vigilarMemoria aplica el corte. Asume un único
// escritor (el muestreo secuencial de vigilarMemoria más la llamada inicial).
func muestrearRAM() uint64 {
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	if m.Sys > picoRAMBytes.Load() {
		picoRAMBytes.Store(m.Sys)
	}
	return m.Sys
}

// vigilarMemoria muestrea la memoria del proceso cada 500 ms para registrar el
// pico en estatus.json y, si limiteMB > 0, cancela la exportación al superar el
// tope (evita que una exportación muy pesada agote la RAM del servidor). Corre en
// su propia goroutine; al superar el tope escribe ESTATUS=ERROR vía logFatal y
// termina el proceso. Un límite <= 0 solo desactiva el corte, no el muestreo.
func vigilarMemoria(limiteMB int) {
	limiteBytes := uint64(limiteMB) * 1024 * 1024
	ticker := time.NewTicker(500 * time.Millisecond)
	defer ticker.Stop()
	for range ticker.C {
		sys := muestrearRAM()
		if limiteMB > 0 && sys > limiteBytes {
			logFatal(fmt.Errorf("exportación cancelada: memoria del proceso (%d MB) superó el límite de %d MB", sys/1024/1024, limiteMB))
		}
	}
}

func exportarExpedientes(dbName string, modulos []string, medicoIDs []int, password string) {
	start := time.Now()
	loc, err := time.LoadLocation("America/Mexico_City")
	if err != nil {
		loc = time.UTC
	}
	logInfo("Iniciando exportación: db=%s módulos=%s médicos=%v", dbName, strings.Join(modulos, ","), medicoIDs)

	modulosSet := validarModulos(modulos)

	config, err := cargarConfig()
	if err != nil {
		logFatal(err)
	}
	aplicarCentral(config.Central)
	muestrearRAM() // pico inicial para que los primeros estatus reflejen un valor
	go vigilarMemoria(config.LimiteMemoriaMB)
	db, err := conectarDB(config.DB.dsn(dbName))
	if err != nil {
		logFatal(err)
	}
	defer db.Close()
	logInfo("Conexión a la base de datos establecida")

	baseDir := filepath.Join("exportaciones", dbName)
	zipPath := filepath.Join("exportaciones", dbName+".zip")
	// Ya no se borra la exportación previa automáticamente: si el ZIP existe hay
	// una exportación generada y se aborta para no sobreescribir una descarga
	// vigente (debe limpiarse antes con accion=eliminar). El ZIP es el marcador de
	// finalización; una carpeta {db}/ sin ZIP es residuo de una corrida fallida,
	// así que se limpia para permitir el reintento.
	if existeRuta(zipPath) {
		logFatal(fmt.Errorf("ya existe una exportación generada para %s; elimínela antes de generar una nueva", dbName))
	}
	if err := os.RemoveAll(baseDir); err != nil {
		logFatal(err)
	}
	if err := os.MkdirAll(baseDir, 0755); err != nil {
		logFatal(err)
	}

	cfg := ConfigPDF{
		Orientacion: "P",
		Unidad:      "mm",
		Tamaño:      "Letter",
		FontDir:     "",
		MarginLeft:  15,
		MarginRight: 15,
		FooterH:     20,
		LineH:       5,
		FontName:    "LiberationSerif",
		FontSize:    10,
		CellPadding: 1,
	}
	comp := Empresa{
		Nombre:    "Medis365 - " + dbName,
		Subtitulo: "",
		Footer:    "",
	}

	pacientesCols, pacientesRows := ejecutarQuery(db, queries["obtenerListaPacientes"])
	if len(medicoIDs) > 0 {
		actualizarEstatus(func() { estatus.Mensaje = "Filtrando pacientes por médico" })
		logInfo("Filtrando pacientes por %d médico(s): %v", len(medicoIDs), medicoIDs)
		codigosFiltrados := codigosPacientesPorMedicos(db, medicoIDs)
		filtered := make([]map[string]string, 0, len(pacientesRows))
		for _, p := range pacientesRows {
			if codigosFiltrados[p["_codigo"]] {
				filtered = append(filtered, p)
			}
		}
		pacientesRows = filtered
		logInfo("Pacientes tras filtro: %d", len(pacientesRows))
	}

	if modulosSet["general"] {
		actualizarEstatus(func() { estatus.Mensaje = "Exportando clínica" })
		logInfo("Exportando clínica")
		clinicaCols, clinicaRows := ejecutarQuery(db, queries["obtenerDatosClinica"])
		rep := nuevoReporte(cfg, comp)
		rep.pdf.AddPage()
		tituloGeneral(rep, "Clínica")
		crearParrafosPdf(rep, "Clínica", clinicaCols, clinicaRows)
		guardarPDF(rep, filepath.Join(baseDir, "Clinica.pdf"))

		actualizarEstatus(func() { estatus.Mensaje = "Exportando médicos" })
		logInfo("Exportando médicos")
		medicosCols, medicosRows := ejecutarQuery(db, queries["obtenerListaMedicos"])
		rep = nuevoReporte(cfg, comp)
		rep.pdf.AddPage()
		tituloGeneral(rep, "Médicos")
		crearParrafosPdf(rep, "Médico", medicosCols, medicosRows)
		guardarPDF(rep, filepath.Join(baseDir, "Medicos.pdf"))

		actualizarEstatus(func() { estatus.Mensaje = "Exportando lista de pacientes" })
		logInfo("Exportando lista de pacientes")
		rep = nuevoReporte(cfg, comp)
		rep.pdf.AddPage()
		tituloGeneral(rep, "Pacientes")
		crearTablaPdf(rep, pacientesCols, pacientesRows)
		guardarPDF(rep, filepath.Join(baseDir, "Pacientes.pdf"))
	}

	iterarPacientes := modulosSet["expedientes"] || modulosSet["caja"]
	if iterarPacientes {
		total := len(pacientesRows)
		actualizarEstatus(func() { estatus.PacientesTotal = total })
		logInfo("Iniciando exportación de %d paciente(s)", total)

		for i, p := range pacientesRows {
			codigo := p["_codigo"]
			nombre := p["Paciente"]
			logInfo("[%d/%d] %s - %s", i+1, total, codigo, nombre)
			carpeta := filepath.Join(baseDir, fmt.Sprintf("%s - %s", codigo, sanitizarDir(nombre)))
			if err := os.MkdirAll(carpeta, 0755); err != nil {
				logFatal(err)
			}

			if modulosSet["expedientes"] {
				exportarExpedientePaciente(db, cfg, comp, codigo, nombre, carpeta)
			}
			if modulosSet["caja"] {
				exportarCajaPaciente(db, cfg, comp, codigo, nombre, carpeta)
			}

			actualizarEstatus(func() {
				estatus.Progreso = redondear2(float64(i+1) / float64(total) * 100)
				estatus.Mensaje = fmt.Sprintf("Exportando pacientes (%d/%d)", i+1, total)
			})
		}
	}

	if modulosSet["agenda"] {
		actualizarEstatus(func() { estatus.Mensaje = "Exportando agenda" })
		logInfo("Exportando agenda")
		carpetaAgenda := filepath.Join(baseDir, "Agenda")
		if err := os.MkdirAll(carpetaAgenda, 0755); err != nil {
			logFatal(err)
		}
		citasQ, citasArgs := filtrarAgenda(queries["obtenerCitas"], "A.CODIGOMEDICO", medicoIDs)
		citasCols, citasRows := ejecutarQuery(db, citasQ, citasArgs...)
		rep := nuevoReporte(cfg, comp)
		rep.pdf.AddPage()
		tituloGeneral(rep, "Citas agendadas")
		crearTablaPdf(rep, citasCols, citasRows)
		guardarPDF(rep, filepath.Join(carpetaAgenda, "CitasAgendadas.pdf"))

		noLabQ, noLabArgs := filtrarAgenda(queries["obtenerNoLaborables"], "NL.CODIGOMEDICO", medicoIDs)
		noLaborablesCols, noLaborablesRows := ejecutarQuery(db, noLabQ, noLabArgs...)
		rep = nuevoReporte(cfg, comp)
		rep.pdf.AddPage()
		tituloGeneral(rep, "Otros eventos")
		crearTablaPdf(rep, noLaborablesCols, noLaborablesRows)
		guardarPDF(rep, filepath.Join(carpetaAgenda, "OtrosEventos.pdf"))

		citasCtrlQ, citasCtrlArgs := filtrarAgenda(queries["obtenerCitasdeControl"], "C.CODIGOMEDICO", medicoIDs)
		citasControlCols, citasControlRows := ejecutarQuery(db, citasCtrlQ, citasCtrlArgs...)
		rep = nuevoReporte(cfg, comp)
		rep.pdf.AddPage()
		tituloGeneral(rep, "Citas de control")
		crearTablaPdf(rep, citasControlCols, citasControlRows)
		guardarPDF(rep, filepath.Join(carpetaAgenda, "CitasControl.pdf"))
	}

	var rutaCliente string
	if modulosSet["archivos"] {
		rutaCliente = resolverCarpetaCliente(config.ClientesDir, dbName)
	}

	actualizarEstatus(func() { estatus.Mensaje = "Comprimiendo ZIP" })
	logInfo("Generando ZIP de exportación")
	startZip := time.Now()
	if err := generarZIP(baseDir, dbName, rutaCliente, password); err != nil {
		logFatal(err)
	}
	fin := time.Now()

	_, bytesPdfs := dirStats(baseDir)
	pesoPdfs := bytesAMB(bytesPdfs)
	var pesoArchivos float64
	if rutaCliente != "" {
		_, bytesArchivos := dirStats(rutaCliente)
		pesoArchivos = bytesAMB(bytesArchivos)
	}
	var pesoZip float64
	if info, err := os.Stat(filepath.Join("exportaciones", dbName+".zip")); err == nil {
		pesoZip = bytesAMB(info.Size())
	}

	actualizarEstatus(func() {
		estatus.Estatus = "COMPLETADO"
		estatus.Mensaje = "Exportación completada"
		estatus.FechaFin = fin.In(loc).Format("2006-01-02 03:04:05 PM")
		estatus.DuracionSegundosPdfs = redondear2(startZip.Sub(start).Seconds())
		estatus.DuracionSegundosCompresion = redondear2(fin.Sub(startZip).Seconds())
		estatus.DuracionSegundos = redondear2(fin.Sub(start).Seconds())
		estatus.PesoPdfsMB = pesoPdfs
		estatus.PesoArchivosMB = pesoArchivos
		estatus.PesoZipMB = pesoZip
		estatus.Progreso = 100
	})
	logInfo("Exportación completada en %.2fs (compresión %.2fs)", redondear2(fin.Sub(start).Seconds()), redondear2(fin.Sub(startZip).Seconds()))
}

type registroExpediente struct {
	etiqueta     string
	fechaSort    string
	cols         []string
	row          map[string]string
	tituloTabla  string
	subRegistros []registroExpediente
	conSecciones bool
}

// registrosSimples convierte filas de query en registroExpediente usando _id como etiqueta.
func registrosSimples(cols []string, rows []map[string]string, etiquetaBase string) []registroExpediente {
	var registros []registroExpediente
	for _, row := range rows {
		registros = append(registros, registroExpediente{
			etiqueta:  fmt.Sprintf("%s #%s", etiquetaBase, row["_id"]),
			fechaSort: row["_sort"],
			cols:      cols,
			row:       row,
		})
	}
	return registros
}

func exportarDatosGenerales(db *sql.DB, rep Reporte, codigo string) {
	cols, rows := ejecutarQuery(db, queries["obtenerDatosPaciente"], codigo)
	crearParrafosPdf(rep, "Paciente", cols, rows)
}

func cargarNotasAdicionales(db *sql.DB, codigo string) map[string][]registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerNotasAdicionales"], codigo)
	porNE := make(map[string][]registroExpediente)
	for _, row := range rows {
		clave := row["_id_nota"]
		porNE[clave] = append(porNE[clave], registroExpediente{
			etiqueta: fmt.Sprintf("Nota adicional #%s", row["_id"]),
			cols:     cols,
			row:      row,
		})
	}
	return porNE
}

func agregarRecetas(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerRecetas"], codigo)
	return registrosSimples(cols, rows, "Receta")
}

func agregarRegistrosCrecimiento(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerRegistrosCrecimiento"], codigo)
	return registrosSimples(cols, rows, "Registro de crecimiento")
}

func agregarNotasEvolucion(db *sql.DB, codigo string, notasAdicPorNE map[string][]registroExpediente) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerNotasEvolucion"], codigo)
	expandirDiagnosticos(db, rows, "Diagnósticos", "", "")
	var registros []registroExpediente
	for _, row := range rows {
		id := row["_id"]
		registros = append(registros, registroExpediente{
			etiqueta:     fmt.Sprintf("Nota de evolución #%s", id),
			fechaSort:    row["_sort"],
			cols:         cols,
			row:          row,
			tituloTabla:  "Listado de notas adicionales",
			subRegistros: notasAdicPorNE[id],
		})
	}
	return registros
}

func agregarAnalisisClinicos(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerAnalisisClinicos"], codigo)
	return agruparPorOrden(cols, rows, "Orden de análisis clínicos")
}

func agregarEstudiosGabinete(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerEstudiosGabinete"], codigo)
	return agruparPorOrden(cols, rows, "Orden de estudios de gabinete")
}

func expandirDiagnosticos(db *sql.DB, rows []map[string]string, columna, filtroCol, filtroVal string) {
	var codigosDx []string
	for _, row := range rows {
		if filtroCol != "" && row[filtroCol] != filtroVal {
			continue
		}
		if v := row[columna]; v != "" {
			codigosDx = append(codigosDx, strings.Split(v, ",")...)
		}
	}
	diccDx := diccionarioDiagnosticos(db, codigosDx)
	for i, row := range rows {
		if filtroCol != "" && row[filtroCol] != filtroVal {
			continue
		}
		if v := row[columna]; v != "" {
			rows[i][columna] = formatearDiagnosticos(diccDx, v)
		}
	}
}

func diccionarioDiagnosticos(db *sql.DB, codigos []string) map[string]string {
	vistos := make(map[string]bool)
	var unicos []string
	for _, c := range codigos {
		c = strings.TrimSpace(c)
		if c != "" && !vistos[c] {
			vistos[c] = true
			unicos = append(unicos, c)
		}
	}
	if len(unicos) == 0 {
		return nil
	}
	var personalizados, cie10 []string
	for _, c := range unicos {
		if strings.HasPrefix(c, "DX") {
			personalizados = append(personalizados, c)
		} else {
			cie10 = append(cie10, c)
		}
	}
	dic := make(map[string]string, len(unicos))
	if len(cie10) > 0 {
		placeholders := strings.TrimRight(strings.Repeat("?,", len(cie10)), ",")
		args := make([]any, len(cie10))
		for i, c := range cie10 {
			args[i] = c
		}
		_, rows := ejecutarQuery(db, queries["diagnosticosCIE10"]+" ("+placeholders+")", args...)
		for _, row := range rows {
			if row["DESCRIPCION"] != "" {
				dic[row["CODIGO"]] = row["DESCRIPCION"]
			}
		}
	}
	if len(personalizados) > 0 {
		placeholders := strings.TrimRight(strings.Repeat("?,", len(personalizados)), ",")
		args := make([]any, len(personalizados))
		for i, c := range personalizados {
			args[i] = c
		}
		_, rows := ejecutarQuery(db, queries["diagnosticosPersonalizados"]+" ("+placeholders+")", args...)
		for _, row := range rows {
			if row["DESCRIPCION"] != "" {
				dic[row["CODIGO"]] = row["DESCRIPCION"]
			}
		}
	}
	return dic
}

func formatearDiagnosticos(diccDx map[string]string, codigosStr string) string {
	if codigosStr == "" {
		return ""
	}
	codigos := strings.Split(codigosStr, ",")
	partes := make([]string, 0, len(codigos))
	for _, codigo := range codigos {
		codigo = strings.TrimSpace(codigo)
		if codigo == "" {
			continue
		}
		if desc := diccDx[codigo]; desc != "" {
			partes = append(partes, codigo+" - "+desc)
		} else {
			partes = append(partes, codigo)
		}
	}
	return strings.Join(partes, ", ")
}

func agregarRegistrosPrenatal(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerRegistrosPrenatal"], codigo)
	expandirDiagnosticos(db, rows, "Diagnósticos", "", "")
	return registrosSimples(cols, rows, "Control prenatal")
}

func agregarVacunas(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerVacunas"], codigo, codigo)
	return registrosSimples(cols, rows, "Vacuna")
}

func agregarImagenes(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerImagenes"], codigo)
	return registrosSimples(cols, rows, "Imagen")
}

func agregarDocumentos(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerDocumentos"], codigo)
	return registrosSimples(cols, rows, "Documento")
}

func agregarEnlaces(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerEnlaces"], codigo)
	return registrosSimples(cols, rows, "Enlace")
}

func agregarFormatos(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerFormatos"], codigo)
	return registrosSimples(cols, rows, "Formato")
}

func agregarHistoriasClinicas(db *sql.DB, codigo string) []registroExpediente {
	cols, rows := ejecutarQuery(db, queries["obtenerHistoriasClinicas"], codigo)
	expandirDiagnosticos(db, rows, "Respuesta", "_tipo_respuesta", "X")
	registros := agruparPorOrden(cols, rows, "Historia clínica")
	for i := range registros {
		registros[i].conSecciones = true
	}
	return registros
}

func exportarExpedientePaciente(db *sql.DB, cfg ConfigPDF, comp Empresa, codigo, nombre, carpeta string) {
	compPaciente := comp
	compPaciente.Subtitulo = codigo + " - " + nombre
	rep := nuevoReporte(cfg, compPaciente)
	rep.pdf.AddPage()

	exportarDatosGenerales(db, rep, codigo)

	notasAdicPorNE := cargarNotasAdicionales(db, codigo)

	var registros []registroExpediente
	registros = append(registros, agregarRecetas(db, codigo)...)
	registros = append(registros, agregarRegistrosCrecimiento(db, codigo)...)
	registros = append(registros, agregarNotasEvolucion(db, codigo, notasAdicPorNE)...)
	registros = append(registros, agregarAnalisisClinicos(db, codigo)...)
	registros = append(registros, agregarEstudiosGabinete(db, codigo)...)
	registros = append(registros, agregarRegistrosPrenatal(db, codigo)...)
	registros = append(registros, agregarVacunas(db, codigo)...)
	registros = append(registros, agregarImagenes(db, codigo)...)
	registros = append(registros, agregarDocumentos(db, codigo)...)
	registros = append(registros, agregarEnlaces(db, codigo)...)
	registros = append(registros, agregarFormatos(db, codigo)...)
	registros = append(registros, agregarHistoriasClinicas(db, codigo)...)

	sort.Slice(registros, func(i, j int) bool {
		return registros[i].fechaSort > registros[j].fechaSort
	})

	var ultimaFecha string
	for _, reg := range registros {
		if reg.fechaSort != "" {
			fechaDia := reg.fechaSort[:10]
			if fechaDia != ultimaFecha {
				if ultimaFecha != "" {
					rep.pdf.Ln(3)
				}
				t, err := time.Parse("2006-01-02", fechaDia)
				var fechaFormato string
				if err == nil {
					fechaFormato = t.Format("02/01/2006")
				} else {
					fechaFormato = fechaDia
				}
				rep.pdf.SetFont(rep.cfg.FontName, "B", 13)
				rep.pdf.CellFormat(0, 8, "CONSULTA "+fechaFormato, "", 1, "L", false, 0, "")
				ultimaFecha = fechaDia
			}
		}
		renderizarRegistro(rep, reg.etiqueta, reg.cols, reg.row)
		if len(reg.subRegistros) > 0 {
			if reg.conSecciones {
				registrosHCySecciones(rep, reg.subRegistros)
			} else {
				subCols := filtrarColsOcultas(reg.subRegistros[0].cols)
				subRows := make([]map[string]string, len(reg.subRegistros))
				for i, sub := range reg.subRegistros {
					subRows[i] = sub.row
				}
				if reg.tituloTabla != "" {
					rep.pdf.SetFont(rep.cfg.FontName, "B", rep.cfg.FontSize)
					rep.pdf.CellFormat(0, 2, reg.tituloTabla, "", 1, "L", false, 0, "")
				}
				crearTablaPdf(rep, subCols, subRows)
			}
		}
	}

	guardarPDF(rep, filepath.Join(carpeta, fmt.Sprintf("%s - Expediente.pdf", codigo)))
}

func exportarCajaPaciente(db *sql.DB, cfg ConfigPDF, comp Empresa, codigo, nombre, carpeta string) {
	compPaciente := comp
	compPaciente.Subtitulo = codigo + " - " + nombre
	repCaja := nuevoReporte(cfg, compPaciente)
	repCaja.pdf.AddPage()
	tituloGeneral(repCaja, "Caja")

	cargosGeneralesCols, cargosGeneralesRows := ejecutarQuery(db, queries["obtenerCargosGenerales"], codigo)
	repCaja.pdf.SetFont(repCaja.cfg.FontName, "B", repCaja.cfg.FontSize)
	repCaja.pdf.CellFormat(0, 2, "Resumen", "", 1, "L", false, 0, "")
	crearTablaPdf(repCaja, cargosGeneralesCols, cargosGeneralesRows)

	cargosCols, cargosRows := ejecutarQuery(db, queries["obtenerCargos"], codigo)
	repCaja.pdf.Ln(3)
	repCaja.pdf.SetFont(repCaja.cfg.FontName, "B", repCaja.cfg.FontSize)
	repCaja.pdf.CellFormat(0, 2, "Cargos", "", 1, "L", false, 0, "")
	crearTablaPdf(repCaja, cargosCols, cargosRows)

	abonosCols, abonosRows := ejecutarQuery(db, queries["obtenerAbonos"], codigo)
	repCaja.pdf.Ln(3)
	repCaja.pdf.SetFont(repCaja.cfg.FontName, "B", repCaja.cfg.FontSize)
	repCaja.pdf.CellFormat(0, 2, "Abonos", "", 1, "L", false, 0, "")
	crearTablaPdf(repCaja, abonosCols, abonosRows)

	cotizacionesCols, cotizacionesRows := ejecutarQuery(db, queries["obtenerCotizaciones"], codigo)
	repCaja.pdf.Ln(3)
	repCaja.pdf.SetFont(repCaja.cfg.FontName, "B", repCaja.cfg.FontSize)
	repCaja.pdf.CellFormat(0, 2, "Cotizaciones", "", 1, "L", false, 0, "")
	crearTablaPdf(repCaja, cotizacionesCols, cotizacionesRows)

	rutaCaja := filepath.Join(carpeta, fmt.Sprintf("%s - Caja.pdf", codigo))
	os.Remove(rutaCaja)
	guardarPDF(repCaja, rutaCaja)
}

func agruparPorOrden(cols []string, rows []map[string]string, etiquetaBase string) []registroExpediente {
	var colsOrden, colsDetalle []string
	inDetalle := false
	for _, col := range cols {
		if col == "_detalle" {
			inDetalle = true
			continue
		}
		if strings.HasPrefix(col, "_") {
			continue
		}
		if inDetalle {
			colsDetalle = append(colsDetalle, col)
		} else {
			colsOrden = append(colsOrden, col)
		}
	}

	type ordenEntry struct {
		row          map[string]string
		subRegistros []registroExpediente
	}
	ordenMap := map[string]*ordenEntry{}
	var ordenKeys []string
	for _, row := range rows {
		idOrden := row["_id_orden"]
		if _, existe := ordenMap[idOrden]; !existe {
			ordenMap[idOrden] = &ordenEntry{row: row}
			ordenKeys = append(ordenKeys, idOrden)
		}
		subEtiqueta := ""
		if len(colsDetalle) > 0 {
			subEtiqueta = row[colsDetalle[0]]
		}
		ordenMap[idOrden].subRegistros = append(ordenMap[idOrden].subRegistros, registroExpediente{
			etiqueta: subEtiqueta,
			cols:     colsDetalle,
			row:      row,
		})
	}

	var registros []registroExpediente
	for _, idOrden := range ordenKeys {
		entry := ordenMap[idOrden]
		registros = append(registros, registroExpediente{
			etiqueta:     fmt.Sprintf("%s #%s", etiquetaBase, idOrden),
			fechaSort:    entry.row["_sort"],
			cols:         colsOrden,
			row:          entry.row,
			tituloTabla:  "Listado de " + strings.ToLower(etiquetaBase),
			subRegistros: entry.subRegistros,
		})
	}
	return registros
}

// generarZIP empaqueta toda la exportación en un único {db}.zip cifrado (AES-256):
//   - {db}_Registros/: los PDFs generados (baseDir).
//   - {db}_Archivos/:  los archivos del cliente, solo si el módulo "archivos"
//     aportó una carpeta (rutaCliente != "").
func generarZIP(baseDir, dbName, rutaCliente, password string) (err error) {
	zipPath := filepath.Join("exportaciones", dbName+".zip")

	// No se elimina ningún ZIP previo: exportarExpedientes ya abortó si existía
	// una exportación generada para dbName, así que aquí siempre partimos de cero.
	// Si algo falla, se borra el ZIP parcial para que su presencia no bloquee un
	// reintento posterior (el ZIP es el marcador de exportación completada).
	defer func() {
		if err != nil {
			os.Remove(zipPath)
		}
	}()

	f, err := os.Create(zipPath)
	if err != nil {
		return fmt.Errorf("no se pudo crear %s: %w", filepath.Base(zipPath), err)
	}
	defer f.Close()

	w := zip.NewWriter(f)
	if err := agregarCarpetaAlZip(w, baseDir, dbName+"_Registros", password); err != nil {
		w.Close()
		return err
	}
	if rutaCliente != "" {
		if err := agregarCarpetaAlZip(w, rutaCliente, dbName+"_Archivos", password); err != nil {
			w.Close()
			return err
		}
	}
	return w.Close()
}

// agregarCarpetaAlZip añade el contenido de origen al ZIP abierto w, ubicando
// cada archivo (cifrado AES-256) bajo prefijo/ dentro del ZIP.
func agregarCarpetaAlZip(w *zip.Writer, origen, prefijo, password string) error {
	return filepath.Walk(origen, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		rel, err := filepath.Rel(origen, path)
		if err != nil {
			return err
		}
		zf, err := w.Encrypt(filepath.Join(prefijo, rel), password, zip.AES256Encryption)
		if err != nil {
			return err
		}
		src, err := os.Open(path)
		if err != nil {
			return err
		}
		_, err = io.Copy(zf, src)
		src.Close()
		return err
	})
}

var queryPorModulo = map[string]string{
	"general":     "alcanceTotalesGeneral",
	"expedientes": "alcanceTotalesExpedientes",
	"agenda":      "alcanceTotalesAgenda",
	"caja":        "alcanceTotalesCaja",
}

func consultarAlcance(dbName string, modulos []string) {
	modulosSet := make(map[string]bool)
	if len(modulos) == 0 {
		for _, m := range modulosDisponibles {
			modulosSet[m] = true
		}
	} else {
		for _, m := range modulos {
			modulosSet[m] = true
		}
	}

	// "general" (médicos/clínicas) solo es relevante si hay al menos un módulo de contenido
	tieneContenido := modulosSet["expedientes"] || modulosSet["agenda"] || modulosSet["caja"]
	if !tieneContenido {
		delete(modulosSet, "general")
	}

	necesitaDB := false
	for _, m := range modulosDisponibles {
		if modulosSet[m] && queryPorModulo[m] != "" {
			necesitaDB = true
			break
		}
	}

	// La config se necesita para conectar a la BD y para resolver la ruta de
	// archivos del cliente (config.ClientesDir), aunque "archivos" no toque la BD.
	var config Config
	if necesitaDB || modulosSet["archivos"] {
		var err error
		config, err = cargarConfig()
		if err != nil {
			fmt.Fprintln(os.Stderr, "Error de configuración:", err)
			os.Exit(1)
		}
	}

	var db *sql.DB
	if necesitaDB {
		aplicarCentral(config.Central)
		var err error
		db, err = conectarDB(config.DB.dsn(dbName))
		if err != nil {
			fmt.Fprintln(os.Stderr, "Error conectando a la base de datos:", err)
			os.Exit(1)
		}
		defer db.Close()
	}

	alcance := make(map[string][]TotalModulo)
	for _, modulo := range modulosDisponibles {
		if !modulosSet[modulo] {
			continue
		}
		queryKey := queryPorModulo[modulo]
		if queryKey == "" {
			continue
		}
		cols, rows := ejecutarQuery(db, queries[queryKey])
		row := rows[0]
		totales := make([]TotalModulo, 0, len(cols))
		for _, col := range cols {
			total, _ := strconv.Atoi(row[col])
			totales = append(totales, TotalModulo{Nombre: col, Total: total})
		}
		alcance[modulo] = totales
	}

	var archivosTotal int
	var pesoMB float64
	if modulosSet["archivos"] {
		rutaCliente := resolverCarpetaCliente(config.ClientesDir, dbName)
		if rutaCliente != "" {
			var pesoTotal int64
			archivosTotal, pesoTotal = dirStats(rutaCliente)
			pesoMB = bytesAMB(pesoTotal)
		}
	}
	resultado := ResultadoAlcance{DB: dbName, Alcance: alcance, ArchivosTotal: archivosTotal, PesoMB: pesoMB}
	data, _ := json.MarshalIndent(resultado, "", "\t")
	fmt.Println(string(data))
}

// existeRuta indica si ruta apunta a un archivo o carpeta existente.
func existeRuta(ruta string) bool {
	_, err := os.Stat(ruta)
	return err == nil
}

// eliminarExportacion borra los artefactos generados para dbName dentro de
// exportaciones/: la carpeta de PDFs {db}/ y el {db}.zip (más las variantes
// legacy de nombres separados). Solo opera dentro de exportaciones/; NUNCA toca
// la carpeta de archivos fuente del cliente (config.clientes_dir). No lee
// config.json ni escribe estatus.json; ante fallo escribe a stderr y termina con
// código !=0 para que medis-back lo capture.
func eliminarExportacion(dbName string) {
	rutas := []string{
		filepath.Join("exportaciones", dbName),
		filepath.Join("exportaciones", dbName+".zip"),
		filepath.Join("exportaciones", dbName+"_Registros.zip"),
		filepath.Join("exportaciones", dbName+"_Archivos.zip"),
		filepath.Join("exportaciones", dbName+"_registros.zip"),
	}
	for _, ruta := range rutas {
		if err := os.RemoveAll(ruta); err != nil {
			fmt.Fprintf(os.Stderr, "Error al eliminar %s: %v\n", filepath.Base(ruta), err)
			os.Exit(1)
		}
	}
	data, _ := json.Marshal(map[string]any{"success": true, "db": dbName})
	fmt.Println(string(data))
}

func main() {
	accion, db, password, modulos, medicoIDs := leerParametros()

	switch accion {
	case "estatus":
		s := consultarEstatus()
		data, _ := json.MarshalIndent(s, "", "\t")
		fmt.Println(string(data))
	case "exportar":
		iniciarLog(filepath.Join("exportaciones", "exportacion.log"))
		defer cerrarLog()
		exportarExpedientes(db, modulos, medicoIDs, password)
	case "alcance":
		consultarAlcance(db, modulos)
	case "eliminar":
		eliminarExportacion(db)
	}
}
