import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  NotFoundException,
} from "@nestjs/common";
import { Repository } from "typeorm";
import { v4 as uuidv4 } from "uuid";
import { ProductosService } from "../productos/productos.service";
import { TallerParametrosService } from "../taller-parametros/taller-parametros.service";
import { TenantRepositoryAccessor } from "../tenancy/tenant-repository.accessor";
import { AddLineaDto } from "./dto/add-linea.dto";
import { CreateCotizacionDto } from "./dto/create-cotizacion.dto";
import { DeleteLineaDto } from "./dto/delete-linea.dto";
import { PublicDecisionDto } from "./dto/public-decision.dto";
import { UpdateLineaDto } from "./dto/update-linea.dto";
import { CotizacionLinea } from "./entities/cotizacion-linea.entity";
import { CotizacionLock } from "./entities/cotizacion-lock.entity";
import { CotizacionVersion } from "./entities/cotizacion-version.entity";
import { Cotizacion, EstadoCotizacion } from "./entities/cotizacion.entity";
import { EstadoOT } from "../ots/entities/orden-trabajo.entity";
import { OtsService } from "../ots/ots.service";
import { TipoEventoOT } from "../ots/entities/ot-evento.entity";

type CotizacionUser = {
  id?: number;
  username?: string;
  nombre?: string;
  rol?: string;
  tiendaId?: number | null;
  permissions?: Record<string, "none" | "read" | "write">;
};

@Injectable()
export class CotizacionesService {
  private readonly lockTtlMs = 15 * 60 * 1000;

  constructor(
    private readonly tenantAccessor: TenantRepositoryAccessor,
    private productosService: ProductosService,
    private otsService: OtsService,
    private tallerParametros: TallerParametrosService,
  ) {}

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

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

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

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

  async findByOt(
    otId: number,
    user?: CotizacionUser,
  ): Promise<Cotizacion | null> {
    await this.otsService.findById(otId, user);
    return this.cotizacionRepository.findOne({
      where: { otId },
      relations: ["lineas"],
    });
  }

  async findById(id: number, user?: CotizacionUser): Promise<Cotizacion> {
    const cotizacion = await this.cotizacionRepository.findOne({
      where: { id },
      relations: ["lineas", "ordenTrabajo", "ordenTrabajo.mecanico"],
    });

    if (!cotizacion) {
      throw new NotFoundException(`Cotizacion ${id} no encontrada`);
    }

    if (user) {
      await this.otsService.findById(cotizacion.otId, user);
    }

    return cotizacion;
  }

  async create(
    dto: CreateCotizacionDto,
    user?: CotizacionUser,
  ): Promise<Cotizacion> {
    const existing = await this.findByOt(dto.otId, user);
    if (existing) {
      return existing;
    }

    const numeroCotizacion = await this.generateNumeroCotizacion();

    const cotizacion = this.cotizacionRepository.create({
      otId: dto.otId,
      numeroCotizacion,
      notas: dto.notas,
      localId: dto.localId,
      asesorId: user?.id ?? null,
      asesorNombre: this.getUserDisplayName(user),
    });

    return this.cotizacionRepository.save(cotizacion);
  }

  async createPublicLink(
    cotizacionId: number,
    userId?: number,
    user?: CotizacionUser,
  ) {
    // Antes de tocar nada: este metodo cambia la cotizacion a ENVIADA y la OT
    // a COTIZADA, asi que con la funcion apagada no puede dejar medio hecho.
    await this.tallerParametros.exigirHabilitado("cotizacion.usa_link_publico");
    const cotizacion = await this.findById(cotizacionId, user);

    if (!cotizacion.lineas?.length) {
      throw new BadRequestException(
        "No se puede enviar una cotizacion sin lineas",
      );
    }

    if (cotizacion.estado === EstadoCotizacion.APROBADA) {
      throw new BadRequestException(
        "La cotización ya fue respondida por el cliente",
      );
    }

    if (cotizacion.estado === EstadoCotizacion.RECHAZADA) {
      this.resetRejectedDecision(cotizacion, false);
    }

    cotizacion.publicToken = uuidv4().replaceAll("-", "");
    cotizacion.publicExpiresAt = new Date(
      Date.now() + 30 * 24 * 60 * 60 * 1000,
    );
    cotizacion.asesorId = user?.id ?? cotizacion.asesorId ?? null;
    cotizacion.asesorNombre =
      this.getUserDisplayName(user) || cotizacion.asesorNombre || null;
    cotizacion.estado = EstadoCotizacion.ENVIADA;
    cotizacion.version = Number(cotizacion.version || 1) + 1;

    const saved = await this.cotizacionRepository.save(cotizacion);
    await this.otsService.markCotizadaFromCotizacion(saved.otId, userId);
    await this.saveVersionSnapshot(
      saved.id,
      "enviada",
      "Link publico generado",
      userId,
    );
    await this.otsService.logEvento(
      saved.otId,
      TipoEventoOT.OTRO,
      `Cotización ${saved.numeroCotizacion || saved.id} enviada al cliente por link público`,
      userId,
    );

    // El token que se le da al CLIENTE lleva el tenant embebido
    // ("<tenantId>.<publicToken>") — nunca se guarda asi en la columna
    // (publicToken sigue siendo el uuid solo, ver findPublicCotizacion() mas
    // abajo, que lo recibe YA SIN el prefijo porque PublicTenantGuard lo
    // saca antes de que este metodo se llame). Es lo unico que hace falta
    // para que las rutas publicas (sin JWT) sepan a que tenant conectarse.
    const tenantPrefix = this.tenantAccessor.getCurrentTenantId();
    const publicToken = `${tenantPrefix}.${saved.publicToken}`;
    return {
      token: publicToken,
      expiresAt: saved.publicExpiresAt,
      publicPath: `/cotizacion/${publicToken}`,
    };
  }

  async findPublicByToken(token: string) {
    const cotizacion = await this.findPublicCotizacion(token);
    return this.vistaPublica(cotizacion);
  }

  async decidePublic(token: string, dto: PublicDecisionDto) {
    const cotizacion = await this.findPublicCotizacion(token);
    const nombre = String(dto.nombre || "").trim();
    const firma = String(dto.firma || "").trim();
    const motivo = String(dto.motivo || "").trim();

    if (
      [EstadoCotizacion.APROBADA, EstadoCotizacion.RECHAZADA].includes(
        cotizacion.estado,
      )
    ) {
      throw new BadRequestException("La cotización ya fue respondida");
    }

    if (!nombre)
      throw new BadRequestException("Escribe el nombre de quien firma");
    this.validatePublicSignature(firma);
    if (dto.decision === "rechazar" && !motivo) {
      throw new BadRequestException("Indica el motivo del rechazo");
    }

    cotizacion.estado =
      dto.decision === "aceptar"
        ? EstadoCotizacion.APROBADA
        : EstadoCotizacion.RECHAZADA;
    cotizacion.decisionClienteAt = new Date();
    cotizacion.decisionClienteNombre = nombre;
    cotizacion.decisionClienteFirma = firma;
    cotizacion.decisionClienteMotivo = motivo || null;

    if (dto.decision === "aceptar") {
      cotizacion.fechaAprobacion = cotizacion.decisionClienteAt;
      cotizacion.aprobadoPor = nombre;
    }

    cotizacion.version = Number(cotizacion.version || 1) + 1;
    const saved = await this.cotizacionRepository.save(cotizacion);
    if (saved.estado === EstadoCotizacion.APROBADA) {
      await this.otsService.markAprobadaFromCotizacion(saved.otId, null);
    }
    await this.saveVersionSnapshot(
      saved.id,
      saved.estado,
      motivo ||
        `Cliente ${dto.decision === "aceptar" ? "acepto" : "rechazo"} la cotizacion`,
      null,
    );
    await this.lockRepository.delete({ cotizacionId: saved.id });
    await this.otsService.logEvento(
      saved.otId,
      dto.decision === "aceptar"
        ? TipoEventoOT.COTIZACION_APROBADA
        : TipoEventoOT.OTRO,
      `Cliente ${nombre} ${dto.decision === "aceptar" ? "aceptó" : "rechazó"} la cotización ${saved.numeroCotizacion || saved.id}${motivo ? ` | Motivo: ${motivo}` : ""}`,
    );

    return this.vistaPublica(saved);
  }

  async listVersions(cotizacionId: number, user?: CotizacionUser) {
    await this.findById(cotizacionId, user);
    return this.versionRepository.find({
      where: { cotizacionId },
      order: { createdAt: "DESC" },
    });
  }

  async getLockStatus(cotizacionId: number, user?: CotizacionUser) {
    await this.findById(cotizacionId, user);
    const lock = await this.getActiveLock(cotizacionId);

    if (!lock) {
      return { locked: false, ownedByCurrentUser: false };
    }

    return {
      locked: true,
      ownedByCurrentUser: lock.usuarioId === user?.id,
      usuarioId: lock.usuarioId,
      usuarioNombre: lock.usuarioNombre,
      expiresAt: lock.expiresAt,
    };
  }

  async acquireLock(cotizacionId: number, user: CotizacionUser) {
    const cotizacion = await this.findById(cotizacionId, user);
    this.ensureCotizacionEditable(cotizacion);
    this.ensureUserIdentified(user);

    const existing = await this.lockRepository.findOne({
      where: { cotizacionId },
    });
    const now = new Date();
    const expiresAt = new Date(now.getTime() + this.lockTtlMs);

    if (
      existing &&
      existing.expiresAt > now &&
      existing.usuarioId !== user.id
    ) {
      throw new ConflictException(
        `Cotizacion bloqueada por ${existing.usuarioNombre || `Usuario ${existing.usuarioId}`}`,
      );
    }

    const lock = existing || this.lockRepository.create({ cotizacionId });
    lock.token = uuidv4();
    lock.usuarioId = Number(user.id);
    lock.usuarioNombre = user.username || `Usuario ${user.id}`;
    lock.expiresAt = expiresAt;

    const savedLock = await this.lockRepository.save(lock);
    return this.toLockResponse(savedLock);
  }

  async renewLock(
    cotizacionId: number,
    lockToken: string,
    user: CotizacionUser,
  ) {
    await this.findById(cotizacionId, user);
    const lock = await this.assertLock(cotizacionId, lockToken, user);
    lock.expiresAt = new Date(Date.now() + this.lockTtlMs);
    const savedLock = await this.lockRepository.save(lock);
    return this.toLockResponse(savedLock);
  }

  async releaseLock(
    cotizacionId: number,
    lockToken: string,
    user: CotizacionUser,
  ) {
    await this.findById(cotizacionId, user);
    const lock = await this.lockRepository.findOne({
      where: { cotizacionId, token: lockToken },
    });

    if (lock && lock.usuarioId === user?.id) {
      await this.lockRepository.delete({ id: lock.id });
    }

    return { released: true };
  }

  async addLinea(
    cotizacionId: number,
    dto: AddLineaDto,
    user?: CotizacionUser | number,
  ): Promise<{
    linea: CotizacionLinea;
    duplicado: boolean;
    mensaje: string;
  }> {
    const actor = typeof user === "number" ? { id: user } : user;
    const cotizacion = await this.findById(cotizacionId, actor);
    this.ensureLineasEditables(cotizacion);
    await this.assertLock(cotizacionId, dto.lockToken, actor);
    const wasRejected = cotizacion.estado === EstadoCotizacion.RECHAZADA;

    if (
      cotizacion.ordenTrabajo?.estado === EstadoOT.TERMINADA ||
      // FACTURADA: la venta ya existe en POSTouch; cambiar lineas ahora
      // desincronizaria la cotizacion de la factura.
      cotizacion.ordenTrabajo?.estado === EstadoOT.FACTURADA ||
      cotizacion.ordenTrabajo?.estado === EstadoOT.ENTREGADA
    ) {
      throw new BadRequestException(
        "No se pueden agregar lineas a una OT que ya esta terminada, facturada o entregada",
      );
    }

    const cantidad = Number(dto.cantidad ?? 1);

    if (!Number.isFinite(cantidad) || cantidad <= 0) {
      throw new BadRequestException("La cantidad debe ser mayor a cero");
    }

    const descripcionManual = String(dto.descripcion || "").trim();

    if (!dto.plu && descripcionManual) {
      // "Producto por ingresar": un precio inventado ahi mismo, sin pasar por
      // el catalogo -- a diferencia de agregar una linea con `plu` (que sale
      // de un producto ya existente y su precio ya revisado), esto le da a
      // quien cotiza la posibilidad de facturar cualquier monto sin ningun
      // control. `cotizaciones:write` ya no alcanza para esto -- se exige el
      // permiso puntual `productoLibre`, off por defecto salvo admin/super
      // admin (ver DEFAULT_PERMISSIONS_BY_ROLE en auth.service.ts y
      // permission.guard.ts).
      const nivelProductoLibre = actor?.permissions?.productoLibre || "none";
      if (nivelProductoLibre !== "write") {
        throw new ForbiddenException(
          "No tenes permiso para agregar un producto por ingresar (precio manual). Pedile a un administrador que te lo habilite.",
        );
      }

      const precioFinalUnitario = Number(dto.precioUnitarioConIva ?? 0);

      if (!Number.isFinite(precioFinalUnitario) || precioFinalUnitario < 0) {
        throw new BadRequestException("El precio de la línea manual no es válido");
      }

      const taxSplit = this.splitTaxIncludedUnitPrice(
        precioFinalUnitario,
        true,
        12,
      );
      const subtotal = taxSplit.baseUnitPrice * cantidad;
      const iva = taxSplit.taxUnitAmount * cantidad;
      const total = precioFinalUnitario * cantidad;
      const manualPlu = `MANUAL-${Date.now().toString(36).toUpperCase()}`;

      const linea = this.lineaRepository.create({
        cotizacionId,
        plu: manualPlu,
        descripcion: descripcionManual,
        precioUnitario: taxSplit.baseUnitPrice,
        cantidad,
        subtotal,
        iva,
        total,
        esServicio: Boolean(dto.esServicio),
        orden: cotizacion.lineas ? cotizacion.lineas.length : 0,
      });

      const savedLinea = await this.lineaRepository.save(linea);
      if (wasRejected) {
        this.resetRejectedDecision(cotizacion);
        await this.cotizacionRepository.save(cotizacion);
      }

      await this.recalculateTotals(cotizacionId);
      await this.otsService.logEvento(
        cotizacion.otId,
        TipoEventoOT.PRODUCTO_AGREGADO,
        `Línea manual agregada: ${descripcionManual} (Cant: ${cantidad})`,
        actor?.id,
      );

      return {
        linea: savedLinea,
        duplicado: false,
        mensaje:
          "Línea manual agregada a esta cotización. No se guardó en tu catálogo de Productos.",
      };
    }

    if (!dto.plu) {
      throw new BadRequestException("Selecciona un producto o escribe una descripción manual");
    }

    const producto = await this.productosService.findByPlu(dto.plu);

    // Check for existing line with same PLU — merge quantity instead of duplicating
    const existingLine = cotizacion.lineas?.find((l) => l.plu === producto.plu);

    if (existingLine) {
      const newCantidad = Number(existingLine.cantidad) + cantidad;
      const precioUnitario = Number(existingLine.precioUnitario);
      const subtotal = precioUnitario * newCantidad;
      const previousSubtotal = Number(existingLine.subtotal || 0);
      const previousIva = Number(existingLine.iva || 0);
      const ivaRate = previousSubtotal > 0 ? previousIva / previousSubtotal : 0;
      const iva = subtotal * ivaRate;
      const total = subtotal + iva;
      await this.ensureSaleNotBelowCost(
        producto.plu,
        total / newCantidad,
        producto.es_servicio,
      );

      existingLine.cantidad = newCantidad;
      existingLine.subtotal = subtotal;
      existingLine.iva = iva;
      existingLine.total = total;
      if (wasRejected) {
        this.resetRejectedDecision(cotizacion);
        await this.cotizacionRepository.save(cotizacion);
      }

      const savedLinea = await this.lineaRepository.save(existingLine);
      await this.recalculateTotals(cotizacionId);
      await this.otsService.logEvento(
        cotizacion.otId,
        TipoEventoOT.PRODUCTO_AGREGADO,
        `Producto actualizado en cotización: ${producto.plu} - ${producto.desccorta || producto.desclarga || ""} (Cant: ${cantidad})`,
        actor?.id,
      );

      return {
        linea: savedLinea,
        duplicado: true,
        mensaje:
          "El producto ya existía en la cotización. Se actualizó la cantidad.",
      };
    }

    const precioFinalUnitario = Number(producto.precio);

    if (!Number.isFinite(precioFinalUnitario) || precioFinalUnitario < 0) {
      throw new BadRequestException("El precio del producto no es válido");
    }

    await this.ensureSaleNotBelowCost(
      producto.plu,
      precioFinalUnitario,
      producto.es_servicio,
    );

    const taxSplit = this.splitTaxIncludedUnitPrice(
      precioFinalUnitario,
      producto.pagaiva,
      producto.iva,
    );
    const subtotal = taxSplit.baseUnitPrice * cantidad;
    const iva = taxSplit.taxUnitAmount * cantidad;
    const total = precioFinalUnitario * cantidad;

    const linea = this.lineaRepository.create({
      cotizacionId,
      plu: producto.plu,
      descripcion: producto.desclarga || producto.desccorta || producto.plu,
      precioUnitario: taxSplit.baseUnitPrice,
      cantidad,
      subtotal,
      iva,
      total,
      esServicio: producto.es_servicio,
      orden: cotizacion.lineas ? cotizacion.lineas.length : 0,
    });

    const savedLinea = await this.lineaRepository.save(linea);
    if (wasRejected) {
      this.resetRejectedDecision(cotizacion);
      await this.cotizacionRepository.save(cotizacion);
    }

    await this.recalculateTotals(cotizacionId);

    await this.otsService.logEvento(
      cotizacion.otId,
      TipoEventoOT.PRODUCTO_AGREGADO,
      `Producto agregado: ${producto.plu} - ${producto.desccorta || ""} (Cant: ${cantidad})`,
      actor?.id,
    );

    return {
      linea: savedLinea,
      duplicado: false,
      mensaje: "Producto agregado a la cotización.",
    };
  }

  async updateLinea(
    cotizacionId: number,
    lineaId: number,
    dto: UpdateLineaDto,
    user?: CotizacionUser,
  ): Promise<CotizacionLinea> {
    const cotizacion = await this.findById(cotizacionId, user);
    this.ensureLineasEditables(cotizacion);
    await this.assertLock(cotizacionId, dto.lockToken, user);
    const motivo = await this.normalizeAuditMotivo(dto.motivo);
    const wasRejected = cotizacion.estado === EstadoCotizacion.RECHAZADA;

    if (
      cotizacion.ordenTrabajo?.estado === EstadoOT.TERMINADA ||
      // FACTURADA: la venta ya existe en POSTouch; cambiar lineas ahora
      // desincronizaria la cotizacion de la factura.
      cotizacion.ordenTrabajo?.estado === EstadoOT.FACTURADA ||
      cotizacion.ordenTrabajo?.estado === EstadoOT.ENTREGADA
    ) {
      throw new BadRequestException(
        "No se pueden modificar lineas de una OT que ya esta terminada, facturada o entregada",
      );
    }

    const linea = await this.lineaRepository.findOne({
      where: { id: lineaId, cotizacionId },
    });

    if (!linea) {
      throw new NotFoundException(`Linea ${lineaId} no encontrada`);
    }

    const before = {
      descripcion: linea.descripcion,
      precioUnitario: Number(linea.precioUnitario || 0),
      cantidad: Number(linea.cantidad || 0),
    };
    const previousSubtotal = Number(linea.subtotal || 0);
    const previousIva = Number(linea.iva || 0);
    const ivaRate = previousSubtotal > 0 ? previousIva / previousSubtotal : 0;

    if (dto.descripcion !== undefined) {
      const descripcion = String(dto.descripcion || "").trim();
      if (!descripcion) {
        throw new BadRequestException("La descripción no puede quedar vacía");
      }
      linea.descripcion = descripcion;
    }

    if (dto.precioUnitario !== undefined) {
      const precioUnitario = Number(dto.precioUnitario);
      if (!Number.isFinite(precioUnitario) || precioUnitario < 0) {
        throw new BadRequestException("El precio unitario no es válido");
      }
      linea.precioUnitario = precioUnitario;
    }

    if (dto.precioUnitarioConIva !== undefined) {
      const precioUnitarioConIva = Number(dto.precioUnitarioConIva);
      if (!Number.isFinite(precioUnitarioConIva) || precioUnitarioConIva < 0) {
        throw new BadRequestException("El precio unitario no es válido");
      }
      linea.precioUnitario = ivaRate
        ? precioUnitarioConIva / (1 + ivaRate)
        : precioUnitarioConIva;
    }

    if (dto.cantidad !== undefined) {
      const cantidad = Number(dto.cantidad);
      if (!Number.isFinite(cantidad) || cantidad <= 0) {
        throw new BadRequestException("La cantidad debe ser mayor a cero");
      }
      linea.cantidad = cantidad;
    }

    if (
      dto.cantidad !== undefined ||
      dto.precioUnitario !== undefined ||
      dto.precioUnitarioConIva !== undefined ||
      dto.descripcion !== undefined
    ) {
      linea.subtotal = Number(linea.precioUnitario) * Number(linea.cantidad);
      linea.iva = linea.subtotal * ivaRate;
      linea.total = linea.subtotal + linea.iva;
      const finalUnitPrice =
        Number(linea.cantidad || 0) > 0
          ? Number(linea.total || 0) / Number(linea.cantidad || 1)
          : Number(linea.precioUnitario || 0) * (1 + ivaRate);
      await this.ensureSaleNotBelowCost(
        linea.plu,
        finalUnitPrice,
        Boolean(linea.esServicio),
      );
    }

    const savedLinea = await this.lineaRepository.save(linea);
    if (wasRejected) {
      this.resetRejectedDecision(cotizacion);
      await this.cotizacionRepository.save(cotizacion);
    }
    await this.recalculateTotals(cotizacionId);
    await this.logCotizacionCambio(
      cotizacion,
      savedLinea,
      motivo,
      user,
      before,
    );

    return savedLinea;
  }

  async removeLinea(
    cotizacionId: number,
    lineaId: number,
    dto: DeleteLineaDto,
    user?: CotizacionUser,
  ): Promise<void> {
    const cotizacion = await this.findById(cotizacionId, user);
    this.ensureLineasEditables(cotizacion);
    await this.assertLock(cotizacionId, dto?.lockToken, user);
    const motivo = await this.normalizeAuditMotivo(dto?.motivo);
    const wasRejected = cotizacion.estado === EstadoCotizacion.RECHAZADA;

    if (
      cotizacion.ordenTrabajo?.estado === EstadoOT.TERMINADA ||
      // FACTURADA: la venta ya existe en POSTouch; cambiar lineas ahora
      // desincronizaria la cotizacion de la factura.
      cotizacion.ordenTrabajo?.estado === EstadoOT.FACTURADA ||
      cotizacion.ordenTrabajo?.estado === EstadoOT.ENTREGADA
    ) {
      throw new BadRequestException(
        "No se pueden eliminar lineas de una OT que ya esta terminada, facturada o entregada",
      );
    }

    const linea = await this.lineaRepository.findOne({
      where: { id: lineaId, cotizacionId },
    });

    if (!linea) {
      throw new NotFoundException(`Linea ${lineaId} no encontrada`);
    }

    await this.lineaRepository.delete({
      id: lineaId,
      cotizacionId,
    });
    if (wasRejected) {
      this.resetRejectedDecision(cotizacion);
      await this.cotizacionRepository.save(cotizacion);
    }

    await this.recalculateTotals(cotizacionId);
    await this.otsService.logEvento(
      cotizacion.otId,
      TipoEventoOT.OTRO,
      [
        `Cotización ${cotizacion.numeroCotizacion || cotizacion.id} modificada`,
        `Usuario: ${this.getAuditUserLabel(user)}`,
        `Accion: linea eliminada`,
        `Linea: ${linea.plu} - ${linea.descripcion}`,
        `Cantidad: ${Number(linea.cantidad || 0)}`,
        `Precio: ${Number(linea.precioUnitario || 0)}`,
        `Motivo: ${motivo}`,
      ].join(" | "),
      user?.id,
    );
  }

  async aprobar(
    cotizacionId: number,
    aprobadoPor: string,
    lockToken: string,
    user?: CotizacionUser,
  ): Promise<Cotizacion> {
    const cotizacion = await this.findById(cotizacionId, user);
    await this.assertLock(cotizacionId, lockToken, user);

    if (cotizacion.estado === EstadoCotizacion.APROBADA) {
      throw new BadRequestException("La cotización ya fue aprobada");
    }

    if (!cotizacion.lineas || cotizacion.lineas.length === 0) {
      throw new BadRequestException(
        "No se puede aprobar una cotización sin líneas",
      );
    }

    cotizacion.estado = EstadoCotizacion.APROBADA;
    cotizacion.fechaAprobacion = new Date();
    cotizacion.aprobadoPor = aprobadoPor;
    cotizacion.version = cotizacion.version + 1;

    const savedCotizacion = await this.cotizacionRepository.save(cotizacion);
    await this.otsService.markAprobadaFromCotizacion(
      savedCotizacion.otId,
      user?.id,
    );

    await this.otsService.logEvento(
      cotizacion.otId,
      TipoEventoOT.COTIZACION_APROBADA,
      `Cotización ${cotizacion.numeroCotizacion} aprobada por ${aprobadoPor}`,
    );

    await this.lockRepository.delete({ cotizacionId });

    return savedCotizacion;
  }

  private async recalculateTotals(cotizacionId: number): Promise<void> {
    const lineas = await this.lineaRepository.find({
      where: { cotizacionId },
    });

    const subtotal = lineas.reduce((sum, l) => sum + Number(l.subtotal), 0);
    const iva = lineas.reduce((sum, l) => sum + Number(l.iva), 0);
    const total = lineas.reduce((sum, l) => sum + Number(l.total), 0);

    await this.cotizacionRepository.update(cotizacionId, {
      subtotal,
      iva,
      total,
    });
  }

  private ensureCotizacionEditable(cotizacion: Cotizacion): void {
    if (cotizacion.estado === EstadoCotizacion.APROBADA) {
      throw new BadRequestException(
        "La cotización ya fue aprobada y no puede modificarse",
      );
    }
  }

  private ensureLineasEditables(cotizacion: Cotizacion): void {
    this.ensureCotizacionEditable(cotizacion);
    if (cotizacion.estado === EstadoCotizacion.ENVIADA) {
      throw new BadRequestException(
        "La cotización ya fue enviada al cliente y queda bloqueada para edición. Espera su respuesta o marca la cotización como rechazada para volver a editarla.",
      );
    }
  }

  private splitTaxIncludedUnitPrice(
    price: number,
    paysTax: boolean,
    taxPercent?: number,
  ) {
    const finalUnitPrice = Number(price || 0);
    const rate = paysTax ? Number(taxPercent || 0) / 100 : 0;

    if (!Number.isFinite(finalUnitPrice) || finalUnitPrice < 0) {
      throw new BadRequestException("El precio del producto no es válido");
    }

    if (!rate) {
      return { baseUnitPrice: finalUnitPrice, taxUnitAmount: 0 };
    }

    const baseUnitPrice = finalUnitPrice / (1 + rate);
    return {
      baseUnitPrice,
      taxUnitAmount: finalUnitPrice - baseUnitPrice,
    };
  }

  private resetRejectedDecision(
    cotizacion: Cotizacion,
    incrementVersion = true,
  ): void {
    cotizacion.estado = EstadoCotizacion.BORRADOR;
    cotizacion.publicToken = null;
    cotizacion.publicExpiresAt = null;
    cotizacion.decisionClienteAt = null;
    cotizacion.decisionClienteNombre = null;
    cotizacion.decisionClienteFirma = null;
    cotizacion.decisionClienteMotivo = null;
    if (incrementVersion) {
      cotizacion.version = Number(cotizacion.version || 1) + 1;
    }
  }

  private ensureUserIdentified(user?: { id?: number }) {
    if (!user?.id) {
      throw new BadRequestException("No se pudo identificar el usuario activo");
    }
  }

  private async getActiveLock(cotizacionId: number) {
    const lock = await this.lockRepository.findOne({ where: { cotizacionId } });
    if (!lock) return null;

    if (lock.expiresAt <= new Date()) {
      await this.lockRepository.delete({ id: lock.id });
      return null;
    }

    return lock;
  }

  private async assertLock(
    cotizacionId: number,
    lockToken: string | undefined,
    user?: { id?: number },
  ) {
    this.ensureUserIdentified(user);

    if (!lockToken?.trim()) {
      throw new ConflictException("Esta cotización requiere bloqueo activo");
    }

    const lock = await this.lockRepository.findOne({
      where: { cotizacionId, token: lockToken },
    });

    if (!lock || lock.expiresAt <= new Date()) {
      if (lock) await this.lockRepository.delete({ id: lock.id });
      throw new ConflictException("El bloqueo de cotización expiró");
    }

    if (lock.usuarioId !== user?.id) {
      throw new ConflictException("El bloqueo pertenece a otro usuario");
    }

    return lock;
  }

  private toLockResponse(lock: CotizacionLock) {
    return {
      locked: true,
      ownedByCurrentUser: true,
      token: lock.token,
      usuarioId: lock.usuarioId,
      usuarioNombre: lock.usuarioNombre,
      expiresAt: lock.expiresAt,
    };
  }

  private async findPublicCotizacion(token: string) {
    const normalizedToken = String(token || "").trim();
    if (!/^[a-f0-9]{32}$/i.test(normalizedToken)) {
      throw new NotFoundException("Cotización no encontrada");
    }

    const cotizacion = await this.cotizacionRepository.findOne({
      where: { publicToken: normalizedToken },
      relations: [
        "lineas",
        "ordenTrabajo",
        "ordenTrabajo.vehiculo",
        "ordenTrabajo.mecanico",
      ],
    });

    if (!cotizacion) {
      throw new NotFoundException("Cotización no encontrada");
    }

    if (
      cotizacion.publicExpiresAt &&
      cotizacion.publicExpiresAt < new Date() &&
      cotizacion.estado !== EstadoCotizacion.APROBADA &&
      cotizacion.estado !== EstadoCotizacion.RECHAZADA
    ) {
      throw new BadRequestException("El link de cotización expiró");
    }

    return cotizacion;
  }

  // Sin modulo de mecanicos el cliente no debe ver "mecanico asignado"
  // aunque la OT conserve uno de cuando el taller si lo usaba.
  private async vistaPublica(cotizacion: Cotizacion) {
    const vista = this.toPublicCotizacion(cotizacion);
    if (
      vista.ot &&
      !(await this.tallerParametros.getBooleanConFallback("ot.usa_mecanicos"))
    ) {
      vista.ot = { ...vista.ot, mecanico: null };
    }
    return vista;
  }

  private toPublicCotizacion(cotizacion: Cotizacion) {
    return {
      id: cotizacion.id,
      numeroCotizacion: cotizacion.numeroCotizacion,
      estado: cotizacion.estado,
      asesorNombre: cotizacion.asesorNombre,
      subtotal: Number(cotizacion.subtotal || 0),
      iva: Number(cotizacion.iva || 0),
      total: Number(cotizacion.total || 0),
      publicExpiresAt: cotizacion.publicExpiresAt,
      decisionClienteAt: cotizacion.decisionClienteAt,
      decisionClienteNombre: cotizacion.decisionClienteNombre,
      decisionClienteMotivo: cotizacion.decisionClienteMotivo,
      ot: cotizacion.ordenTrabajo
        ? {
            numeroOt: cotizacion.ordenTrabajo.numeroOt,
            fechaEntrada: cotizacion.ordenTrabajo.fechaEntrada,
            requerimientoCliente: cotizacion.ordenTrabajo.requerimientoCliente,
            observaciones: cotizacion.ordenTrabajo.observaciones,
            mecanico: cotizacion.ordenTrabajo.mecanico
              ? {
                  id: cotizacion.ordenTrabajo.mecanico.id,
                  nombre: cotizacion.ordenTrabajo.mecanico.nombre,
                  especialidad: cotizacion.ordenTrabajo.mecanico.especialidad,
                }
              : null,
            vehiculo: cotizacion.ordenTrabajo.vehiculo
              ? {
                  placa: cotizacion.ordenTrabajo.vehiculo.placa,
                  marca: cotizacion.ordenTrabajo.vehiculo.marca,
                  modelo: cotizacion.ordenTrabajo.vehiculo.modelo,
                  anio: cotizacion.ordenTrabajo.vehiculo.anio,
                }
              : null,
          }
        : null,
      lineas: (cotizacion.lineas || []).map((linea) => ({
        id: linea.id,
        plu: linea.plu,
        descripcion: linea.descripcion,
        cantidad: Number(linea.cantidad || 0),
        precioUnitario: Number(linea.precioUnitario || 0),
        subtotal: Number(linea.subtotal || 0),
        iva: Number(linea.iva || 0),
        total: Number(linea.total || 0),
        esServicio: Boolean(linea.esServicio),
      })),
    };
  }

  private validatePublicSignature(firma: string) {
    if (firma.length > 1500000) {
      throw new BadRequestException("La firma es demasiado grande");
    }

    if (
      !firma.startsWith("data:image/png;base64,") &&
      !firma.startsWith("data:image/jpeg;base64,") &&
      !firma.startsWith("data:image/webp;base64,")
    ) {
      throw new BadRequestException("La firma debe ser una imagen válida");
    }

    const [, base64 = ""] = firma.split(",", 2);
    if (!base64 || !/^[A-Za-z0-9+/=]+$/.test(base64)) {
      throw new BadRequestException("La firma debe ser una imagen válida");
    }
  }

  private async saveVersionSnapshot(
    cotizacionId: number,
    estado: string,
    motivo: string | null,
    userId?: number | null,
  ) {
    const cotizacion = await this.findById(cotizacionId);
    const snapshot = this.toPublicCotizacion(cotizacion);
    const lastVersion = await this.versionRepository.findOne({
      where: { cotizacionId },
      order: { version: "DESC" },
    });

    await this.versionRepository.save(
      this.versionRepository.create({
        cotizacionId,
        version: (lastVersion?.version || 0) + 1,
        estado,
        snapshot,
        motivo,
        createdBy: userId ?? null,
      }),
    );
  }

  // Apagado = el motivo es opcional, pero la bitacora sigue registrando quien
  // cambio que: sin motivo se anota "Sin motivo indicado" en vez de dejar el
  // campo vacio o inventar uno.
  private async normalizeAuditMotivo(value: string): Promise<string> {
    const motivo = String(value || "").trim();
    if (
      !motivo &&
      (await this.tallerParametros.getBooleanConFallback("cotizacion.exige_motivo_cambio"))
    ) {
      throw new BadRequestException("El motivo del cambio es requerido");
    }
    return motivo || "Sin motivo indicado";
  }

  private getAuditUserLabel(user?: { id?: number; username?: string }): string {
    return (
      user?.username ||
      (user?.id ? `Usuario ${user.id}` : "Usuario no identificado")
    );
  }

  private getUserDisplayName(user?: CotizacionUser): string | null {
    const name = String(user?.nombre || user?.username || "").trim();
    return name || null;
  }

  private async ensureSaleNotBelowCost(
    plu: string,
    finalUnitPrice: number,
    esServicio: boolean,
  ) {
    if (esServicio) {
      return;
    }

    if (
      !(await this.tallerParametros.getBooleanConFallback("cotizacion.bloquea_venta_bajo_costo"))
    ) {
      return;
    }

    const costo = await this.productosService.getCostoByPlu(plu);
    if (!Number.isFinite(costo) || costo <= 0) {
      return;
    }

    if (Number(finalUnitPrice || 0) + 0.0001 < costo) {
      throw new BadRequestException(
        `No se permite vender bajo costo. Costo: ${costo.toFixed(2)}, precio: ${Number(finalUnitPrice || 0).toFixed(2)}`,
      );
    }
  }

  private async logCotizacionCambio(
    cotizacion: Cotizacion,
    linea: CotizacionLinea,
    motivo: string,
    user: { id?: number; username?: string } | undefined,
    before: { descripcion: string; precioUnitario: number; cantidad: number },
  ) {
    const changes: string[] = [];
    const nextCantidad = Number(linea.cantidad || 0);
    const nextPrecio = Number(linea.precioUnitario || 0);

    if (before.cantidad !== nextCantidad) {
      changes.push(`cantidad ${before.cantidad} -> ${nextCantidad}`);
    }

    if (before.precioUnitario !== nextPrecio) {
      changes.push(`precio ${before.precioUnitario} -> ${nextPrecio}`);
    }

    if (before.descripcion !== linea.descripcion) {
      changes.push("descripcion actualizada");
    }

    await this.otsService.logEvento(
      cotizacion.otId,
      TipoEventoOT.OTRO,
      [
        `Cotización ${cotizacion.numeroCotizacion || cotizacion.id} modificada`,
        `Usuario: ${this.getAuditUserLabel(user)}`,
        `Accion: linea actualizada`,
        `Linea: ${linea.plu} - ${linea.descripcion}`,
        `Cambios: ${changes.length ? changes.join(", ") : "sin cambios de valor"}`,
        `Motivo: ${motivo}`,
      ].join(" | "),
      user?.id,
    );
  }

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

    const lastCot = await this.cotizacionRepository
      .createQueryBuilder("cot")
      .where("cot.numeroCotizacion LIKE :prefix", {
        prefix: `COT-${datePrefix}-%`,
      })
      .orderBy("cot.numeroCotizacion", "DESC")
      .getOne();

    let sequence = 1;

    if (lastCot && lastCot.numeroCotizacion) {
      const match = lastCot.numeroCotizacion.match(/COT-\d{8}-(\d+)/);
      if (match) {
        sequence = parseInt(match[1], 10) + 1;
      }
    }

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