/*
  As fotos do catálogo em PDF.

  O PDF leva cada foto DENTRO dele, em JPEG e no tamanho em que aparece. Foi
  a lição do protótipo: com a foto como veio (WebP do serviço de imagem), o
  Chrome regravava tudo sem compressão e 24 páginas davam 19 MB; em JPEG do
  tamanho certo, 2,4 MB.

  Cada foto é baixada uma vez (versão de 1000 px do serviço de imagem, e o
  original se o serviço recusar), vira uma base de 900 px e ganha uma
  classificação pela borda: **recorte** (produto em fundo branco, entra
  inteiro no quadro) ou **cena** (foto de ambiente, preenche o quadro).

  As versões pequenas só são feitas para as fotos que a página usa de fato:
  o modelo recebe um marcador (`ktfoto:...`) no lugar do endereço, e depois
  de montado o HTML cada marcador vira a foto no tamanho pedido.

  Tudo fica guardado em disco por alguns dias: gerar de novo, trocando só
  uma opção, não baixa nada.
*/

import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import sharp from "sharp";
import { miniatura } from "../../foto.ts";
import { baixar } from "./rede.ts";
import { pastaDoCatalogo } from "./pasta.ts";

type FotoPreparada = { base: Buffer; recorte: boolean };

/* Os tamanhos do desenho aprovado: largura em px e qualidade do JPEG. */
export const TAMANHOS = {
  cartao: { largura: 440, qualidade: 76 },
  grande: { largura: 900, qualidade: 80 },
  capa: { largura: 320, qualidade: 76 },
  mini: { largura: 120, qualidade: 70 },
  extra: { largura: 220, qualidade: 72 },
} as const;

const LIMITE_DO_CACHE = 400 * 1024 * 1024;
const VALIDADE_DO_CACHE_MS = 10 * 24 * 60 * 60 * 1000;

const chaveDe = (url: string) => createHash("sha1").update(url).digest("hex").slice(0, 24);

async function pastaDasFotos() {
  const pasta = path.join(await pastaDoCatalogo(), "fotos");
  await fs.mkdir(pasta, { recursive: true });
  return pasta;
}

async function lerDoCache(arquivo: string) {
  try {
    const conteudo = await fs.readFile(arquivo);
    /* Tocar no arquivo mantém a foto usada longe da faxina. */
    const agora = new Date();
    await fs.utimes(arquivo, agora, agora).catch(() => undefined);
    return conteudo;
  } catch {
    return null;
  }
}

async function gravarNoCache(arquivo: string, conteudo: Buffer | string) {
  const temporario = `${arquivo}.${process.pid}.${Date.now()}.tmp`;
  try {
    await fs.writeFile(temporario, conteudo);
    await fs.rename(temporario, arquivo);
  } catch {
    await fs.rm(temporario, { force: true }).catch(() => undefined);
  }
}

/* A borda quase toda branca entrega o produto recortado em fundo branco. */
async function ehRecorte(base: Buffer) {
  const { data, info } = await sharp(base).resize(40, 40, { fit: "fill" }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
  let borda = 0;
  let branca = 0;
  for (let y = 0; y < info.height; y += 1) {
    for (let x = 0; x < info.width; x += 1) {
      if (x > 1 && x < info.width - 2 && y > 1 && y < info.height - 2) continue;
      const i = (y * info.width + x) * 3;
      borda += 1;
      if (data[i] > 232 && data[i + 1] > 232 && data[i + 2] > 232) branca += 1;
    }
  }
  return branca / borda >= 0.85;
}

const emPreparo = new Map<string, Promise<FotoPreparada | null>>();

/** A base de 900 px e a classificação de uma foto. `null` = não deu. */
export function prepararFoto(url: string): Promise<FotoPreparada | null> {
  const chave = chaveDe(url);
  const andamento = emPreparo.get(chave);
  if (andamento) return andamento;
  const trabalho = (async () => {
    const pasta = await pastaDasFotos();
    const arquivoBase = path.join(pasta, `${chave}.jpg`);
    const arquivoInfo = path.join(pasta, `${chave}.json`);
    const [guardada, info] = await Promise.all([lerDoCache(arquivoBase), lerDoCache(arquivoInfo)]);
    if (guardada && info) {
      try {
        return { base: guardada, recorte: JSON.parse(info.toString("utf8")).recorte === true };
      } catch {
        /* info estragada: refaz */
      }
    }
    const reduzida = miniatura(url, 1000, 78);
    const bruto = (reduzida !== url ? await baixar(`${reduzida}&format=origin`) : null) || (await baixar(url));
    if (!bruto) return null;
    try {
      const base = await sharp(bruto, { failOn: "none" })
        .rotate()
        .flatten({ background: "#FFFFFF" })
        .resize({ width: 900, height: 900, fit: "inside", withoutEnlargement: true })
        .jpeg({ quality: 82, mozjpeg: true })
        .toBuffer();
      const recorte = await ehRecorte(base);
      await gravarNoCache(arquivoBase, base);
      await gravarNoCache(arquivoInfo, JSON.stringify({ recorte }));
      return { base, recorte };
    } catch {
      return null;
    }
  })().finally(() => emPreparo.delete(chave));
  emPreparo.set(chave, trabalho);
  return trabalho;
}

async function versao(url: string, base: Buffer, largura: number, qualidade: number) {
  const arquivo = path.join(await pastaDasFotos(), `${chaveDe(url)}-${largura}-${qualidade}.jpg`);
  const guardada = await lerDoCache(arquivo);
  if (guardada) return guardada;
  const feita = await sharp(base)
    .resize({ width: largura, height: largura, fit: "inside", withoutEnlargement: true })
    .jpeg({ quality: qualidade, mozjpeg: true })
    .toBuffer();
  await gravarNoCache(arquivo, feita);
  return feita;
}

/* ------------------------------------------------------------ marcadores */

/*
  O endereço que o modelo recebe no lugar da foto. Só letras, números e
  dois-pontos: passa intacto pelo escape de HTML do modelo.
*/
const MARCADOR = /ktfoto:([0-9a-f]{24}):(\d{2,4}):(\d{2,3})/g;

export class FotosDoCatalogo {
  #urls = new Map<string, string>();
  #bases = new Map<string, Buffer>();

  /** Registra uma foto já preparada e devolve o marcador do tamanho pedido. */
  marcador(url: string, preparada: FotoPreparada, tamanho: keyof typeof TAMANHOS) {
    const chave = chaveDe(url);
    this.#urls.set(chave, url);
    this.#bases.set(chave, preparada.base);
    const { largura, qualidade } = TAMANHOS[tamanho];
    return `ktfoto:${chave}:${largura}:${qualidade}`;
  }

  /** Troca cada marcador do HTML pela foto em JPEG, feita só agora. */
  async embutir(html: string, aoProgredir?: (feitas: number, total: number) => void) {
    const pedidos = [...new Set(Array.from(html.matchAll(MARCADOR), (achado) => achado[0]))];
    const prontas = new Map<string, string>();
    let feitas = 0;
    await emLotes(pedidos, 4, async (marcador) => {
      const [, chave, largura, qualidade] = marcador.split(":");
      const url = this.#urls.get(chave);
      const base = this.#bases.get(chave);
      if (url && base) {
        const jpeg = await versao(url, base, Number(largura), Number(qualidade));
        prontas.set(marcador, `data:image/jpeg;base64,${jpeg.toString("base64")}`);
      }
      feitas += 1;
      aoProgredir?.(feitas, pedidos.length);
    });
    /* Marcador sem foto não sobra no HTML: vira imagem vazia, que o Chrome
       nem tenta buscar. */
    return html.replace(MARCADOR, (marcador) => prontas.get(marcador) || "data:,");
  }
}

/* Poucas de cada vez, para não levar recusa do serviço de imagem. */
export async function emLotes<T>(itens: T[], tamanho: number, fazer: (item: T) => Promise<void>) {
  let proximo = 0;
  const trabalhadores = Array.from({ length: Math.min(tamanho, itens.length) }, async () => {
    while (proximo < itens.length) {
      const item = itens[proximo];
      proximo += 1;
      await fazer(item);
    }
  });
  await Promise.all(trabalhadores);
}

/* --------------------------------------------------------------- faxina */

let ultimaFaxina = 0;

/**
 * Mantém a pasta das fotos abaixo do limite: apaga o que ninguém usa há
 * dias e, se ainda passar, as mais antigas primeiro. Roda no máximo uma vez
 * por hora, sempre depois de um catálogo pronto.
 */
export async function faxinaDasFotos() {
  if (Date.now() - ultimaFaxina < 60 * 60 * 1000) return;
  ultimaFaxina = Date.now();
  try {
    const pasta = await pastaDasFotos();
    const nomes = await fs.readdir(pasta);
    const arquivos = (
      await Promise.all(
        nomes.map(async (nome) => {
          const caminho = path.join(pasta, nome);
          const info = await fs.stat(caminho).catch(() => null);
          return info?.isFile() ? { caminho, tamanho: info.size, tocado: info.mtimeMs } : null;
        })
      )
    ).filter((item): item is { caminho: string; tamanho: number; tocado: number } => Boolean(item));
    const agora = Date.now();
    let total = 0;
    const vivos: typeof arquivos = [];
    for (const arquivo of arquivos) {
      if (agora - arquivo.tocado > VALIDADE_DO_CACHE_MS) await fs.rm(arquivo.caminho, { force: true }).catch(() => undefined);
      else {
        vivos.push(arquivo);
        total += arquivo.tamanho;
      }
    }
    vivos.sort((a, b) => a.tocado - b.tocado);
    for (const arquivo of vivos) {
      if (total <= LIMITE_DO_CACHE) break;
      await fs.rm(arquivo.caminho, { force: true }).catch(() => undefined);
      total -= arquivo.tamanho;
    }
  } catch {
    /* faxina é cortesia: não derruba catálogo nenhum */
  }
}
