import {
  BadRequestException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { In, IsNull, Repository } from "typeorm";
import { UserRole, Usuario } from "../auth/entities/usuario.entity";
import { ClienteLocal } from "../clientes/entities/cliente-local.entity";
import { EstadoCotizacion } from "../cotizaciones/entities/cotizacion.entity";
import {
  FacturacionPosService,
  ConsultaPagoPos,
  FacturaCreadaPos,
  OtParaFacturar,
} from "../facturacion-pos/facturacion-pos.service";
import { OtFacturacion } from "../facturacion-pos/entities/ot-facturacion.entity";
import { ocultarCredencialesTienda } from "../auth/ocultar-credenciales-tienda";
import { MecanicosService } from "../mecanicos/mecanicos.service";
import { TallerParametrosService } from "../taller-parametros/taller-parametros.service";
import { TenantRepositoryAccessor } from "../tenancy/tenant-repository.accessor";
import { VehiculosService } from "../vehiculos/vehiculos.service";
import { CreateDanoDto } from "./dto/create-dano.dto";
import { DatosEntrega } from "./dto/change-estado.dto";
import { CreateFirmaDto } from "./dto/create-firma.dto";
import { CreateFotoDto } from "./dto/create-foto.dto";
import { CreateOtDto } from "./dto/create-ot.dto";
import { SearchOtDto } from "./dto/search-ot.dto";
import { UpdateOtDto } from "./dto/update-ot.dto";
import { EstadoOT, OrdenTrabajo } from "./entities/orden-trabajo.entity";
import { OtCosaTrae } from "./entities/ot-cosa-trae.entity";
import { OtDano } from "./entities/ot-dano.entity";
import { OtFirma } from "./entities/ot-firma.entity";
import { OtFoto } from "./entities/ot-foto.entity";
import { OtEvento, TipoEventoOT } from "./entities/ot-evento.entity";

type EnvioPosOt =
  | { resultado: "enviada"; factura: FacturaCreadaPos }
  | { resultado: "reparada" | "ya_facturada" | "no_terminada" }
  | { resultado: "no_aplica"; error: string }
  | { resultado: "error"; error: string; incierto?: boolean };

const ALLOWED_ESTADO_TRANSITIONS: Record<EstadoOT, EstadoOT[]> = {
  [EstadoOT.BORRADOR]: [EstadoOT.COTIZADA],
  [EstadoOT.COTIZADA]: [EstadoOT.APROBADA],
  [EstadoOT.APROBADA]: [EstadoOT.EN_PROCESO],
  [EstadoOT.EN_PROCESO]: [EstadoOT.TERMINADA],
  [EstadoOT.TERMINADA]: [EstadoOT.FACTURADA, EstadoOT.ENTREGADA],
  [EstadoOT.FACTURADA]: [EstadoOT.ENTREGADA],
  [EstadoOT.ENTREGADA]: [],
};

@Injectable()
export class OtsService {
  constructor(
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private mecanicosService: MecanicosService,
    private vehiculosService: VehiculosService,
    private facturacionPosService: FacturacionPosService,
    private tallerParametros: TallerParametrosService,
  ) {}

  private get otRepository(): Repository<OrdenTrabajo> {
    return this.tenantAccessor.repositoryFor(OrdenTrabajo);
  }

  private get cosaTraeRepository(): Repository<OtCosaTrae> {
    return this.tenantAccessor.repositoryFor(OtCosaTrae);
  }

  private get danoRepository(): Repository<OtDano> {
    return this.tenantAccessor.repositoryFor(OtDano);
  }

  private get fotoRepository(): Repository<OtFoto> {
    return this.tenantAccessor.repositoryFor(OtFoto);
  }

  private get firmaRepository(): Repository<OtFirma> {
    return this.tenantAccessor.repositoryFor(OtFirma);
  }

  private get eventoRepository(): Repository<OtEvento> {
    return this.tenantAccessor.repositoryFor(OtEvento);
  }

  private get otFacturacionRepository(): Repository<OtFacturacion> {
    return this.tenantAccessor.repositoryFor(OtFacturacion);
  }

  private get usuarioRepository(): Repository<Usuario> {
    return this.tenantAccessor.repositoryFor(Usuario);
  }

  private get clienteRepository(): Repository<ClienteLocal> {
    return this.tenantAccessor.repositoryFor(ClienteLocal);
  }

  async logEvento(
    otId: number,
    tipo: TipoEventoOT,
    descripcion: string,
    usuarioId?: number,
  ) {
    const evento = this.eventoRepository.create({
      otId,
      tipo,
      descripcion,
      usuarioId: usuarioId ?? null,
    });
    return this.eventoRepository.save(evento);
  }

  async getEventos(otId: number): Promise<Array<OtEvento & { usuario?: any }>> {
    const eventos = await this.eventoRepository.find({
      where: { otId },
      order: { createdAt: "DESC" },
    });

    const userIds = Array.from(
      new Set(
        eventos
          .map((evento) => evento.usuarioId)
          .filter(
            (id): id is number => id !== null && Number.isFinite(Number(id)),
          ),
      ),
    );

    if (!userIds.length) {
      return eventos;
    }

    const usuarios = await this.usuarioRepository.find({
      where: { id: In(userIds) },
      select: ["id", "username", "nombre", "rol"],
    });
    const usuariosById = new Map(
      usuarios.map((usuario) => [usuario.id, usuario]),
    );

    return eventos.map((evento) => ({
      ...evento,
      usuario: evento.usuarioId
        ? usuariosById.get(evento.usuarioId) || null
        : null,
    }));
  }

  async findAll(query: SearchOtDto, user?: any) {
    const {
      estado,
      search,
      clienteTarjeta,
      placa,
      fechaDesde,
      fechaHasta,
      tiendaId,
      page = 1,
      limit = 20,
    } = query;
    const skip = (page - 1) * limit;

    const queryBuilder = this.otRepository
      .createQueryBuilder("ot")
      .leftJoinAndSelect("ot.vehiculo", "vehiculo")
      .leftJoinAndSelect("vehiculo.tipoVehiculo", "tipoVehiculo")
      .leftJoinAndSelect("ot.mecanico", "mecanico")
      .leftJoinAndSelect("ot.tienda", "tienda")
      .leftJoin(
        ClienteLocal,
        "cliente",
        "cliente.tarjeta COLLATE utf8mb4_unicode_ci = ot.clienteTarjeta",
      );

    this.applyStoreScope(queryBuilder, user, tiendaId);
    this.applyEstadoScope(queryBuilder, user);

    if (estado) {
      queryBuilder.andWhere("ot.estado = :estado", { estado });
    }

    if (clienteTarjeta) {
      queryBuilder.andWhere("ot.clienteTarjeta = :clienteTarjeta", {
        clienteTarjeta,
      });
    }

    if (placa) {
      queryBuilder.andWhere("vehiculo.placa LIKE :placa", {
        placa: `%${placa}%`,
      });
    }

    if (search?.trim()) {
      const term = `%${search.trim()}%`;
      queryBuilder.andWhere(
        `(
          ot.numeroOt LIKE :term
          OR ot.clienteTarjeta LIKE :term
          OR vehiculo.placa LIKE :term
          OR cliente.nombre LIKE :term
          OR cliente.nit LIKE :term
          OR cliente.telefono LIKE :term
          OR cliente.celular LIKE :term
        )`,
        { term },
      );
    }

    if (fechaDesde) {
      queryBuilder.andWhere("ot.fechaEntrada >= :fechaDesde", { fechaDesde });
    }

    if (fechaHasta) {
      queryBuilder.andWhere("ot.fechaEntrada <= :fechaHasta", { fechaHasta });
    }

    queryBuilder.orderBy("ot.fechaEntrada", "DESC");
    queryBuilder.skip(skip).take(limit);

    const [data, total] = await queryBuilder.getManyAndCount();
    const clienteTarjetas = Array.from(
      new Set(data.map((ot) => ot.clienteTarjeta).filter(Boolean)),
    );
    const clientes = clienteTarjetas.length
      ? await this.clienteRepository.find({
          where: { tarjeta: In(clienteTarjetas) },
        })
      : [];
    const clientesByTarjeta = new Map(
      clientes.map((cliente) => [cliente.tarjeta, cliente]),
    );
    const rows = data.map((ot) =>
      ocultarCredencialesTienda({
        ...ot,
        cliente: clientesByTarjeta.get(ot.clienteTarjeta) || null,
      }),
    );

    const countBuilder = this.otRepository
      .createQueryBuilder("ot")
      .select("ot.estado", "estado")
      .addSelect("COUNT(*)", "count")
      .groupBy("ot.estado");
    this.applyStoreScope(countBuilder, user, tiendaId);
    this.applyEstadoScope(countBuilder, user);
    const byEstado = await countBuilder.getRawMany();

    const estadoCounts = {
      borrador: 0,
      en_proceso: 0,
      cotizada: 0,
      aprobada: 0,
      terminada: 0,
      entregada: 0,
    };

    byEstado.forEach((item) => {
      estadoCounts[item.estado as keyof typeof estadoCounts] = parseInt(
        item.count,
        10,
      );
    });

    return {
      data: rows,
      total,
      page,
      totalPages: Math.ceil(total / limit),
      byEstado: estadoCounts,
    };
  }

  async findById(id: number, user?: any): Promise<OrdenTrabajo> {
    const ot = await this.otRepository.findOne({
      where: { id },
      relations: [
        "vehiculo",
        "vehiculo.tipoVehiculo",
        "mecanico",
        "tienda",
        "cosasTrae",
        "cosasTrae.cosaTrae",
        "danos",
        "danos.tipoDano",
        "fotos",
        "firma",
        "cotizaciones",
        "cotizaciones.lineas",
      ],
    });

    if (!ot) {
      throw new NotFoundException(`OT ${id} no encontrada`);
    }

    this.ensureStoreAccess(ot, user);
    this.ensureEstadoAccess(ot, user);
    (ot as any).cliente =
      (await this.clienteRepository.findOne({
        where: { tarjeta: ot.clienteTarjeta },
      })) || null;

    return ocultarCredencialesTienda(ot);
  }

  async findByNumero(numeroOt: string, user?: any): Promise<OrdenTrabajo> {
    const ot = await this.otRepository.findOne({
      where: { numeroOt },
      relations: ["vehiculo", "vehiculo.tipoVehiculo", "tienda"],
    });

    if (!ot) {
      throw new NotFoundException(`OT ${numeroOt} no encontrada`);
    }

    this.ensureStoreAccess(ot, user);

    return ocultarCredencialesTienda(ot);
  }

  async create(
    dto: CreateOtDto,
    userId?: number,
    user?: any,
  ): Promise<OrdenTrabajo> {
    const actor = user || (await this.getUsuarioScope(userId));
    const requerimientoCliente = String(dto.requerimientoCliente ?? "").trim();

    if (!requerimientoCliente) {
      throw new BadRequestException("Registra el requerimiento del cliente");
    }

    let vehiculoId = dto.vehiculoId;

    if (!vehiculoId && dto.vehiculo) {
      const vehiculo = await this.vehiculosService.findOrCreate(
        dto.vehiculo,
        userId,
      );
      vehiculoId = vehiculo.id;
    }

    if (!vehiculoId) {
      throw new BadRequestException("Se requiere un vehículo");
    }

    // Apagado = el taller acepta varias OT abiertas sobre el mismo vehiculo
    // (p. ej. un trabajo largo mas una revision rapida).
    if (await this.tallerParametros.getBooleanConFallback("ot.una_ot_por_vehiculo")) {
      await this.ensureVehiculoDisponible(vehiculoId);
    }

    const numeroOt = await this.generateNumeroOt();
    const tiendaId = this.resolveTiendaIdForCreate(dto.tiendaId, actor);
    if (dto.mecanicoId) {
      await this.tallerParametros.exigirHabilitado("ot.usa_mecanicos");
    }
    const mecanico = dto.mecanicoId
      ? await this.mecanicosService.ensureAssignable(dto.mecanicoId, actor)
      : null;

    const ot = this.otRepository.create({
      numeroOt,
      clienteTarjeta: dto.clienteTarjeta,
      vehiculoId,
      tiendaId,
      mecanicoId: mecanico?.id ?? null,
      fechaEntrada: new Date(dto.fechaEntrada),
      km: dto.km,
      nivelGasolina: dto.nivelGasolina || 0,
      requerimientoCliente,
      observaciones: dto.observaciones?.trim() || undefined,
      localId: dto.localId,
      createdBy: userId,
      updatedBy: userId,
    });

    const savedOt = await this.otRepository.save(ot);

    const cosasTraeIds = this.normalizeCosasTrae(dto.cosasTrae);
    if (cosasTraeIds.length > 0) {
      await this.tallerParametros.exigirHabilitado("ot.usa_cosas_trae");
    }

    if (cosasTraeIds.length > 0) {
      const cosasTrae = cosasTraeIds.map((cosaId) =>
        this.cosaTraeRepository.create({
          otId: savedOt.id,
          cosaTraeId: cosaId,
        }),
      );
      await this.cosaTraeRepository.save(cosasTrae);
    }

    await this.logEvento(
      savedOt.id,
      TipoEventoOT.CREACION,
      `Recepción creada (Placa: ${dto.vehiculo?.placa || "Desconocida"})`,
      userId,
    );

    return this.findById(savedOt.id, actor);
  }

  async update(
    id: number,
    dto: UpdateOtDto,
    userId?: number,
    user?: any,
  ): Promise<OrdenTrabajo> {
    const actor = user || (await this.getUsuarioScope(userId));
    const ot = await this.findById(id, actor);
    this.ensureEditable(ot);

    if (dto.requerimientoCliente !== undefined) {
      const requerimientoCliente = String(dto.requerimientoCliente).trim();
      if (!requerimientoCliente) {
        throw new BadRequestException("Registra el requerimiento del cliente");
      }
      dto.requerimientoCliente = requerimientoCliente;
    }

    if (dto.observaciones !== undefined) {
      dto.observaciones = dto.observaciones?.trim() || undefined;
    }

    // Con mecanicos apagado un valor vacio NO desasigna al mecanico historico
    // de la OT: solo se rechaza intentar asignar uno.
    const usaMecanicos = await this.tallerParametros.getBooleanConFallback("ot.usa_mecanicos");
    if (dto.mecanicoId && !usaMecanicos) {
      await this.tallerParametros.exigirHabilitado("ot.usa_mecanicos");
    }

    if (dto.mecanicoId !== undefined && dto.mecanicoId !== null) {
      await this.mecanicosService.ensureAssignable(Number(dto.mecanicoId), actor);
    }

    Object.assign(ot, {
      ...dto,
      mecanicoId:
        dto.mecanicoId === undefined || !usaMecanicos
          ? ot.mecanicoId
          : dto.mecanicoId || null,
      fechaEntrada: dto.fechaEntrada
        ? new Date(dto.fechaEntrada)
        : ot.fechaEntrada,
      fechaSalida: dto.fechaSalida ? new Date(dto.fechaSalida) : ot.fechaSalida,
      version: ot.version + 1,
      updatedBy: userId,
    });

    await this.otRepository.save(ot);

    // Con "cosas que trae" apagado, [] no borra lo ya registrado (el FE puede
    // mandarlo vacio); solo se rechaza intentar guardar elementos nuevos.
    const cosasTraeSolicitadas = this.normalizeCosasTrae(dto.cosasTrae);
    if (cosasTraeSolicitadas.length > 0) {
      await this.tallerParametros.exigirHabilitado("ot.usa_cosas_trae");
    }
    const aplicarCosasTrae =
      dto.cosasTrae !== undefined &&
      (await this.tallerParametros.getBooleanConFallback("ot.usa_cosas_trae"));

    if (aplicarCosasTrae) {
      await this.cosaTraeRepository.delete({ otId: id });
      const cosasTraeIds = this.normalizeCosasTrae(dto.cosasTrae);

      if (cosasTraeIds.length > 0) {
        const cosasTrae = cosasTraeIds.map((cosaId) =>
          this.cosaTraeRepository.create({
            otId: id,
            cosaTraeId: cosaId,
          }),
        );
        await this.cosaTraeRepository.save(cosasTrae);
      }
    }

    return this.findById(id, actor);
  }

  async changeEstado(
    id: number,
    estado: EstadoOT,
    fechaSalida?: string,
    userId?: number,
    user?: any,
    entrega?: DatosEntrega,
  ): Promise<OrdenTrabajo> {
    if (estado === EstadoOT.FACTURADA) {
      throw new BadRequestException(
        "FACTURADA se asigna automaticamente al facturar en POSTouch, no se puede establecer manualmente",
      );
    }

    const actor = user || (await this.getUsuarioScope(userId));
    const ot = await this.findById(id, actor);
    const nextStates = ALLOWED_ESTADO_TRANSITIONS[ot.estado] || [];

    if (estado !== ot.estado && !nextStates.includes(estado)) {
      throw new BadRequestException(
        `Transicion invalida: ${ot.estado} -> ${estado}`,
      );
    }

    if (estado === EstadoOT.ENTREGADA && !fechaSalida && !ot.fechaSalida) {
      // Apagado = la fecha de salida se asigna sola (es la fecha en que se
      // registra la entrega) en vez de bloquear.
      if (await this.tallerParametros.getBooleanConFallback("ot.exige_fecha_salida")) {
        throw new BadRequestException(
          "Se requiere la fecha de salida para marcar como entregada",
        );
      }
      fechaSalida = new Date().toISOString();
    }

    if (estado === EstadoOT.ENTREGADA) {
      await this.ensureFacturadaYPagada(ot);
      await this.ensureReadyForDelivery(ot);
      await this.validarDatosEntrega(entrega);
    }

    // El boton "Marcar cotizada" del frontend no validaba nada antes de este
    // guard: se podia avanzar el estado sin haber creado ninguna cotizacion,
    // dejando la OT en "Cotizada" con el modulo de Cotizacion todavia en
    // "Pendiente" -- un estado inconsistente que confunde a quien vea la OT
    // despues (parece que ya se cotizo, pero no hay nada que mostrarle al
    // cliente ni que facturar).
    if (estado === EstadoOT.COTIZADA) {
      const tieneCotizacionConLineas = (ot.cotizaciones || []).some(
        (cotizacion) => (cotizacion.lineas || []).length > 0,
      );
      if (!tieneCotizacionConLineas) {
        throw new BadRequestException(
          "No se puede marcar como cotizada sin al menos una cotización con productos o servicios agregados.",
        );
      }
    }

    const estadoPrevio = ot.estado;
    ot.estado = estado;
    if (fechaSalida) {
      ot.fechaSalida = new Date(fechaSalida);
    }
    if (estado === EstadoOT.ENTREGADA && entrega) {
      this.aplicarDatosEntrega(ot, entrega);
    }
    ot.version = ot.version + 1;
    ot.updatedBy = userId ?? null;

    await this.otRepository.save(ot);

    if (estado === EstadoOT.ENTREGADA && entrega) {
      await this.guardarFirmaEntrega(ot.id, entrega);
    }

    await this.logEvento(
      id,
      TipoEventoOT.CAMBIO_ESTADO,
      `Estado cambiado a: ${estado}`,
      userId,
    );

    // Solo en la transicion real a TERMINADA: un PATCH terminada sobre una OT
    // que YA estaba terminada se saltaba la validacion de transicion y
    // reenviaba la orden a POSTouch (factura duplicada).
    if (estado === EstadoOT.TERMINADA && estadoPrevio !== EstadoOT.TERMINADA) {
      await this.intentarEnvioAutomaticoAPostouch(id, userId);
    }

    return this.findById(id, actor);
  }

  // Lo que la pantalla de la OT necesita para decidir que mostrar de la
  // facturacion POS. Devuelve SIEMPRE un objeto (nunca null): `aplica` dice si
  // esta OT se factura por POS en este taller, y los campos de la factura solo
  // vienen si ya se emitio. Reemplaza al "null si nunca se facturo" de antes,
  // que obligaba al frontend a adivinar si el taller usa POS o no.
  async getFacturacionPos(otId: number) {
    const ot = await this.otRepository.findOne({ where: { id: otId } });
    if (!ot) {
      throw new NotFoundException(`OT ${otId} no encontrada`);
    }

    const aplica = await this.facturacionPosService.facturacionAplica(ot.tiendaId);
    const exigePago =
      aplica &&
      (await this.tallerParametros.getBooleanConFallback("facturacion.exige_pago_para_entrega"));
    const factura = await this.otFacturacionRepository.findOne({
      where: { otId },
      order: { id: "DESC" },
    });

    // Cada vez que la pantalla refresca una OT facturada y sin pago se consulta
    // POSTouch: asi el pago se reconoce solo, sin que nadie tenga que
    // confirmarlo a mano ni correr un proceso aparte.
    const pagoPos =
      aplica && factura && ot.estado === EstadoOT.FACTURADA && !ot.pagoConfirmadoAt
        ? await this.detectarPagoEnPos(ot, factura)
        : null;

    return {
      aplica,
      exigePago,
      pagoConfirmado: Boolean(ot.pagoConfirmadoAt),
      pagoConfirmadoAt: ot.pagoConfirmadoAt ?? null,
      pagoDetectadoEnPos: Boolean(ot.pagoConfirmadoAt) && ot.pagoConfirmadoPor == null,
      pagoPos: pagoPos?.estado ?? null,
      pagoPosDetalle: pagoPos?.detalle ?? null,
      ...(factura
        ? {
            tienda: factura.tienda,
            caja: factura.caja,
            tipo: factura.tipo,
            serie: factura.serie,
            transac: factura.transac,
            estadoFel: factura.estadoFel,
            fechaCreacion: factura.fechaCreacion,
            fechaLiquidacion: factura.fechaLiquidacion,
          }
        : {}),
    };
  }

  // Hasta que exista el job que lee el pago desde POSTouch (hdetmed), una
  // persona con el permiso `confirmarPago` marca que el cliente ya pago. Es la
  // unica barrera contra entregar un vehiculo sin cobrar; cuando el job
  // exista llenara esta misma columna.
  async confirmarPago(otId: number, userId?: number, user?: any) {
    const actor = user || (await this.getUsuarioScope(userId));
    const ot = await this.findById(otId, actor);

    if (!(await this.facturacionPosService.facturacionAplica(ot.tiendaId))) {
      throw new BadRequestException(
        "Esta OT no se factura por POSTouch, no hay pago que confirmar.",
      );
    }
    if (ot.estado !== EstadoOT.FACTURADA) {
      throw new BadRequestException(
        `Solo se puede confirmar el pago de una OT facturada (esta en ${ot.estado}).`,
      );
    }
    // Idempotente: reconfirmar no mueve la fecha original.
    if (ot.pagoConfirmadoAt) {
      return ot;
    }

    const factura = await this.otFacturacionRepository.findOne({
      where: { otId },
      order: { id: "DESC" },
    });

    // Lo normal es que POSTouch ya diga que esta pagada: en ese caso se
    // reconoce desde hdetmed y esta accion manual queda como respaldo.
    const pagoPos = factura ? await this.detectarPagoEnPos(ot, factura) : null;
    if (pagoPos?.estado === "pagada") {
      return this.findById(otId, actor);
    }
    // Una factura anulada en POSTouch nunca se da por cobrada, ni a mano.
    if (pagoPos?.estado === "anulada") {
      throw new BadRequestException(
        "La factura está anulada en POSTouch: no se puede confirmar el pago.",
      );
    }

    // Respaldo manual (POSTouch inaccesible o aun "por liquidar"): queda en la
    // bitacora que fue manual y que decia POSTouch en ese momento.
    ot.pagoConfirmadoAt = new Date();
    ot.pagoConfirmadoPor = userId ?? null;
    ot.version = ot.version + 1;
    ot.updatedBy = userId ?? null;
    await this.otRepository.save(ot);

    await this.logEvento(
      otId,
      TipoEventoOT.OTRO,
      `Pago confirmado manualmente${factura ? ` (factura ${factura.tipo} ${factura.serie}-${factura.transac})` : ""}. POSTouch indicaba: ${pagoPos?.detalle ?? pagoPos?.estado ?? "sin consulta"}`,
      userId,
    );

    return this.findById(otId, actor);
  }

  // Boton "Reintentar factura" del frontend: el envio automatico solo se
  // dispara UNA vez, al pasar a TERMINADA (ver changeEstado) -- si POSTouch
  // no respondia en ese momento (red, servidor caido, PLU faltante ya
  // corregido despues), antes no habia forma de reintentar sin volver a
  // pasar la OT por todo el flujo de estados. Solo tiene sentido en
  // TERMINADA: en cualquier otro estado no hay nada que reintentar (ya se
  // facturo, o todavia no se termino).
  async reintentarFacturacionPos(otId: number, userId?: number) {
    const ot = await this.otRepository.findOne({ where: { id: otId } });
    if (!ot) {
      throw new NotFoundException(`OT ${otId} no encontrada`);
    }
    if (ot.estado !== EstadoOT.TERMINADA) {
      throw new BadRequestException(
        `Solo se puede reintentar la facturacion de una OT en estado TERMINADA (esta en ${ot.estado}).`,
      );
    }

    const envio = await this.intentarEnvioAutomaticoAPostouch(otId, userId);

    // Antes este metodo devolvia 200 con la OT aunque el envio hubiera
    // fallado y el frontend mostraba "Orden facturada" -- el error real solo
    // quedaba en la bitacora. Ahora el fallo llega como error HTTP con el
    // mensaje de POSTouch.
    if (envio.resultado === "error" || envio.resultado === "no_aplica") {
      throw new BadRequestException(envio.error);
    }
    return this.findById(otId);
  }

  // Envia la OT a POSTouch UNA sola vez, sin importar cuantas llamadas
  // lleguen a la vez (doble clic, reintento mientras corre el automatico):
  // la fila de la OT se bloquea dentro de una transaccion, y quien llega
  // segundo ve la factura ya registrada y no reenvia. Registrar la factura y
  // pasar la OT a FACTURADA ocurren en esa misma transaccion, asi que "POSTouch
  // ya la tiene" y "el taller lo sabe" no se pueden separar a medias.
  private async intentarEnvioAutomaticoAPostouch(
    otId: number,
    userId?: number,
  ): Promise<EnvioPosOt> {
    let facturaEnPos: FacturaCreadaPos | null = null;
    let envio: EnvioPosOt;

    try {
      envio = await this.tenantAccessor.runInTransaction(async (repos) => {
        const otRepo = repos.get(OrdenTrabajo);
        const ot = await otRepo.findOne({
          where: { id: otId },
          lock: { mode: "pessimistic_write" },
          loadEagerRelations: false,
        });
        if (!ot) {
          throw new NotFoundException(`OT ${otId} no encontrada`);
        }

        const existente = await repos.get(OtFacturacion).findOne({
          where: { otId },
          order: { id: "DESC" },
        });
        if (existente) {
          // La factura existe pero la OT quedo en TERMINADA (falla anterior
          // entre guardar la factura y mover el estado): se repara sin tocar POS.
          if (ot.estado === EstadoOT.TERMINADA) {
            ot.estado = EstadoOT.FACTURADA;
            ot.version = ot.version + 1;
            ot.updatedBy = userId ?? null;
            await otRepo.save(ot);
            return { resultado: "reparada" } as EnvioPosOt;
          }
          return { resultado: "ya_facturada" } as EnvioPosOt;
        }

        if (ot.estado !== EstadoOT.TERMINADA) {
          return { resultado: "no_terminada" } as EnvioPosOt;
        }

        const otParaFacturar = await this.buildOtParaFacturar(otId);
        const resultado = await this.facturacionPosService.enviarOrdenTerminada(otParaFacturar);

        if (!resultado.exito) {
          return {
            resultado: resultado.noAplica ? "no_aplica" : "error",
            error: resultado.error,
            incierto: resultado.incierto,
          } as EnvioPosOt;
        }

        facturaEnPos = resultado.factura;
        await repos.get(OtFacturacion).save({ otId, ...resultado.factura });
        ot.estado = EstadoOT.FACTURADA;
        ot.version = ot.version + 1;
        ot.updatedBy = userId ?? null;
        await otRepo.save(ot);
        return { resultado: "enviada", factura: resultado.factura } as EnvioPosOt;
      });
    } catch (error: any) {
      const detalle = error?.message ?? "Error desconocido preparando el envio a POSTouch";
      envio = facturaEnPos
        ? {
            resultado: "error",
            incierto: true,
            error: `La factura YA se creó en POSTouch (${this.describirFactura(facturaEnPos)}) pero no se pudo guardar en el taller: ${detalle}. NO reintentes: avisa a soporte para enlazarla a esta OT.`,
          }
        : { resultado: "error", error: detalle };
    }

    if (envio.resultado === "enviada") {
      await this.logEvento(
        otId,
        TipoEventoOT.OTRO,
        `Orden enviada automáticamente a POSTouch (pendiente de pago): ${this.describirFactura(envio.factura)}`,
        userId,
      );
    } else if (envio.resultado === "reparada") {
      await this.logEvento(
        otId,
        TipoEventoOT.OTRO,
        "La factura de POSTouch ya estaba registrada: se corrigió el estado de la OT a FACTURADA sin volver a enviar",
        userId,
      );
    } else if (envio.resultado === "error") {
      await this.logEvento(
        otId,
        TipoEventoOT.OTRO,
        `No se pudo enviar a POSTouch automáticamente: ${envio.error}`,
        userId,
      );
    }
    // no_aplica / ya_facturada / no_terminada: nada que registrar. Un taller
    // sin POS no debe llenar su bitacora de "errores" por cada OT terminada.

    return envio;
  }

  private describirFactura(factura: FacturaCreadaPos): string {
    return `tienda ${factura.tienda} caja ${factura.caja} ${factura.tipo} ${factura.serie}-${factura.transac}`;
  }

  private async buildOtParaFacturar(otId: number): Promise<OtParaFacturar> {
    const ot = await this.findById(otId);
    const cliente = (ot as any).cliente as ClienteLocal | null;

    const cotizacionAprobada = (ot.cotizaciones || []).find(
      (c) => c.estado === EstadoCotizacion.APROBADA,
    );

    if (!cotizacionAprobada) {
      throw new BadRequestException(
        `La OT ${ot.numeroOt} no tiene una cotizacion aprobada, no se puede facturar`,
      );
    }

    return {
      id: ot.id,
      numeroOt: ot.numeroOt,
      tiendaId: ot.tiendaId,
      clienteTarjeta: ot.clienteTarjeta,
      cliente: {
        nombre: cliente?.nombre || "CONSUMIDOR FINAL",
        nit: cliente?.nit || "CF",
        telefono: cliente?.telefono,
        email: cliente?.email,
        direccion: cliente?.direccion,
      },
      cotizacion: {
        lineas: cotizacionAprobada.lineas.map((linea) => {
          const subtotal = Number(linea.subtotal);
          const iva = Number(linea.iva);
          return {
            plu: linea.plu,
            descripcion: linea.descripcion,
            precioUnitario: Number(linea.precioUnitario),
            total: Number(linea.total),
            // La linea solo guarda montos (base e IVA, ya redondeados a centavos),
            // asi que la tasa se recupera redondeando al entero: las tasas de IVA
            // son enteras (12 %, 5 %, 0 %) y dividir montos redondeados da 11.99.
            ivaPorcentaje: subtotal > 0 ? Math.round((iva / subtotal) * 100) : 0,
            cantidad: Number(linea.cantidad),
            esServicio: linea.esServicio,
            // POSTouch rechaza PLUs que no existan en maeplu: tanto los
            // productos propios del taller (TLLP-) como las lineas manuales
            // (MANUAL-) necesitan su espejo antes de facturar.
            origenLocal: linea.plu.startsWith("TLLP-") || linea.plu.startsWith("MANUAL-"),
          };
        }),
      },
    };
  }

  async markCotizadaFromCotizacion(
    id: number,
    userId?: number | null,
  ): Promise<void> {
    await this.setEstadoFromCotizacion(
      id,
      EstadoOT.COTIZADA,
      "OT marcada como cotizada al enviar cotización al cliente",
      userId,
      [EstadoOT.BORRADOR],
    );
  }

  async markAprobadaFromCotizacion(
    id: number,
    userId?: number | null,
  ): Promise<void> {
    await this.setEstadoFromCotizacion(
      id,
      EstadoOT.APROBADA,
      "OT aprobada automáticamente por aprobación de cotización",
      userId,
      [EstadoOT.BORRADOR, EstadoOT.COTIZADA],
    );
  }

  private async setEstadoFromCotizacion(
    id: number,
    estado: EstadoOT,
    descripcion: string,
    userId: number | null | undefined,
    estadosPermitidos: EstadoOT[],
  ): Promise<void> {
    const ot = await this.otRepository.findOne({ where: { id } });

    if (!ot || ot.estado === estado || !estadosPermitidos.includes(ot.estado)) {
      return;
    }

    ot.estado = estado;
    ot.version = Number(ot.version || 1) + 1;
    ot.updatedBy = userId ?? null;

    await this.otRepository.save(ot);
    await this.logEvento(
      id,
      TipoEventoOT.CAMBIO_ESTADO,
      descripcion,
      userId ?? undefined,
    );
  }

  // DEUDA DE SEGURIDAD/CORRECTITUD (no arreglar aca — ver diagnostico 2(c):
  // no hay ninguna validacion de existencia de tipoDanoId contra
  // taller_tipos_dano; OtsService ni siquiera inyecta ese repositorio.
  // validateDano() solo chequea "es un entero positivo" — un id invalido
  // pero bien formado llega directo a danoRepository.save() mas abajo y
  // falla con un 500 crudo por la FK, no un 400 claro. Mismo patron en
  // create() (mas abajo, cosaTraeId contra taller_cosas_trae) y en
  // vehiculos.service.ts (tipoVehiculoId, VehiculosService no inyecta
  // TipoVehiculo). Misma familia de deuda que el seed de
  // superadmin/super123 (auth.service.ts) y la escritura masiva sin
  // verificacion de tenant en catalogos/seed (catalogos.controller.ts). Se
  // cierra cuando ots/catalogos se conviertan a tenant-aware — el arreglo es
  // validar existencia y tirar 400, nunca dejar que la FK hable.
  async addDano(
    otId: number,
    dto: CreateDanoDto,
    userId?: number,
    user?: any,
  ): Promise<OtDano> {
    await this.tallerParametros.exigirHabilitado("ot.usa_danos");
    const actor = user || (await this.getUsuarioScope(userId));
    const ot = await this.findById(otId, actor);
    this.ensureEditable(ot);
    this.validateDano(dto);

    const dano = this.danoRepository.create({
      otId,
      zona: dto.zona.trim(),
      tipoDanoId: dto.tipoDanoId,
      descripcion: dto.descripcion?.trim() || undefined,
      posicionX: dto.posicionX,
      posicionY: dto.posicionY,
      fotoUrl: dto.fotoUrl?.trim() || undefined,
    });

    const savedDano = await this.danoRepository.save(dano);
    await this.logEvento(
      otId,
      TipoEventoOT.EVIDENCIA_AGREGADA,
      `Daño registrado en ${dano.zona}`,
      userId,
    );

    return savedDano;
  }

  async removeDano(otId: number, danoId: number, user?: any): Promise<void> {
    await this.tallerParametros.exigirHabilitado("ot.usa_danos");
    const ot = await this.findById(otId, user);
    this.ensureEditable(ot);

    const result = await this.danoRepository.delete({ id: danoId, otId });
    if (result.affected === 0) {
      throw new NotFoundException(`Daño ${danoId} no encontrado en OT ${otId}`);
    }
  }

  async addFoto(
    otId: number,
    dto: CreateFotoDto,
    userId?: number,
    user?: any,
  ): Promise<OtFoto> {
    await this.tallerParametros.exigirHabilitado("ot.usa_fotos");
    const actor = user || (await this.getUsuarioScope(userId));
    const ot = await this.findById(otId, actor);
    this.ensureEditable(ot);
    this.validateFoto(dto);

    const foto = this.fotoRepository.create({
      otId,
      tipo: dto.tipo,
      url: dto.url.trim(),
      descripcion: dto.descripcion?.trim() || undefined,
    });

    const savedFoto = await this.fotoRepository.save(foto);
    await this.logEvento(
      otId,
      TipoEventoOT.EVIDENCIA_AGREGADA,
      `Evidencia agregada: ${foto.tipo}`,
      userId,
    );

    return savedFoto;
  }

  async removeFoto(otId: number, fotoId: number, user?: any): Promise<void> {
    await this.tallerParametros.exigirHabilitado("ot.usa_fotos");
    const ot = await this.findById(otId, user);
    this.ensureEditable(ot);

    const result = await this.fotoRepository.delete({ id: fotoId, otId });
    if (result.affected === 0) {
      throw new NotFoundException(`Foto ${fotoId} no encontrada en OT ${otId}`);
    }
  }

  async saveFirmas(
    otId: number,
    dto: CreateFirmaDto,
    userId?: number,
    user?: any,
  ): Promise<OtFirma> {
    const actor = user || (await this.getUsuarioScope(userId));
    const ot = await this.findById(otId, actor);
    this.ensureEditable(ot);

    // Cada firma se habilita por separado: un taller puede exigir solo la de
    // recepcion, solo la del cliente, o ninguna.
    if (dto.firmaCliente?.trim() || dto.nombreFirmaCliente !== undefined) {
      await this.tallerParametros.exigirHabilitado("ot.usa_firma_cliente");
    }
    if (dto.firmaRecepcion?.trim() || dto.nombreFirmaRecepcion !== undefined) {
      await this.tallerParametros.exigirHabilitado("ot.usa_firma_recepcion");
    }

    let firma = await this.firmaRepository.findOne({ where: { otId } });
    this.validateFirmas(dto, firma);

    if (!firma) {
      firma = this.firmaRepository.create({ otId });
    }

    if (dto.firmaCliente) {
      firma.firmaCliente = dto.firmaCliente.trim();
      firma.fechaFirmaCliente = new Date();
    }

    if (dto.nombreFirmaCliente !== undefined) {
      firma.nombreFirmaCliente = dto.nombreFirmaCliente?.trim() || null;
    }

    if (dto.firmaRecepcion) {
      firma.firmaRecepcion = dto.firmaRecepcion.trim();
      firma.fechaFirmaRecepcion = new Date();
    }

    if (dto.nombreFirmaRecepcion !== undefined) {
      firma.nombreFirmaRecepcion = dto.nombreFirmaRecepcion?.trim() || null;
    }

    const savedFirma = await this.firmaRepository.save(firma);
    await this.logEvento(
      otId,
      TipoEventoOT.FIRMA_CAPTURADA,
      "Firma capturada en la recepción",
      userId,
    );

    return savedFirma;
  }

  async savePdfEscaner(
    otId: number,
    pdfUrl: string,
    userId?: number,
    user?: any,
  ): Promise<OrdenTrabajo> {
    await this.tallerParametros.exigirHabilitado("ot.usa_pdf_escaner");
    const actor = user || (await this.getUsuarioScope(userId));
    const ot = await this.findById(otId, actor);
    this.ensureEditable(ot);
    const normalizedPdfUrl = String(pdfUrl ?? "").trim();

    if (!normalizedPdfUrl) {
      throw new BadRequestException("Selecciona el PDF del escaner.");
    }

    if (!this.hasAllowedFileExtension(normalizedPdfUrl, [".pdf"])) {
      throw new BadRequestException("El archivo del escaner debe ser PDF.");
    }

    ot.pdfEscaner = normalizedPdfUrl;
    ot.updatedBy = userId ?? null;
    const savedOt = await this.otRepository.save(ot);
    await this.logEvento(
      otId,
      TipoEventoOT.EVIDENCIA_AGREGADA,
      "PDF de escáner adjuntado",
      userId,
    );

    return savedOt;
  }

  private isAdmin(user?: any) {
    return user?.rol === UserRole.SUPER_ADMIN || user?.rol === UserRole.ADMIN;
  }

  private async getUsuarioScope(userId?: number) {
    if (!userId) {
      return null;
    }

    return this.usuarioRepository.findOne({
      where: { id: userId, activo: true },
      select: ["id", "username", "nombre", "rol", "tiendaId"],
    });
  }

  private applyStoreScope(
    queryBuilder: any,
    user?: any,
    requestedTiendaId?: number,
  ) {
    if (this.isAdmin(user)) {
      if (requestedTiendaId) {
        queryBuilder.andWhere("ot.tiendaId = :requestedTiendaId", {
          requestedTiendaId,
        });
      }
      return;
    }

    if (user?.tiendaId) {
      queryBuilder.andWhere("ot.tiendaId = :userTiendaId", {
        userTiendaId: user.tiendaId,
      });
      return;
    }

    if (user) {
      queryBuilder.andWhere("1 = 0");
    }
  }

  private ensureStoreAccess(ot: OrdenTrabajo, user?: any) {
    if (!user || this.isAdmin(user)) {
      return;
    }

    if (!user.tiendaId || Number(ot.tiendaId) !== Number(user.tiendaId)) {
      throw new NotFoundException(`OT ${ot.id} no encontrada`);
    }
  }

  // null/ausente en estadosVisibles = sin restriccion (ve todos los
  // estados, comportamiento de siempre) -- polaridad opuesta a tiendaId
  // arriba a propósito: es un filtro opt-in que un admin activa por
  // usuario, no algo que todo el mundo tenga por default. NotFoundException
  // (no Forbidden), mismo criterio que ensureStoreAccess: no revela que la
  // OT existe si el estado actual esta fuera de lo permitido.
  private applyEstadoScope(queryBuilder: any, user?: any) {
    const restriccion = user?.estadosVisibles;
    if (!Array.isArray(restriccion) || this.isAdmin(user)) {
      return;
    }
    queryBuilder.andWhere("ot.estado IN (:...estadosVisibles)", {
      estadosVisibles: restriccion.length > 0 ? restriccion : ["__ninguno__"],
    });
  }

  private ensureEstadoAccess(ot: OrdenTrabajo, user?: any) {
    const restriccion = user?.estadosVisibles;
    if (!user || this.isAdmin(user) || !Array.isArray(restriccion)) {
      return;
    }
    if (!restriccion.includes(ot.estado)) {
      throw new NotFoundException(`OT ${ot.id} no encontrada`);
    }
  }

  private resolveTiendaIdForCreate(dtoTiendaId?: number, user?: any) {
    if (this.isAdmin(user)) {
      return dtoTiendaId ?? user?.tiendaId ?? null;
    }

    return user?.tiendaId ?? null;
  }

  private async generateNumeroOt(): Promise<string> {
    const today = new Date();
    const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, "");

    const lastOt = await this.otRepository
      .createQueryBuilder("ot")
      .where("ot.numeroOt LIKE :prefix", { prefix: `OT-${datePrefix}-%` })
      .orderBy("ot.numeroOt", "DESC")
      .getOne();

    let sequence = 1;

    if (lastOt) {
      const match = lastOt.numeroOt.match(/OT-\d{8}-(\d+)/);
      if (match) {
        sequence = parseInt(match[1], 10) + 1;
      }
    }

    return `OT-${datePrefix}-${sequence.toString().padStart(4, "0")}`;
  }

  private async ensureVehiculoDisponible(vehiculoId: number): Promise<void> {
    const activeOt = await this.otRepository
      .createQueryBuilder("ot")
      .leftJoinAndSelect("ot.vehiculo", "vehiculo")
      .where("ot.vehiculoId = :vehiculoId", { vehiculoId })
      .andWhere("ot.estado != :entregada", { entregada: EstadoOT.ENTREGADA })
      .orderBy("ot.fechaEntrada", "DESC")
      .getOne();

    if (activeOt) {
      const placa = activeOt.vehiculo?.placa
        ? ` (${activeOt.vehiculo.placa})`
        : "";
      throw new BadRequestException(
        `Este vehiculo${placa} ya tiene una orden activa: ${activeOt.numeroOt}`,
      );
    }
  }

  private ensureEditable(ot: OrdenTrabajo): void {
    if (ot.estado === EstadoOT.ENTREGADA) {
      throw new BadRequestException("No se puede modificar una OT entregada");
    }
  }

  private normalizeCosasTrae(cosasTrae?: number[]): number[] {
    if (!cosasTrae?.length) {
      return [];
    }

    const normalized = cosasTrae
      .map((item) => Number(item))
      .filter((item) => Number.isInteger(item) && item > 0);

    return [...new Set(normalized)];
  }

  // Consulta el pago de la factura en POSTouch (solo lectura de hdetmed) y, si
  // ya esta liquidada, lo registra en la OT. Nunca lanza: un POSTouch
  // inaccesible o con un esquema distinto no puede romper la pantalla de la OT,
  // solo deja el estado como "desconocido" y el respaldo manual disponible.
  private async detectarPagoEnPos(
    ot: OrdenTrabajo,
    factura: OtFacturacion,
  ): Promise<{ estado: ConsultaPagoPos["estado"] | "desconocido"; detalle?: string }> {
    let consulta: ConsultaPagoPos;
    try {
      consulta = await this.facturacionPosService.consultarPago(factura);
    } catch (error: any) {
      return {
        estado: "desconocido",
        detalle: `No se pudo consultar el pago en POSTouch: ${error?.message ?? error}`,
      };
    }

    if (consulta.estado === "pagada" && !ot.pagoConfirmadoAt) {
      // Condicional (solo si sigue sin confirmar): dos refrescos simultaneos de
      // la pantalla no deben duplicar la fecha ni el evento de bitacora.
      const ahora = new Date();
      const resultado = await this.otRepository.update(
        { id: ot.id, pagoConfirmadoAt: IsNull() },
        { pagoConfirmadoAt: ahora, pagoConfirmadoPor: null },
      );
      ot.pagoConfirmadoAt = ahora;
      ot.pagoConfirmadoPor = null;

      if (resultado?.affected) {
        await this.otFacturacionRepository.update({ id: factura.id }, { fechaLiquidacion: ahora });
        await this.logEvento(
          ot.id,
          TipoEventoOT.OTRO,
          `Pago detectado en POSTouch (factura ${factura.tipo} ${factura.serie}-${factura.transac}, Q${consulta.pagado.toFixed(2)})`,
        );
      }
    }

    const detalles: Record<string, string> = {
      pagada: "Pagada en POSTouch",
      pendiente: "Pendiente de liquidar en POSTouch",
      anulada: "Factura anulada en POSTouch",
      no_encontrada: "No se encontró la factura en las tablas de POSTouch",
    };
    return { estado: consulta.estado, detalle: detalles[consulta.estado] };
  }

  // Con facturacion POS aplicable la entrega solo sale de FACTURADA (si no,
  // se podia entregar un vehiculo que nunca llego a POSTouch) y, mientras
  // `facturacion.exige_pago_para_entrega` este encendido, con el pago
  // confirmado por una persona. Sin POS la regla no existe.
  private async ensureFacturadaYPagada(ot: OrdenTrabajo): Promise<void> {
    if (!(await this.facturacionPosService.facturacionAplica(ot.tiendaId))) {
      return;
    }
    if (ot.estado !== EstadoOT.FACTURADA) {
      throw new BadRequestException(
        "Esta OT debe facturarse en POSTouch antes de entregar el vehículo. Usa «Reintentar factura» si el envío falló.",
      );
    }
    if (
      (await this.tallerParametros.getBooleanConFallback("facturacion.exige_pago_para_entrega")) &&
      !ot.pagoConfirmadoAt
    ) {
      // Antes de rechazar se pregunta a POSTouch: si el cliente ya pago en caja
      // no hay que pedirle a nadie que lo confirme.
      const factura = await this.otFacturacionRepository.findOne({
        where: { otId: ot.id },
        order: { id: "DESC" },
      });
      const pagoPos = factura ? await this.detectarPagoEnPos(ot, factura) : null;
      if (ot.pagoConfirmadoAt) return;

      throw new BadRequestException(
        pagoPos?.estado === "anulada"
          ? "La factura está anulada en POSTouch: no se puede entregar el vehículo."
          : pagoPos?.estado === "pendiente"
            ? "El pago de esta OT sigue pendiente de liquidar en POSTouch. Cóbralo en caja antes de entregar el vehículo."
            : pagoPos?.estado === "desconocido" || pagoPos?.estado === "no_encontrada"
              ? `No se pudo verificar el pago en POSTouch (${pagoPos.detalle}). Un administrador puede confirmarlo manualmente.`
              : "Falta confirmar el pago del cliente antes de entregar el vehículo.",
      );
    }
  }

  private async ensureReadyForDelivery(ot: OrdenTrabajo): Promise<void> {
    const [exigeFirmaCliente, exigeFirmaRecepcion] = await Promise.all([
      this.tallerParametros.getBooleanConFallback("ot.usa_firma_cliente"),
      this.tallerParametros.getBooleanConFallback("ot.usa_firma_recepcion"),
    ]);

    if (exigeFirmaCliente && !ot.firma?.firmaCliente) {
      throw new BadRequestException(
        "Captura la firma del cliente antes de entregar el vehículo.",
      );
    }

    if (exigeFirmaRecepcion && !ot.firma?.firmaRecepcion) {
      throw new BadRequestException(
        "Captura la firma de recepción antes de entregar el vehículo.",
      );
    }
  }

  // Los datos de la entrega son opcionales para el backend (el sync offline y
  // los clientes viejos entregan sin ellos); la pantalla es la que los pide.
  // Lo que si se valida es que, cuando llegan, tengan sentido.
  private async validarDatosEntrega(entrega?: DatosEntrega): Promise<void> {
    if (!entrega) return;

    if (entrega.firmaEntrega?.trim()) {
      await this.tallerParametros.exigirHabilitado("ot.usa_firma_recepcion");
      this.validateImageUrl(entrega.firmaEntrega, "La firma de quien entrega");
      if (!entrega.entregadoPor?.trim()) {
        throw new BadRequestException(
          "Escribe el nombre de quien entrega el vehículo.",
        );
      }
    }
  }

  private aplicarDatosEntrega(ot: OrdenTrabajo, entrega: DatosEntrega): void {
    if (entrega.kmSalida !== undefined) {
      ot.kmSalida = entrega.kmSalida;
    }
    if (entrega.nivelGasolinaSalida !== undefined) {
      ot.nivelGasolinaSalida = entrega.nivelGasolinaSalida;
    }
    if (entrega.recomendacionesEntrega !== undefined) {
      ot.recomendacionesEntrega = entrega.recomendacionesEntrega.trim() || null;
    }
  }

  // Nombre y firma de quien entrega viven con las demas firmas de la OT, en
  // su propia columna: no pisan la firma de recepcion.
  private async guardarFirmaEntrega(
    otId: number,
    entrega: DatosEntrega,
  ): Promise<void> {
    const nombre = entrega.entregadoPor?.trim();
    const firmaUrl = entrega.firmaEntrega?.trim();
    if (!nombre && !firmaUrl) return;

    const firma =
      (await this.firmaRepository.findOne({ where: { otId } })) ??
      this.firmaRepository.create({ otId });

    if (nombre) {
      firma.nombreFirmaEntrega = nombre;
    }
    if (firmaUrl) {
      firma.firmaEntrega = firmaUrl;
      firma.fechaFirmaEntrega = new Date();
    }

    await this.firmaRepository.save(firma);
  }

  private validateFirmas(dto: CreateFirmaDto, current?: OtFirma | null): void {
    const hasNewClienteFirma = Boolean(dto.firmaCliente?.trim());
    const hasNewRecepcionFirma = Boolean(dto.firmaRecepcion?.trim());
    const hasClienteName = dto.nombreFirmaCliente !== undefined;
    const hasRecepcionName = dto.nombreFirmaRecepcion !== undefined;
    const hasAnyInput =
      hasNewClienteFirma ||
      hasNewRecepcionFirma ||
      hasClienteName ||
      hasRecepcionName;

    if (!hasAnyInput) {
      throw new BadRequestException(
        "Dibuja al menos una firma antes de guardar.",
      );
    }

    if (hasNewClienteFirma) {
      this.validateImageUrl(dto.firmaCliente, "La firma del cliente");
      if (!dto.nombreFirmaCliente?.trim()) {
        throw new BadRequestException(
          "Escribe el nombre de quien firma como cliente.",
        );
      }
    }

    if (hasNewRecepcionFirma) {
      this.validateImageUrl(dto.firmaRecepcion, "La firma de recepción");
      if (!dto.nombreFirmaRecepcion?.trim()) {
        throw new BadRequestException(
          "Escribe el nombre de quien firma por recepción.",
        );
      }
    }

    if (hasClienteName && !hasNewClienteFirma && !current?.firmaCliente) {
      throw new BadRequestException(
        "Dibuja la firma del cliente antes de guardar su nombre.",
      );
    }

    if (hasRecepcionName && !hasNewRecepcionFirma && !current?.firmaRecepcion) {
      throw new BadRequestException(
        "Dibuja la firma de recepción antes de guardar su nombre.",
      );
    }
  }

  private validateDano(dto: CreateDanoDto): void {
    if (!String(dto.zona ?? "").trim()) {
      throw new BadRequestException("Selecciona la zona del daño.");
    }

    if (!Number.isInteger(dto.tipoDanoId) || dto.tipoDanoId <= 0) {
      throw new BadRequestException("Selecciona el tipo de daño.");
    }

    this.validatePercentage(dto.posicionX, "posicion X");
    this.validatePercentage(dto.posicionY, "posicion Y");

    if (
      dto.fotoUrl &&
      !this.hasAllowedFileExtension(dto.fotoUrl, [
        ".jpg",
        ".jpeg",
        ".png",
        ".webp",
        ".gif",
      ])
    ) {
      throw new BadRequestException("La foto del daño debe ser una imagen.");
    }
  }

  private validateFoto(dto: CreateFotoDto): void {
    if (!String(dto.url ?? "").trim()) {
      throw new BadRequestException("Selecciona una foto para guardar.");
    }

    if (
      !this.hasAllowedFileExtension(dto.url, [
        ".jpg",
        ".jpeg",
        ".png",
        ".webp",
        ".gif",
      ])
    ) {
      throw new BadRequestException("La evidencia debe ser una imagen valida.");
    }
  }

  private validateImageUrl(url: string | undefined, label: string): void {
    if (
      !url ||
      !this.hasAllowedFileExtension(url, [
        ".jpg",
        ".jpeg",
        ".png",
        ".webp",
        ".gif",
      ])
    ) {
      throw new BadRequestException(`${label} debe ser una imagen valida.`);
    }
  }

  private validatePercentage(value: number | undefined, label: string): void {
    if (value === undefined || value === null) {
      return;
    }

    const numericValue = Number(value);
    if (
      !Number.isFinite(numericValue) ||
      numericValue < 0 ||
      numericValue > 100
    ) {
      throw new BadRequestException(`La ${label} debe estar entre 0 y 100.`);
    }
  }

  private hasAllowedFileExtension(url: string, allowedExtensions: string[]) {
    const cleanUrl = String(url)
      .trim()
      .split("?")[0]
      .split("#")[0]
      .toLowerCase();
    return allowedExtensions.some((extension) => cleanUrl.endsWith(extension));
  }
}
