import { BadRequestException } from "@nestjs/common";
import {
  extraerJson,
  PostouchClientService,
  PostouchRespuestaIncierta,
  redondear,
} from "./postouch-client.service";

const config = {
  habilitada: true as const,
  url: "http://132.226.40.48:3310/create_ordenes_edngt",
  username: "22",
  password: "5551",
  computadora: "5.5",
  precioDecimales: 2,
  precioIncluyeIva: true,
};

const orden = {
  id: 1234,
  cliente: {
    tarjeta: "2031",
    nombre: "Juan Perez",
    nit: "CF",
    telefono: "5085-4657",
  },
  lineas: [{ plu: "2205", cantidad: 2, precio: 100.005 }],
};

describe("redondear", () => {
  it("redondea al numero de decimales indicado", () => {
    expect(redondear(100.005, 2)).toBe(100.01);
    expect(redondear(100.004, 2)).toBe(100);
    expect(redondear(100, 0)).toBe(100);
  });

  it("maneja correctamente valores afectados por IEEE-754", () => {
    expect(redondear(1.005, 2)).toBe(1.01);
    expect(redondear(35.855, 2)).toBe(35.86);
    expect(redondear(19.995, 2)).toBe(20);
    expect(redondear(2.675, 2)).toBe(2.68);
    expect(redondear(123.456, 2)).toBe(123.46);
  });
});

describe("PostouchClientService.enviarOrden", () => {
  let originalFetch: typeof fetch;

  beforeEach(() => {
    originalFetch = global.fetch;
  });

  afterEach(() => {
    global.fetch = originalFetch;
  });

  it("arma el payload con formasPago=99 por el total exacto y parsea la respuesta", async () => {
    const fetchMock = jest.fn(async () => ({
      ok: true,
      text: async () =>
        JSON.stringify({
          procesadas: 1,
          facturas: [
            { tienda: 1, caja: 2, tipo: "FAC", serie: "A", transac: 555 },
          ],
        }),
    }));
    global.fetch = fetchMock as any;

    const service = new PostouchClientService();
    const result = await service.enviarOrden(config, orden);

    expect(result).toEqual({ tienda: 1, caja: 2, tipo: "FAC", serie: "A", transac: 555 });

    const calls = fetchMock.mock.calls as any[];
    const [url, init] = calls[0];
    expect(url).toBe(config.url);
    const body = JSON.parse((init as any).body);
    expect(body._username).toBe("22");
    expect(body._password).toBe("5551");
    expect(body.computadora).toBe("5.5");
    expect(body.ordenes).toHaveLength(1);
    const [ordenEnviada] = body.ordenes;
    expect(ordenEnviada.id).toBe(1234);
    expect(ordenEnviada.productos).toEqual([
      { plu: "2205", cantidad: 2, precio: 100.01, monto: 200.02, descuentoItemPorcentaje: 0, comboNumero: 0, pluPadre: "" },
    ]);
    expect(ordenEnviada.formasPago).toEqual([
      { media: 99, emisor: 0, codigo: 0, monto: 200.02 },
    ]);
  });

  it("lanza BadRequestException si POSTouch responde con error", async () => {
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ error: "No existe el PLU[2205]." }),
    })) as any;

    const service = new PostouchClientService();

    await expect(service.enviarOrden(config, orden)).rejects.toThrow(
      BadRequestException,
    );
    await expect(service.enviarOrden(config, orden)).rejects.toThrow(
      /No existe el PLU/,
    );
  });

  it("lanza BadRequestException si la respuesta HTTP no es ok", async () => {
    global.fetch = jest.fn(async () => ({
      ok: false,
      text: async () => "Internal Server Error",
    })) as any;

    const service = new PostouchClientService();

    await expect(service.enviarOrden(config, orden)).rejects.toThrow(
      BadRequestException,
    );
  });
});

describe("extraerJson", () => {
  it("parsea JSON limpio", () => {
    expect(extraerJson('{"a":1}')).toEqual({ a: 1 });
  });

  it("ignora warnings de PHP antes del JSON", () => {
    const texto = '<br />\n<b>Warning</b>: Undefined index in x.php on line 5<br />\n{"procesadas":1,"facturas":[{"serie":"A"}]}';
    expect(extraerJson(texto)).toEqual({ procesadas: 1, facturas: [{ serie: "A" }] });
  });

  it("no se confunde con llaves dentro de cadenas ni con ruido posterior", () => {
    expect(extraerJson('aviso {"error":"falta } en [x]"} fin {"otro":1}')).toEqual({
      error: "falta } en [x]",
    });
  });

  it("devuelve {} si no hay JSON recuperable", () => {
    expect(extraerJson("")).toEqual({});
    expect(extraerJson("Internal Server Error")).toEqual({});
    expect(extraerJson('{"truncado": ')).toEqual({});
  });
});

describe("PostouchClientService.enviarOrden — endurecimiento", () => {
  let originalFetch: typeof fetch;
  const factura = { tienda: 1, caja: 2, tipo: "FAC", serie: "A", transac: 555 };
  const respuestaOk = (cuerpo: unknown) =>
    jest.fn(async () => ({ ok: true, text: async () => (typeof cuerpo === "string" ? cuerpo : JSON.stringify(cuerpo)) }));

  beforeEach(() => {
    originalFetch = global.fetch;
  });
  afterEach(() => {
    global.fetch = originalFetch;
  });

  it("manda un AbortSignal con timeout: un POSTouch colgado no bloquea la peticion para siempre", async () => {
    const fetchMock = respuestaOk({ facturas: [factura] });
    global.fetch = fetchMock as any;

    await new PostouchClientService().enviarOrden(config, orden);

    const init = (fetchMock.mock.calls as any[])[0][1];
    expect(init.signal).toBeInstanceOf(AbortSignal);
  });

  it("un timeout es INCIERTO: la orden pudo haberse creado, el mensaje manda a verificar antes de reintentar", async () => {
    global.fetch = jest.fn(async () => {
      throw Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" });
    }) as any;

    const promesa = new PostouchClientService().enviarOrden(config, orden);

    await expect(promesa).rejects.toBeInstanceOf(PostouchRespuestaIncierta);
    await expect(promesa).rejects.toThrow(/no respondió en 30 s.*verifica en POSTouch antes de reintentar/);
  });

  it("conexion rechazada NO es incierta: la orden nunca salio, se puede reintentar sin riesgo", async () => {
    global.fetch = jest.fn(async () => {
      throw Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED", message: "connect ECONNREFUSED 10.0.0.1:80" } });
    }) as any;

    const promesa = new PostouchClientService().enviarOrden(config, orden);

    await expect(promesa).rejects.toBeInstanceOf(BadRequestException);
    await expect(promesa).rejects.not.toBeInstanceOf(PostouchRespuestaIncierta);
    await expect(promesa).rejects.toThrow(/ECONNREFUSED.*No se envió la orden/);
  });

  it("un corte de red a mitad de la llamada (sin codigo de rechazo) se trata como incierto", async () => {
    global.fetch = jest.fn(async () => {
      throw Object.assign(new TypeError("fetch failed"), { cause: { code: "UND_ERR_SOCKET", message: "other side closed" } });
    }) as any;

    await expect(new PostouchClientService().enviarOrden(config, orden)).rejects.toBeInstanceOf(
      PostouchRespuestaIncierta,
    );
  });

  it("tolera warnings de PHP antes del JSON y sigue leyendo la factura creada", async () => {
    global.fetch = respuestaOk(`<b>Notice</b>: algo en x.php\n${JSON.stringify({ facturas: [factura] })}`) as any;

    await expect(new PostouchClientService().enviarOrden(config, orden)).resolves.toEqual(factura);
  });

  it("una respuesta 200 sin factura legible es INCIERTA, no un fallo simple (probablemente SI se creo)", async () => {
    global.fetch = respuestaOk("Fatal error: algo explotó despues de facturar") as any;

    const promesa = new PostouchClientService().enviarOrden(config, orden);

    await expect(promesa).rejects.toBeInstanceOf(PostouchRespuestaIncierta);
    await expect(promesa).rejects.toThrow(/Verifica en POSTouch antes de reintentar/);
  });

  it.each(["tienda", "caja", "tipo", "serie", "transac"])(
    "una factura sin el campo %s es incierta: nunca se guarda a medias",
    async (campo) => {
      const incompleta: Record<string, unknown> = { ...factura };
      delete incompleta[campo];
      global.fetch = respuestaOk({ facturas: [incompleta] }) as any;

      await expect(new PostouchClientService().enviarOrden(config, orden)).rejects.toBeInstanceOf(
        PostouchRespuestaIncierta,
      );
    },
  );

  it("un rechazo explicito de POSTouch NO es incierto", async () => {
    global.fetch = respuestaOk({ error: "No existe el PLU[2205]." }) as any;

    const promesa = new PostouchClientService().enviarOrden(config, orden);

    await expect(promesa).rejects.not.toBeInstanceOf(PostouchRespuestaIncierta);
    await expect(promesa).rejects.toThrow(/No existe el PLU/);
  });

  // Caso real del demo (12-oct-2026): POSTouch facturo la orden (CI0101-3155) y
  // DESPUES revento con un error fatal de PHP (Domicilio.php, getPedidoSerie()
  // sobre null porque la caja no era de tipo domicilio). Respondio HTTP 500 con
  // el cuerpo vacio. Contarlo como "fallo" invitaba a reintentar y duplicar la
  // venta.
  describe("respuesta HTTP no exitosa", () => {
    const respuestaHttp = (status: number, cuerpo: unknown) =>
      jest.fn(async () => ({
        ok: false,
        status,
        text: async () => (typeof cuerpo === "string" ? cuerpo : JSON.stringify(cuerpo)),
      }));

    it.each([
      ["500 con cuerpo vacio", 500, ""],
      ["500 con la pagina de error de PHP", 500, "<html><body>Fatal error: Call to a member function getPedidoSerie() on null</body></html>"],
      ["500 con JSON que no trae error", 500, { procesadas: 0 }],
      ["502 del proxy", 502, "Bad Gateway"],
      ["504 del proxy", 504, ""],
    ])("%s es INCIERTA: POSTouch pudo facturar antes de fallar", async (_nombre, status, cuerpo) => {
      global.fetch = respuestaHttp(status as number, cuerpo) as any;

      const promesa = new PostouchClientService().enviarOrden(config, orden);

      await expect(promesa).rejects.toBeInstanceOf(PostouchRespuestaIncierta);
      await expect(promesa).rejects.toThrow(/Verifica en POSTouch antes de reintentar/);
      await expect(promesa).rejects.toThrow(new RegExp(`HTTP ${status}`));
    });

    it("sin codigo de estado legible tambien es incierta", async () => {
      global.fetch = jest.fn(async () => ({ ok: false, text: async () => "Internal Server Error" })) as any;

      await expect(new PostouchClientService().enviarOrden(config, orden)).rejects.toBeInstanceOf(
        PostouchRespuestaIncierta,
      );
    });

    // Este POSTouch contesta HTTP 500 hasta para un usuario inexistente, pero con
    // un JSON {"error": ...}: eso es un rechazo explicito, nunca llego a facturar.
    it("500 con JSON {error} es un rechazo explicito, NO incierto", async () => {
      global.fetch = respuestaHttp(500, { error: "No se ha encontrado ningún usuario con el nombre ingresado [22]." }) as any;

      const promesa = new PostouchClientService().enviarOrden(config, orden);

      await expect(promesa).rejects.not.toBeInstanceOf(PostouchRespuestaIncierta);
      await expect(promesa).rejects.toThrow(/No se ha encontrado ningún usuario/);
    });

    it.each([400, 401, 403, 404])("%s es un rechazo: la orden no llego a procesarse, NO es incierto", async (status) => {
      global.fetch = respuestaHttp(status, "No autorizado") as any;

      const promesa = new PostouchClientService().enviarOrden(config, orden);

      await expect(promesa).rejects.toBeInstanceOf(BadRequestException);
      await expect(promesa).rejects.not.toBeInstanceOf(PostouchRespuestaIncierta);
      await expect(promesa).rejects.toThrow(new RegExp(`HTTP ${status}`));
    });
  });

  describe("cuadre contra lo cotizado (se verifica ANTES de llamar a POS)", () => {
    const ordenCon = (lineas: any[]) => ({ ...orden, lineas });

    it("deja pasar cuando los montos cuadran con el total cotizado", async () => {
      const fetchMock = respuestaOk({ facturas: [factura] });
      global.fetch = fetchMock as any;

      await new PostouchClientService().enviarOrden(
        config,
        ordenCon([{ plu: "2205", cantidad: 2, precio: 100, totalEsperado: 200 }]),
      );

      expect(fetchMock).toHaveBeenCalledTimes(1);
    });

    it("tolera el redondeo de un centavo (100.00 entre 3 unidades = 33.33 x 3 = 99.99)", async () => {
      global.fetch = respuestaOk({ facturas: [factura] }) as any;

      await expect(
        new PostouchClientService().enviarOrden(
          config,
          ordenCon([{ plu: "2205", cantidad: 3, precio: 100 / 3, totalEsperado: 100 }]),
        ),
      ).resolves.toEqual(factura);
    });

    it("89.29 x 1.12 con IVA: enviando el precio final cuadra; enviando la base NO cuadraria", async () => {
      global.fetch = respuestaOk({ facturas: [factura] }) as any;
      const servicio = new PostouchClientService();

      await expect(
        servicio.enviarOrden(config, ordenCon([{ plu: "2205", cantidad: 1, precio: 100, totalEsperado: 100 }])),
      ).resolves.toEqual(factura);

      const fetchMock = respuestaOk({ facturas: [factura] });
      global.fetch = fetchMock as any;
      await expect(
        servicio.enviarOrden(config, ordenCon([{ plu: "2205", cantidad: 1, precio: 89.29, totalEsperado: 100 }])),
      ).rejects.toThrow(/no cuadra con lo cotizado.*No se envió nada a POSTouch/);
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it("rechaza ANTES de llamar si el redondeo de la tienda desvia el total mas de lo tolerado", async () => {
      const fetchMock = respuestaOk({ facturas: [factura] });
      global.fetch = fetchMock as any;

      // 0 decimales: 33.33 -> 33, x3 = 99 contra 100 cotizado.
      await expect(
        new PostouchClientService().enviarOrden(
          { ...config, precioDecimales: 0 },
          ordenCon([{ plu: "2205", cantidad: 3, precio: 100 / 3, totalEsperado: 100 }]),
        ),
      ).rejects.toThrow(/no cuadra con lo cotizado/);
      expect(fetchMock).not.toHaveBeenCalled();
    });

    it("sin totalEsperado (precio base) no compara nada", async () => {
      global.fetch = respuestaOk({ facturas: [factura] }) as any;

      await expect(
        new PostouchClientService().enviarOrden(config, ordenCon([{ plu: "2205", cantidad: 1, precio: 89.29 }])),
      ).resolves.toEqual(factura);
    });
  });
});

describe("PostouchClientService.probarConexion", () => {
  let originalFetch: typeof fetch;

  beforeEach(() => {
    originalFetch = global.fetch;
  });

  afterEach(() => {
    global.fetch = originalFetch;
  });

  it("nunca llama a enviarOrden bajo ninguna rama -- el payload siempre lleva ordenes:[]", async () => {
    const fetchMock = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ procesadas: 0, facturas: [] }),
    }));
    global.fetch = fetchMock as any;
    const service = new PostouchClientService();

    await service.probarConexion({ url: config.url, username: "22", password: "5551", computadora: "5.5" });

    const [, init] = fetchMock.mock.calls[0] as any[];
    const body = JSON.parse(init.body);
    expect(body.ordenes).toEqual([]);
  });

  it("ok:true solo cuando la respuesta es exactamente {procesadas:0, facturas:[]}", async () => {
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ procesadas: 0, facturas: [] }),
    })) as any;
    const service = new PostouchClientService();

    await expect(
      service.probarConexion({ url: config.url, username: "22", password: "5551", computadora: "5.5" }),
    ).resolves.toEqual({ ok: true });
  });

  it("ok:false si la respuesta HTTP no es exitosa", async () => {
    global.fetch = jest.fn(async () => ({ ok: false, status: 500, text: async () => "Internal Server Error" })) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });

    expect(resultado.ok).toBe(false);
  });

  it("ok:false si la respuesta trae data.error", async () => {
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ error: "Usuario o contraseña incorrectos" }),
    })) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "clave-mala",
      computadora: "5.5",
    });

    expect(resultado).toEqual({ ok: false, detalle: expect.stringContaining("Usuario o contraseña incorrectos") });
  });

  it("ok:false ante una forma de respuesta inesperada -- nunca asume éxito por omisión", async () => {
    global.fetch = jest.fn(async () => ({ ok: true, text: async () => JSON.stringify({ algo: "distinto" }) })) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });

    expect(resultado.ok).toBe(false);
  });

  it("ok:false si la respuesta no es JSON válido", async () => {
    global.fetch = jest.fn(async () => ({ ok: true, text: async () => "<html>error</html>" })) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });

    expect(resultado.ok).toBe(false);
  });

  it("nunca loguea la contraseña real, en ningún resultado", async () => {
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ procesadas: 0, facturas: [] }),
    })) as any;
    const service = new PostouchClientService();
    const debugSpy = jest.spyOn((service as any).logger, "debug");

    await service.probarConexion({
      url: config.url,
      username: "22",
      password: "secreta-super-unica",
      computadora: "5.5",
    });

    for (const call of debugSpy.mock.calls) {
      expect(JSON.stringify(call)).not.toContain("secreta-super-unica");
    }
  });

  it("ok:false si fetch rechaza (falla de red -- DNS, conexion rechazada, timeout) -- nunca propaga la excepcion", async () => {
    global.fetch = jest.fn(async () => {
      throw new Error("ECONNREFUSED");
    }) as any;
    const service = new PostouchClientService();

    const resultado = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });

    expect(resultado).toEqual({ ok: false, detalle: expect.stringContaining("ECONNREFUSED") });
  });

  it("ok:false si la respuesta es parcialmente correcta -- guarda contra un && que se degrade a || en esRespuestaEsperada", async () => {
    const service = new PostouchClientService();

    // primer caso: procesadas correcto (0) pero facturas no vacio
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ procesadas: 0, facturas: [{ tienda: 1 }] }),
    })) as any;
    const resultado1 = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });
    expect(resultado1.ok).toBe(false);

    // segundo caso: facturas vacio (correcto) pero procesadas distinto de 0
    global.fetch = jest.fn(async () => ({
      ok: true,
      text: async () => JSON.stringify({ procesadas: 3, facturas: [] }),
    })) as any;
    const resultado2 = await service.probarConexion({
      url: config.url,
      username: "22",
      password: "5551",
      computadora: "5.5",
    });
    expect(resultado2.ok).toBe(false);
  });
});
