/* ============================================================
 * Copyright (c) 2026 RAVAPI Soluções. www.ravapi.com
 * Integração Fases 1–3 contra PostgreSQL real
 *   node --experimental-transform-types --test src/__tests__/integracao.test.ts
 * ============================================================ */
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import { randomUUID } from 'node:crypto';

import { comContexto, emTransacao } from '../db/client.ts';
import {
  TenantAusente, SemPermissao, LicencaBloqueada, DadosInvalidos, LimiteDoPlano, ErroDeNegocio,
  MuitasRequisicoes, NaoEncontrado,
} from '../core/errors.ts';
import { canonicalizar, calcularHash, mascararNome, mascararDocumento, BLOCO_GENESE, type PayloadRecibo } from '../core/canonical.ts';
import { CadastrosService, documentoValido, digitos } from '../domain/cadastros.service.ts';
import { UsuariosService } from '../domain/usuarios.service.ts';
import { LicencaService, estadoPorData, podeSelar, toleranciaEfetiva } from '../domain/licenca.service.ts';
import { RecibosService, definirPartes, verboDeQuitacao, corpoDoRecibo } from '../domain/recibos.service.ts';
import { DESCRICAO_MAXIMA } from '../render/template.ts';
import { ImpressaoService } from '../domain/impressao.service.ts';
import { VerificacaoService } from '../domain/verificacao.service.ts';
import { RateLimitService } from '../domain/rate-limit.service.ts';
import { AssetsService } from '../domain/assets.service.ts';
import type { StorageAdapter } from '../domain/storage.ts';
import type { UsuarioRef } from '../core/identity.ts';

const IP_TESTE = '203.0.113.10';

const TENANT = '11111111-1111-1111-1111-111111111111';
const EMPRESA = 'a0000000-0000-0000-0000-0000000000c1';
const SERIE_R = 'a0000000-0000-0000-0000-00000000005a';
const SERIE_P = 'a0000000-0000-0000-0000-00000000005b';

let pool: pg.Pool;
let cadastros: CadastrosService;
let usuarios: UsuariosService;
let licenca: LicencaService;
let recibos: RecibosService;
let impressao: ImpressaoService;
let verificacao: VerificacaoService;
let limitePlano = 10;

const supervisor: UsuarioRef = {
  id: 'a0000000-0000-0000-0000-000000000a01', nome: 'Vitória Campos',
  email: 'vitoria@dc.com.br', papel: 'SUPERVISOR', seriesPermitidas: [SERIE_R, SERIE_P],
};
const emissorSoR: UsuarioRef = {
  id: 'a0000000-0000-0000-0000-000000000a02', nome: 'Anderson Ribeiro',
  email: 'anderson@dc.com.br', papel: 'EMISSOR', seriesPermitidas: [SERIE_R],
};
const proprietario: UsuarioRef = {
  id: 'a0000000-0000-0000-0000-000000000a03', nome: 'Rafael',
  email: 'rafael@ravapi.com', papel: 'PROPRIETARIO', seriesPermitidas: [],
};
const auditor: UsuarioRef = {
  id: 'a0000000-0000-0000-0000-000000000a04', nome: 'Eliane Corrêa',
  email: 'eliane@contabil.com.br', papel: 'AUDITOR', seriesPermitidas: [],
};

const ctx = <T>(fn: () => T) => comContexto({ tenantId: TENANT }, fn);

before(async () => {
  pool = new pg.Pool({
    host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
    user: 'recibos_app', database: process.env.PGDATABASE ?? 'recibos_int', max: 8,
  });
  const admin = new pg.Pool({
    host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
    user: 'postgres', database: process.env.PGDATABASE ?? 'recibos_int',
  });
  await admin.query(`SET app.tenant_id = '${TENANT}'`);
  await admin.query(
    `INSERT INTO recibos.users (id, tenant_id, nome, email, papel, status)
     VALUES ($1, $3, 'Rafael', 'rafael@ravapi.com', 'PROPRIETARIO', 'ATIVO'),
            ($2, $3, 'Eliane Corrêa', 'eliane@contabil.com.br', 'AUDITOR', 'ATIVO')
     ON CONFLICT (tenant_id, email) DO NOTHING`,
    [proprietario.id, auditor.id, TENANT],
  );
  await admin.query(
    `INSERT INTO recibos.license_state (tenant_id, plano, status, modo, limites, valido_ate, grace_dias)
     VALUES ($1,'profissional','ATIVA','saas','{"usuarios":10,"recibos_mes":500}'::jsonb,
             now() + interval '60 days', 15)
     ON CONFLICT (tenant_id) DO UPDATE SET valido_ate = now() + interval '60 days', status='ATIVA'`,
    [TENANT],
  );
  await admin.end();

  cadastros = new CadastrosService(pool as any);
  usuarios = new UsuariosService(pool as any, () => limitePlano);
  licenca = new LicencaService(pool as any);
  recibos = new RecibosService(pool as any, licenca);
  impressao = new ImpressaoService(pool as any, 'https://ravapi.com/v');
  verificacao = new VerificacaoService(pool as any, new RateLimitService(pool as any));
});

after(async () => { await pool?.end(); });

/* ================= contexto de tenant ================= */

test('sem tenant no contexto a operação ESTOURA, em vez de devolver lista vazia', async () => {
  await assert.rejects(
    () => emTransacao(pool as any, async (x) => x.query('SELECT 1')),
    TenantAusente,
  );
});

test('o tenant fica preso à transação e não vaza para a próxima conexão do pool', async () => {
  await ctx(async () => {
    const dentro = await emTransacao(pool as any, async (x) =>
      (await x.query(`SELECT current_setting('app.tenant_id', true) AS t`)).rows[0].t);
    assert.equal(dentro, TENANT);
  });
  // conexão nova, sem set_config: o valor não pode ter sobrado
  const c = await pool.connect();
  const { rows } = await c.query(`SELECT current_setting('app.tenant_id', true) AS t`);
  c.release();
  assert.ok(!rows[0].t, `tenant vazou entre conexões: ${rows[0].t}`);
});

/* ================= validação de documento ================= */

test('CPF e CNPJ são conferidos pelos dígitos verificadores', () => {
  assert.ok(documentoValido('529.982.247-25'));
  assert.ok(documentoValido('11.222.333/0001-81'));
  assert.ok(!documentoValido('111.111.111-11'));
  assert.ok(!documentoValido('529.982.247-26'));
  assert.ok(!documentoValido('123'));
});

test('contraparte com documento inválido é recusada com mensagem clara', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => cadastros.criarContraparte(supervisor, {
        tipoPessoa: 'PF', nomeRazaoSocial: 'Fulano', cpfCnpj: '111.111.111-11',
      }),
      (e: any) => e instanceof DadosInvalidos && /dígitos verificadores/.test(e.message),
    );
  });
});

test('tipo de pessoa incoerente com o documento é recusado', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => cadastros.criarContraparte(supervisor, {
        tipoPessoa: 'PJ', nomeRazaoSocial: 'Fulano', cpfCnpj: '529.982.247-25',
      }),
      (e: any) => /11 dígitos, o que é PF/.test(e.message),
    );
  });
});

/* ================= papéis ================= */

test('emissor liberado só na série R não consegue emitir na série P', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => recibos.salvarRascunho(emissorSoR, {
        seriesId: SERIE_P, valorCentavos: 1000, descricao: 'x',
        formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
      }),
      SemPermissao,
    );
  });
});

test('auditor não emite nada', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => recibos.salvarRascunho(auditor, {
        seriesId: SERIE_R, valorCentavos: 1000, descricao: 'x',
        formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
      }),
      SemPermissao,
    );
  });
});

test('proprietário emite em qualquer série, mesmo sem permissão explícita', async () => {
  await ctx(async () => {
    const r = await recibos.salvarRascunho(proprietario, {
      seriesId: SERIE_P, valorCentavos: 1000, descricao: 'teste de permissão',
      formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    assert.ok(r.id);
  });
});

/* ================= direção ================= */

const BUFFET = { nome: 'Buffet Dona Conceição Ltda', documento: '18.442.907/0001-63', tipo: 'PJ' as const };
const RICARDO = { nome: 'Ricardo Alves Teixeira', documento: '529.982.247-25', tipo: 'PF' as const };

test('recebimento: a empresa é credora e escreve no plural', () => {
  const p = definirPartes('RECEBIMENTO', BUFFET, RICARDO);
  assert.equal(p.credor.nome, BUFFET.nome);
  assert.equal(p.devedor.nome, RICARDO.nome);
  assert.equal(p.emitentePapel, 'CREDOR');
  assert.equal(verboDeQuitacao(p.credor), 'Recebemos');
});

test('pagamento: a contraparte é credora e, sendo PF, escreve no singular', () => {
  const p = definirPartes('PAGAMENTO', BUFFET, RICARDO);
  assert.equal(p.credor.nome, RICARDO.nome);
  assert.equal(p.devedor.nome, BUFFET.nome);
  assert.equal(p.emitentePapel, 'DEVEDOR');
  assert.equal(verboDeQuitacao(p.credor), 'Recebi');
});

test('o corpo do recibo inverte junto com a direção', () => {
  const entrada = corpoDoRecibo('RECEBIMENTO', BUFFET, RICARDO, 185000, 'evento corporativo');
  const saida = corpoDoRecibo('PAGAMENTO', BUFFET, RICARDO, 22000, 'diária de cozinha');
  assert.match(entrada, /^Recebemos de Ricardo Alves Teixeira/);
  assert.match(entrada, /mil, oitocentos e cinquenta reais/);
  assert.match(saida, /^Recebi de Buffet Dona Conceição Ltda/);
  assert.match(saida, /duzentos e vinte reais/);
});

/* ================= selo e hash ================= */

/**
 * Gera CPF/CNPJ válidos e distintos para os fixtures.
 *
 * Antes da migration 005 os testes cadastravam dezenas de contrapartes com o
 * mesmo CNPJ, o que só passava porque não havia unicidade. Com a restrição no
 * lugar, cada fixture precisa do próprio documento — e isso é bom: fixture que
 * compartilha identidade esconde bug de identidade.
 */
let sequencia = 0;

function dvCpf(base: string): string {
  const calc = (n: number) => {
    let soma = 0;
    for (let i = 0; i < n; i++) soma += Number(base[i]) * (n + 1 - i);
    return (soma * 10) % 11 % 10;
  };
  const d1 = calc(9);
  const d2 = ((() => {
    let soma = 0;
    const comD1 = base + d1;
    for (let i = 0; i < 10; i++) soma += Number(comD1[i]) * (11 - i);
    return (soma * 10) % 11 % 10;
  })());
  return `${base}${d1}${d2}`;
}

function dvCnpj(base: string): string {
  const calc = (b: string, pesos: number[]) => {
    const soma = b.split('').reduce((acc, n, i) => acc + Number(n) * pesos[i], 0);
    const r = soma % 11;
    return r < 2 ? 0 : 11 - r;
  };
  const d1 = calc(base, [5,4,3,2,9,8,7,6,5,4,3,2]);
  const d2 = calc(base + d1, [6,5,4,3,2,9,8,7,6,5,4,3,2]);
  return `${base}${d1}${d2}`;
}

function documentoNovo(tipo: 'PF' | 'PJ'): string {
  sequencia += 1;
  return tipo === 'PF'
    ? dvCpf(String(100000000 + sequencia).slice(0, 9))
    : dvCnpj(String(100000000000 + sequencia).slice(0, 12));
}

/** O documento passado serve só para decidir PF ou PJ; o valor é gerado. */
async function contraparteDeTeste(nome: string, doc: string, tipo: 'PF' | 'PJ') {
  return ctx(() => cadastros.criarContraparte(supervisor, {
    tipoPessoa: tipo, nomeRazaoSocial: nome, cpfCnpj: documentoNovo(tipo),
    cidade: 'Ipatinga', uf: 'MG',
  }));
}

test('selar gera número, extenso, código público e hash a partir do bloco gênese', async () => {
  const cp = await contraparteDeTeste('Marcos Vinícius Andrade', '529.982.247-25', 'PF');
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 185000,
      descricao: 'serviços de buffet para evento corporativo',
      formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    assert.equal(recibo.numero_formatado, 'R-000001');
    assert.equal(recibo.direcao, 'RECEBIMENTO');
    assert.equal(recibo.valor_extenso, 'mil, oitocentos e cinquenta reais');
    assert.equal(recibo.hash_anterior, BLOCO_GENESE);
    assert.match(recibo.codigo_verificacao, /^[0-9A-HJKMNP-TV-Z]{4}-/);
    assert.equal(recibo.credor_snapshot.nome, 'Buffet Dona Conceição Ltda');
    assert.equal(recibo.devedor_snapshot.nome, 'Marcos Vinícius Andrade');
  });
});

test('O HASH FECHA: recalculado em TypeScript bate com o que o Postgres gravou', async () => {
  const cp = await contraparteDeTeste('Conceição Assunção & Filhos Ltda', '11.222.333/0001-81', 'PJ');
  await ctx(async () => {
    const { recibo, canonico } = await recibos.selar(supervisor, {
      seriesId: SERIE_P, counterpartyId: cp.id, valorCentavos: 45075,
      // acentuação, cedilha, til e aspas — o caminho mais curto para
      // uma divergência de encoding entre a aplicação e o banco
      descricao: 'manutenção de coifa, instalação de exaustão e revisão — ordem "OS-4412"',
      formaPagamento: 'Transferência', dataPagamento: '2026-09-11', localPagamento: 'João Monlevade/MG',
    });
    const recalculado = calcularHash(canonico, recibo.hash_anterior);
    assert.equal(recalculado, recibo.hash_documento,
      'a string canônica do TypeScript e o SHA-256 do Postgres divergiram');
    assert.equal(recibo.valor_extenso, 'quatrocentos e cinquenta reais e setenta e cinco centavos');
  });
});

test('a cadeia encadeia: o segundo documento aponta para o hash do primeiro', async () => {
  const cp = await contraparteDeTeste('Escola Semear Ltda', '11.222.333/0001-81', 'PJ');
  await ctx(async () => {
    const a = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 1000,
      descricao: 'primeiro', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    const b = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 2000,
      descricao: 'segundo', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    assert.equal(b.recibo.hash_anterior, a.recibo.hash_documento);
    assert.equal(Number(b.recibo.sequencia_cadeia), Number(a.recibo.sequencia_cadeia) + 1);
  });
});

test('série nova começa no próprio gênese, mesmo com outras já emitindo', async () => {
  const cp = await contraparteDeTeste('Vale Frios Distribuidora', '11.222.333/0001-81', 'PJ');
  await ctx(async () => {
    const jaEmitidos = await emTransacao(pool as any, async (x) =>
      (await x.query('SELECT count(*)::int AS n FROM recibos.receipts')).rows[0].n);
    assert.ok(jaEmitidos > 0, 'precisa haver documentos em outras séries para o teste valer');

    const nova = await cadastros.criarSerie(proprietario, {
      companyId: EMPRESA, codigo: 'X', descricao: 'Série nova de teste', direcao: 'PAGAMENTO',
    });

    const { recibo } = await recibos.selar(
      { ...proprietario }, {
        seriesId: nova.id, counterpartyId: cp.id, valorCentavos: 231000,
        descricao: 'compra de insumos', formaPagamento: 'Transferência',
        dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
      });

    assert.equal(recibo.numero_formatado, 'X-000001');
    assert.equal(recibo.hash_anterior, BLOCO_GENESE,
      'a primeira emissão de uma série aponta para o gênese dela, não para a cadeia de outra série');
    assert.equal(Number(recibo.sequencia_cadeia), 1);
    assert.equal(recibo.direcao, 'PAGAMENTO');
  });
});

test('séries diferentes emitem em paralelo: o lock de uma não segura a outra', async () => {
  const cp = await contraparteDeTeste('Gráfica Monlevade ME', '11.222.333/0001-81', 'PJ');

  // Transação que segura o lock da série R e não solta
  const travador = new pg.Client({
    host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
    user: 'recibos_app', database: process.env.PGDATABASE ?? 'recibos_int',
  });
  await travador.connect();
  await travador.query('BEGIN');
  await travador.query(`SELECT set_config('app.tenant_id', $1, true)`, [TENANT]);
  await travador.query('SELECT id FROM recibos.series WHERE id = $1 FOR UPDATE', [SERIE_R]);

  try {
    // Com a série R travada, selar na série P não pode esperar.
    // statement_timeout curto: se houvesse contenção entre séries, estouraria.
    const rapido = new pg.Pool({
      host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
      user: 'recibos_app', database: process.env.PGDATABASE ?? 'recibos_int',
      statement_timeout: 3000, max: 2,
    });
    const servicoRapido = new RecibosService(rapido as any, new LicencaService(rapido as any));

    await ctx(async () => {
      const { recibo } = await servicoRapido.selar(supervisor, {
        seriesId: SERIE_P, counterpartyId: cp.id, valorCentavos: 12000,
        descricao: 'impressão de cardápios', formaPagamento: 'PIX',
        dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
      });
      assert.match(recibo.numero_formatado, /^P-/);
    });
    await rapido.end();
  } finally {
    await travador.query('ROLLBACK');
    await travador.end();
  }
});

test('valor zero, descrição vazia e data fora do formato são recusados antes de tocar no banco', async () => {
  await ctx(async () => {
    const base = {
      seriesId: SERIE_R, counterpartyId: null, valorCentavos: 1000, descricao: 'ok',
      formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    };
    await assert.rejects(() => recibos.selar(supervisor, { ...base, valorCentavos: 0 }), DadosInvalidos);
    await assert.rejects(() => recibos.selar(supervisor, { ...base, valorCentavos: 10.5 }), DadosInvalidos);
    await assert.rejects(() => recibos.selar(supervisor, { ...base, descricao: '  ' }), DadosInvalidos);
    await assert.rejects(() => recibos.selar(supervisor, { ...base, dataPagamento: '11/09/2026' }), DadosInvalidos);
  });
});

/* ================= cancelamento ================= */

test('emissor não cancela; supervisor cancela e o documento continua existindo', async () => {
  const cp = await contraparteDeTeste('Condomínio Aurora', '11.222.333/0001-81', 'PJ');
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 55500,
      descricao: 'sinal de evento', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    await assert.rejects(() => recibos.cancelar(emissorSoR, recibo.id, 'lançado errado'), SemPermissao);
    await assert.rejects(() => recibos.cancelar(supervisor, recibo.id, 'oops'), DadosInvalidos);

    const c = await recibos.cancelar(supervisor, recibo.id, 'valor lançado errado pelo operador');
    assert.equal(c.receipt_id, recibo.id);

    const ainda = await emTransacao(pool as any, async (x) =>
      (await x.query('SELECT count(*)::int AS n FROM recibos.receipts WHERE id = $1', [recibo.id])).rows[0].n);
    assert.equal(ainda, 1, 'cancelar não pode apagar o documento');
  });
});

/* ================= usuários e convites ================= */

test('convite pendente ocupa lugar no plano', async () => {
  await ctx(async () => {
    limitePlano = 1000;
    const { token } = await usuarios.convidar(proprietario, {
      nome: 'Patrícia Nunes', email: 'patricia@dc.com.br', papel: 'EMISSOR', seriesPermitidas: [SERIE_P],
    });
    assert.ok(token.length > 20);

    limitePlano = 1;   // já há mais de um usuário: o próximo convite estoura
    await assert.rejects(
      () => usuarios.convidar(proprietario, { nome: 'X', email: 'x@dc.com.br', papel: 'EMISSOR' }),
      LimiteDoPlano,
    );
    limitePlano = 1000;
  });
});

test('supervisor não pode convidar — só proprietário gere usuários', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => usuarios.convidar(supervisor, { nome: 'Y', email: 'y@dc.com.br', papel: 'EMISSOR' }),
      SemPermissao,
    );
  });
});

test('não dá para liberar séries para auditor, que não emite', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => usuarios.convidar(proprietario, {
        nome: 'Z', email: 'z@dc.com.br', papel: 'AUDITOR', seriesPermitidas: [SERIE_R],
      }),
      (e: any) => /Auditor não emite/.test(e.message),
    );
  });
});

test('convite com token errado ou vencido não é aceito', async () => {
  await ctx(async () => {
    await assert.rejects(() => usuarios.aceitarConvite('token-inventado', 'hash'),
      (e: any) => /inválido, já usado ou vencido/.test(e.message));
  });
});

test('aceitar convite ativa o usuário e queima o token', async () => {
  await ctx(async () => {
    const { token } = await usuarios.convidar(proprietario, {
      nome: 'Lucas Ferraz', email: 'lucas@dc.com.br', papel: 'EMISSOR', seriesPermitidas: [SERIE_R],
    });
    const u = await usuarios.aceitarConvite(token, 'argon2-hash-fake');
    assert.equal(u.email, 'lucas@dc.com.br');
    await assert.rejects(() => usuarios.aceitarConvite(token, 'outro'), DadosInvalidos);
  });
});

test('o último proprietário ativo não pode ser desativado', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => usuarios.desativar(proprietario, proprietario.id),
      (e: any) => /própria conta/.test(e.message),
    );
    const outro = await usuarios.convidar(proprietario, {
      nome: 'Sócio', email: 'socio@dc.com.br', papel: 'PROPRIETARIO',
    });
    // agora há dois proprietários, mas um deles ainda é CONVIDADO (não ATIVO)
    await assert.rejects(
      () => usuarios.desativar({ ...proprietario, id: outro.id }, proprietario.id),
      (e: any) => /último proprietário ativo/.test(e.message),
    );
  });
});

test('o limite de usuários vem da licença, não de constante no código', async () => {
  const admin = new pg.Pool({
    host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
    user: 'postgres', database: process.env.PGDATABASE ?? 'recibos_int',
  });
  const servicoLigadoNaLicenca = new UsuariosService(
    pool as any,
    async () => (await licenca.atual()).limites.usuarios ?? Number.MAX_SAFE_INTEGER,
  );

  // plano apertado: um lugar só, e já há gente cadastrada
  await admin.query(
    `UPDATE recibos.license_state SET limites = '{"usuarios":1,"recibos_mes":500}'::jsonb
      WHERE tenant_id = $1`, [TENANT],
  );
  await ctx(async () => {
    await assert.rejects(
      () => servicoLigadoNaLicenca.convidar(proprietario, {
        nome: 'Excedente', email: 'excedente@dc.com.br', papel: 'EMISSOR',
      }),
      LimiteDoPlano,
    );
  });

  // upgrade de plano libera na hora, sem deploy
  await admin.query(
    `UPDATE recibos.license_state SET limites = '{"usuarios":50,"recibos_mes":500}'::jsonb
      WHERE tenant_id = $1`, [TENANT],
  );
  await ctx(async () => {
    const { token } = await servicoLigadoNaLicenca.convidar(proprietario, {
      nome: 'Excedente', email: 'excedente@dc.com.br', papel: 'EMISSOR',
    });
    assert.ok(token);
  });
  await admin.end();
});

/* ================= limite da descrição ================= */

test('descrição acima de 300 caracteres é recusada, com o número na mensagem', async () => {
  const cp = await contraparteDeTeste('Casa das Toalhas Ltda', '11.222.333/0001-81', 'PJ');
  await ctx(async () => {
    const base = {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 5000,
      formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    };
    // exatamente no limite: passa
    const noLimite = await recibos.selar(supervisor, { ...base, descricao: 'a'.repeat(DESCRICAO_MAXIMA) });
    assert.equal(noLimite.recibo.descricao.length, DESCRICAO_MAXIMA);

    // um caractere acima: recusa
    await assert.rejects(
      () => recibos.selar(supervisor, { ...base, descricao: 'a'.repeat(DESCRICAO_MAXIMA + 1) }),
      (e: any) => e instanceof DadosInvalidos
        && /301 caracteres e o limite é 300/.test(e.message)
        && /meia folha A4/.test(e.message),
    );
  });
});

test('o rascunho também recusa descrição longa, antes de chegar ao selo', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => recibos.salvarRascunho(supervisor, {
        seriesId: SERIE_R, valorCentavos: 5000, descricao: 'x'.repeat(500),
        formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
      }),
      DadosInvalidos,
    );
  });
});

test('o banco é a rede de baixo: o CHECK recusa mesmo sem passar pela aplicação', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => emTransacao(pool as any, async (x) =>
        x.query(
          `INSERT INTO recibos.receipt_drafts
             (tenant_id, company_id, series_id, direcao, valor_centavos, descricao,
              forma_pagamento, data_pagamento, local_pagamento, criado_por)
           VALUES (current_setting('app.tenant_id')::uuid, $1, $2, 'RECEBIMENTO', 100, $3,
                   'PIX', '2026-09-11', 'Ipatinga', $4)`,
          [EMPRESA, SERIE_R, 'z'.repeat(400), supervisor.id],
        )),
      (e: any) => e.code === '23514' && /descricao_cabe_na_via/.test(e.constraint),
    );
  });
});

/* ================= documento único e cadastro rápido ================= */

test('digitos() normaliza do mesmo jeito que o índice do banco', () => {
  assert.equal(digitos('529.982.247-25'), '52998224725');
  assert.equal(digitos('52998224725'), '52998224725');
  assert.equal(digitos('11.222.333/0001-81'), '11222333000181');
});

test('o mesmo CPF não entra duas vezes, nem com pontuação diferente', async () => {
  await ctx(async () => {
    await cadastros.criarContraparte(supervisor, {
      tipoPessoa: 'PF', nomeRazaoSocial: 'Cleide Martins Pereira', cpfCnpj: '298.647.510-85',
    });
    // mesmo documento, escrito sem pontuação
    await assert.rejects(
      () => cadastros.criarContraparte(supervisor, {
        tipoPessoa: 'PF', nomeRazaoSocial: 'Cleide M. Pereira', cpfCnpj: '29864751085',
      }),
      (e: any) => e instanceof DadosInvalidos
        && /já está cadastrado para Cleide Martins Pereira/.test(e.message),
    );
  });
});

test('porDocumento acha ignorando a formatação digitada', async () => {
  await ctx(async () => {
    const achada = await cadastros.porDocumento('29864751085');
    assert.equal(achada.nome_razao_social, 'Cleide Martins Pereira');
    assert.equal((await cadastros.porDocumento('298.647.510-85')).id, achada.id);
    assert.equal(await cadastros.porDocumento('111.444.777-35'), null);
  });
});

test('cadastro rápido: CPF novo cria, CPF existente devolve a mesma pessoa', async () => {
  await ctx(async () => {
    const nova = await cadastros.localizarOuCriar(supervisor, {
      tipoPessoa: 'PF', nomeRazaoSocial: 'Wagner Dias Ferreira', cpfCnpj: '111.444.777-35',
      cidade: 'Ipatinga', uf: 'MG',
    });
    assert.ok(nova.criada);

    const denovo = await cadastros.localizarOuCriar(supervisor, {
      tipoPessoa: 'PF', nomeRazaoSocial: 'Wagner D. F.', cpfCnpj: '11144477735',
    });
    assert.ok(!denovo.criada, 'não pode duplicar');
    assert.equal(denovo.contraparte.id, nova.contraparte.id);
    assert.equal(denovo.contraparte.nome_razao_social, 'Wagner Dias Ferreira',
      'o cadastro existente manda; o rascunho não sobrescreve o nome');
  });
});

test('contraparte inativa é reativada, nunca recriada', async () => {
  await ctx(async () => {
    const alvo = await cadastros.porDocumento('11144477735');
    await emTransacao(pool as any, async (x) =>
      x.query('UPDATE recibos.counterparties SET ativo = false WHERE id = $1', [alvo.id]));

    const r = await cadastros.localizarOuCriar(supervisor, {
      tipoPessoa: 'PF', nomeRazaoSocial: 'Wagner Dias Ferreira', cpfCnpj: '111.444.777-35',
    });
    assert.ok(r.reativada);
    assert.ok(!r.criada);
    assert.equal(r.contraparte.id, alvo.id,
      'recriar quebraria o vínculo com os recibos já emitidos');
    assert.equal(r.contraparte.ativo, true);
  });
});

test('cadastro rápido recusa documento inválido antes de tocar no banco', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => cadastros.localizarOuCriar(supervisor, {
        tipoPessoa: 'PF', nomeRazaoSocial: 'Ninguém', cpfCnpj: '111.111.111-11',
      }),
      DadosInvalidos,
    );
  });
});

/* ================= trial ================= */

test('a tolerância é zero durante o trial, independente do que a licença disser', () => {
  assert.equal(toleranciaEfetiva(15, false), 15);
  assert.equal(toleranciaEfetiva(15, true), 0, 'trial + tolerância daria 30 dias grátis');
  assert.equal(toleranciaEfetiva(0, true), 0);
});

test('trial vencido bloqueia na hora, mesmo com grace_dias 15 gravado no banco', async () => {
  const admin = new pg.Pool({
    host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
    user: 'postgres', database: process.env.PGDATABASE ?? 'recibos_int',
  });
  const cp = await contraparteDeTeste('Locadora Som e Luz ME', '11.222.333/0001-81', 'PJ');

  // trial vencido ontem, mas o Klavo mandou tolerância de 15 dias por engano
  await admin.query(
    `UPDATE recibos.license_state
        SET em_trial = true, grace_dias = 15, valido_ate = now() - interval '1 day'
      WHERE tenant_id = $1`, [TENANT],
  );
  await ctx(async () => {
    const l = await licenca.atual();
    assert.ok(l.emTrial);
    assert.equal(l.graceDias, 0, 'a trava do produto tem que ignorar a tolerância do trial');
    assert.equal(l.status, 'BLOQUEADA');

    await assert.rejects(
      () => recibos.selar(supervisor, {
        seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 100,
        descricao: 'depois do trial', formaPagamento: 'PIX',
        dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
      }),
      LicencaBloqueada,
    );

    // e leitura continua, como sempre
    const lidos = await emTransacao(pool as any, async (x) =>
      (await x.query('SELECT count(*)::int AS n FROM recibos.receipts')).rows[0].n);
    assert.ok(lidos > 0);
  });

  // fora do trial, a mesma data ficaria apenas em tolerância
  await admin.query(`UPDATE recibos.license_state SET em_trial = false WHERE tenant_id = $1`, [TENANT]);
  await ctx(async () => {
    const l = await licenca.atual();
    assert.equal(l.status, 'GRACE', 'cliente pagante com boleto atrasado 1 dia continua emitindo');
    assert.ok(podeSelar(l));
  });

  await admin.query(
    `UPDATE recibos.license_state SET valido_ate = now() + interval '60 days' WHERE tenant_id = $1`,
    [TENANT]);
  await admin.end();
});

/* ================= impressão (fase 4) ================= */

test('o documento impresso sai do snapshot congelado, não do cadastro de hoje', async () => {
  const admin = new pg.Pool({
    host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
    user: 'postgres', database: process.env.PGDATABASE ?? 'recibos_int',
  });
  const cp = await contraparteDeTeste('Marcenaria São José Ltda', '11.222.333/0001-81', 'PJ');
  let id = '';
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_P, counterpartyId: cp.id, valorCentavos: 78000,
      descricao: 'restauração de mesas do salão',
      formaPagamento: 'Transferência', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    id = recibo.id;
  });

  // a contraparte muda de nome DEPOIS do selo
  await admin.query(
    `UPDATE recibos.counterparties SET nome_razao_social = 'Outro Nome Totalmente Diferente ME'
      WHERE id = $1`, [cp.id],
  );
  await admin.end();

  await ctx(async () => {
    const doc = await impressao.montar(supervisor, id);
    assert.equal(doc.credor.nome, 'Marcenaria São José Ltda',
      'o recibo tem que sair como saiu no dia da emissão');
    assert.ok(!JSON.stringify(doc).includes('Outro Nome Totalmente Diferente'));
  });
});

test('o QR aponta para a URL pública com o código de verificação do documento', async () => {
  const cp = await contraparteDeTeste('Floricultura Bem-Me-Quer', '11.222.333/0001-81', 'PJ');
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 31000,
      descricao: 'decoração de mesa para evento', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    const doc = await impressao.montar(supervisor, recibo.id);
    assert.equal(doc.urlVerificacao, `https://ravapi.com/v/${recibo.codigo_verificacao}`);
    assert.match(doc.qrSvg, /^<svg/);
    assert.ok(doc.qrSvg.length > 500, 'QR vazio ou degenerado');
  });
});

test('o HTML final de um pagamento traz as duas vias corretas', async () => {
  const cp = await contraparteDeTeste('Joana Batista de Souza', '529.982.247-25', 'PF');
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_P, counterpartyId: cp.id, valorCentavos: 18000,
      descricao: 'diária de apoio de cozinha', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    const html = await impressao.html(supervisor, recibo.id);

    assert.equal((html.match(/<section class="via/g) ?? []).length, 2);
    assert.match(html, /RECIBO DE PAGAMENTO/);
    // credor PF: singular
    assert.match(html, /Recebi de <strong>Buffet Dona Conceição Ltda<\/strong>/);
    // 1ª via fica com quem pagou, que no pagamento é a empresa
    const primeiraVia = html.slice(html.indexOf('<section class="via'), html.indexOf('<div class="corte">'));
    assert.match(primeiraVia, /Via do pagador/);
    assert.match(primeiraVia, /Buffet Dona Conceição Ltda/);
    assert.match(html, /cento e oitenta reais/);
  });
});

test('auditor pode imprimir; o registro de impressão entra na trilha', async () => {
  const cp = await contraparteDeTeste('Gráfica Boa Impressão ME', '11.222.333/0001-81', 'PJ');
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 9000,
      descricao: 'impressão de convites', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    const html = await impressao.html(auditor, recibo.id);   // auditor lê e imprime
    assert.match(html, /RECIBO DE RECEBIMENTO/);

    await impressao.registrarImpressao(auditor, recibo.id);
    const eventos = await emTransacao(pool as any, async (x) =>
      (await x.query(
        `SELECT tipo FROM recibos.receipt_events WHERE receipt_id = $1 ORDER BY id`, [recibo.id])).rows
        .map((r: any) => r.tipo));
    assert.deepEqual(eventos, ['SELADO', 'IMPRESSO']);
  });
});

test('recibo cancelado imprime com tarja e motivo', async () => {
  const cp = await contraparteDeTeste('Eventos Aurora Ltda', '11.222.333/0001-81', 'PJ');
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 12300,
      descricao: 'sinal de contratação', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    await recibos.cancelar(supervisor, recibo.id, 'cliente desistiu do evento');

    const html = await impressao.html(supervisor, recibo.id);
    assert.equal((html.match(/class="tarja"/g) ?? []).length, 2);
    assert.equal((html.match(/cliente desistiu do evento/g) ?? []).length, 2);
  });
});

test('impressão não depende da licença: com ela suspensa, o documento ainda sai', async () => {
  const admin = new pg.Pool({
    host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
    user: 'postgres', database: process.env.PGDATABASE ?? 'recibos_int',
  });
  const cp = await contraparteDeTeste('Serralheria do Vale ME', '11.222.333/0001-81', 'PJ');
  let id = '';
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 44000,
      descricao: 'grade de proteção da cozinha', formaPagamento: 'Transferência',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    id = recibo.id;
  });

  await admin.query(
    `UPDATE recibos.license_state SET valido_ate = now() - interval '90 days' WHERE tenant_id = $1`,
    [TENANT],
  );
  await ctx(async () => {
    const html = await impressao.html(supervisor, id);
    assert.match(html, /grade de proteção da cozinha/);
  });
  await admin.query(
    `UPDATE recibos.license_state SET valido_ate = now() + interval '60 days' WHERE tenant_id = $1`,
    [TENANT],
  );
  await admin.end();
});

/* ================= licença ================= */

test('estado da licença pela data: ativa, expirando, tolerância e bloqueada', () => {
  const base = new Date('2026-09-11T12:00:00Z');
  const em = (dias: number) => new Date(base.getTime() + dias * 86_400_000);
  assert.equal(estadoPorData(em(30), 15, base), 'ATIVA');
  assert.equal(estadoPorData(em(3), 15, base), 'EXPIRANDO');
  assert.equal(estadoPorData(em(-3), 15, base), 'GRACE');
  assert.equal(estadoPorData(em(-20), 15, base), 'BLOQUEADA');
  assert.equal(estadoPorData(null, 15, base), 'BLOQUEADA');
});

test('em tolerância ainda se emite; depois dela, não', () => {
  const emGrace = { status: 'GRACE' } as any;
  const bloqueada = { status: 'BLOQUEADA' } as any;
  assert.ok(podeSelar(emGrace));
  assert.ok(!podeSelar(bloqueada));
});

test('licença bloqueada impede SELAR, mas não impede ler, cancelar nem exportar', async () => {
  const admin = new pg.Pool({
    host: process.env.PGHOST ?? '/tmp', port: Number(process.env.PGPORT ?? 5599),
    user: 'postgres', database: process.env.PGDATABASE ?? 'recibos_int',
  });
  const cp = await contraparteDeTeste('Padaria Pão Quente ME', '11.222.333/0001-81', 'PJ');
  let reciboId = '';
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 9900,
      descricao: 'antes de bloquear', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    reciboId = recibo.id;
  });

  // licença vencida há muito mais que a tolerância
  await admin.query(
    `UPDATE recibos.license_state SET valido_ate = now() - interval '90 days' WHERE tenant_id = $1`,
    [TENANT],
  );

  await ctx(async () => {
    await assert.rejects(
      () => recibos.selar(supervisor, {
        seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 100,
        descricao: 'depois de bloquear', formaPagamento: 'PIX',
        dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
      }),
      LicencaBloqueada,
    );

    // leitura continua
    const lidos = await emTransacao(pool as any, async (x) =>
      (await x.query('SELECT count(*)::int AS n FROM recibos.receipts')).rows[0].n);
    assert.ok(lidos > 0, 'leitura não pode ser bloqueada por licença');

    // cancelamento continua — o cliente precisa poder corrigir o passado
    const c = await recibos.cancelar(supervisor, reciboId, 'corrigindo com licença suspensa');
    assert.equal(c.receipt_id, reciboId);
  });

  await admin.query(
    `UPDATE recibos.license_state SET valido_ate = now() + interval '60 days' WHERE tenant_id = $1`,
    [TENANT],
  );
  await admin.end();
});

/* ================= verificação pública (fase 5) ================= */

test('a página pública acha o recibo sem tenant nenhum no contexto — e mascara a contraparte', async () => {
  const cp = await contraparteDeTeste('Ricardo Alves Teixeira', '529.982.247-25', 'PF');
  let codigo = '';
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 43200,
      descricao: 'isto nunca pode aparecer na página pública',
      formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    codigo = recibo.codigo_verificacao;
  });

  // Chamada FORA de ctx(): sem comContexto, sem app.tenant_id — se a busca
  // pública dependesse de RLS normal, viria vazia (é a mesma falha fechada
  // que o teste "sem tenant no contexto" comprova para o resto do sistema).
  const publico = await verificacao.publica(codigo, IP_TESTE);

  assert.equal(publico.status, 'VALIDO');
  assert.equal(publico.direcao, 'RECEBIMENTO');
  assert.equal(publico.valorCentavos, 43200);
  assert.equal(publico.emitenteNomeFantasia, 'Buffet Dona Conceição Ltda');
  assert.equal(publico.contraparteNomeMascarado, mascararNome('Ricardo Alves Teixeira'));
  assert.equal(publico.contraparteDocumentoMascarado, mascararDocumento(cp.cpf_cnpj));
  assert.ok(!publico.contraparteNomeMascarado.includes('Teixeira'), 'sobrenome não pode vazar inteiro');

  // nunca a descrição, nunca o documento completo, nunca dado de contato
  for (const chave of Object.keys(publico)) {
    assert.notEqual(chave, 'descricao');
  }
  assert.ok(!JSON.stringify(publico).includes('nunca pode aparecer'), 'descrição vazou pro público');
  assert.ok(!JSON.stringify(publico).includes(cp.cpf_cnpj), 'documento completo vazou pro público');
});

test('recibo cancelado aparece como CANCELADO, com motivo, na página pública', async () => {
  const cp = await contraparteDeTeste('Ana Beatriz Lima', '529.982.247-25', 'PF');
  let codigo = '';
  let reciboId = '';
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 5000,
      descricao: 'será cancelado', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    codigo = recibo.codigo_verificacao;
    reciboId = recibo.id;
    await recibos.cancelar(supervisor, reciboId, 'motivo do cancelamento para o teste público');
  });

  const publico = await verificacao.publica(codigo, IP_TESTE);
  assert.equal(publico.status, 'CANCELADO');
  assert.equal(publico.motivoCancelamento, 'motivo do cancelamento para o teste público');
  assert.ok(publico.canceladoEm);
});

test('código inexistente devolve 404, não lista vazia nem erro genérico', async () => {
  await assert.rejects(
    () => verificacao.publica('ZZZZ-ZZZZ-ZZZZ', IP_TESTE),
    (erro: any) => erro instanceof ErroDeNegocio && erro.codigo === 'RECIBO_NAO_ENCONTRADO' && erro.status === 404,
  );
});

test('código fora do formato é recusado antes de tocar no banco', async () => {
  await assert.rejects(
    () => verificacao.publica('nao-e-um-codigo', IP_TESTE),
    (erro: any) => erro instanceof ErroDeNegocio && erro.codigo === 'CODIGO_INVALIDO' && erro.status === 422,
  );
});

test('rate limit por IP: passa do limite, o próprio banco barra com 429', async () => {
  // limite baixo e IP exclusivo deste teste — não interfere nos outros nem
  // depende de esperar a janela virar.
  const ipExclusivo = '203.0.113.99';
  const limitado = new VerificacaoService(pool as any, new RateLimitService(pool as any, 3, 60));

  for (let i = 0; i < 3; i++) {
    await assert.rejects(
      () => limitado.publica('ZZZZ-ZZZZ-ZZZZ', ipExclusivo),
      (erro: any) => erro.codigo === 'RECIBO_NAO_ENCONTRADO',
    );
  }
  // a 4ª, dentro da mesma janela, nem chega a consultar o recibo
  await assert.rejects(() => limitado.publica('ZZZZ-ZZZZ-ZZZZ', ipExclusivo), MuitasRequisicoes);
});

test('rate limit é por IP — um IP estourado não afeta outro', async () => {
  const ipA = '203.0.113.201';
  const ipB = '203.0.113.202';
  const limitado = new VerificacaoService(pool as any, new RateLimitService(pool as any, 1, 60));

  await assert.rejects(() => limitado.publica('ZZZZ-ZZZZ-ZZZZ', ipA), (e: any) => e.codigo === 'RECIBO_NAO_ENCONTRADO');
  await assert.rejects(() => limitado.publica('ZZZZ-ZZZZ-ZZZZ', ipA), MuitasRequisicoes);

  // ipB começa do zero, mesmo com ipA já estourado
  await assert.rejects(() => limitado.publica('ZZZZ-ZZZZ-ZZZZ', ipB), (e: any) => e.codigo === 'RECIBO_NAO_ENCONTRADO');
});

/* ================= logo do emitente (fase 5 — storage) ================= */

test('AssetsService salva o logo e a página pública devolve a URL pública', async () => {
  const bytesFake = Buffer.from('conteudo-fake-de-imagem-png');
  const salvos: Array<{ chave: string; bytes: Buffer; mime: string }> = [];
  const storageFake: StorageAdapter = {
    async salvar(chave, bytes, mime) { salvos.push({ chave, bytes, mime }); },
    urlPublica(chave) { return `https://recibos.ravapi.com/${chave}`; },
  };
  const assets = new AssetsService(pool as any, storageFake);
  const verificacaoComLogo = new VerificacaoService(pool as any, new RateLimitService(pool as any), storageFake);

  let codigo = '';
  await ctx(async () => {
    const asset = await assets.salvarLogo(supervisor, EMPRESA, bytesFake, 'image/png');
    assert.equal(salvos.length, 1, 'o storage tem que ter sido chamado uma vez');
    assert.equal(salvos[0].mime, 'image/png');
    assert.ok(salvos[0].chave.startsWith(`logos/${EMPRESA}/`));

    const cp = await contraparteDeTeste('Bruno Costa Lima', '529.982.247-25', 'PF');
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 7700,
      descricao: 'recibo com logo, pra testar a URL pública', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    codigo = recibo.codigo_verificacao;
    // congelado no selo, igual a qualquer outro dado do snapshot (README:
    // "o documento sai como saiu no dia")
    assert.equal(recibo.logo_asset_id, asset.id);

    // limpa a empresa fixture antes de sair do bloco — não deixa logo
    // permanente pros outros testes que reaproveitam esta mesma empresa
    await emTransacao(pool as any, (x) =>
      x.query('UPDATE recibos.companies SET logo_asset_id = NULL WHERE id = $1', [EMPRESA]));
  });

  const publico = await verificacaoComLogo.publica(codigo, IP_TESTE);
  assert.match(publico.logoUrl ?? '', new RegExp(`^https://recibos\.ravapi\.com/logos/${EMPRESA}/`));
});

test('sem storage configurado, a página pública sai sem logo — não quebra', async () => {
  const cp = await contraparteDeTeste('Camila Souza Reis', '529.982.247-25', 'PF');
  let codigo = '';
  await ctx(async () => {
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 3300,
      descricao: 'sem logo nenhum', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    codigo = recibo.codigo_verificacao;
  });

  const publico = await verificacao.publica(codigo, IP_TESTE); // verificacao global, sem storage
  assert.equal(publico.logoUrl, null);
});

/* ================= talão, detalhe, séries (frontend real) ================= */

test('listar() traz os recibos do tenant, mais recente primeiro, e filtra por busca/direção/série', async () => {
  await ctx(async () => {
    const cp = await contraparteDeTeste('Talão Teste Pessoa', '529.982.247-25', 'PF');
    const marcador = `marcador-talao-${randomUUID()}`;
    const { recibo: r1 } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 1100,
      descricao: marcador, formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });

    const todos = await recibos.listar(supervisor, { busca: marcador });
    assert.equal(todos.length, 1);
    assert.equal(todos[0].id, r1.id);
    assert.equal(todos[0].cancelado, false);

    const porDirecao = await recibos.listar(supervisor, { busca: marcador, direcao: 'PAGAMENTO' });
    assert.equal(porDirecao.length, 0, 'era RECEBIMENTO — filtro de PAGAMENTO não pode achar');

    const porSerie = await recibos.listar(supervisor, { busca: marcador, seriesId: SERIE_P });
    assert.equal(porSerie.length, 0, 'foi selado na série R, não na P');
  });
});

test('buscarPorId() traz o recibo; cancelado vem com motivo e data', async () => {
  await ctx(async () => {
    const cp = await contraparteDeTeste('Detalhe Teste Pessoa', '529.982.247-25', 'PF');
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 2200,
      descricao: 'pra detalhe', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });

    const antes = await recibos.buscarPorId(supervisor, recibo.id);
    assert.equal(antes.cancelado, false);
    assert.equal(antes.motivo_cancelamento, null);

    await recibos.cancelar(supervisor, recibo.id, 'motivo do teste de detalhe');
    const depois = await recibos.buscarPorId(supervisor, recibo.id);
    assert.equal(depois.cancelado, true);
    assert.equal(depois.motivo_cancelamento, 'motivo do teste de detalhe');
    assert.ok(depois.cancelado_em);
  });
});

test('buscarPorId() com id inexistente dá 404, não undefined silencioso', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => recibos.buscarPorId(supervisor, randomUUID()),
      (e: any) => e instanceof NaoEncontrado,
    );
  });
});

test('eventos() mostra SELADO logo após o selo, e CANCELADO depois do cancelamento', async () => {
  await ctx(async () => {
    const cp = await contraparteDeTeste('Eventos Teste Pessoa', '529.982.247-25', 'PF');
    const { recibo } = await recibos.selar(supervisor, {
      seriesId: SERIE_R, counterpartyId: cp.id, valorCentavos: 3300,
      descricao: 'pra eventos', formaPagamento: 'PIX',
      dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });

    const antes = await recibos.eventos(supervisor, recibo.id);
    assert.deepEqual(antes.map((e: any) => e.tipo), ['SELADO']);

    await recibos.cancelar(supervisor, recibo.id, 'motivo do teste de eventos');
    const depois = await recibos.eventos(supervisor, recibo.id);
    assert.deepEqual(depois.map((e: any) => e.tipo), ['SELADO', 'CANCELADO']);
    assert.equal(depois[0].usuario_nome, supervisor.nome);
  });
});

test('listarSeries() — proprietário vê tudo, emissor só o que tem permissão', async () => {
  await ctx(async () => {
    const doProprietario = await cadastros.listarSeries(proprietario);
    const codigos = doProprietario.map((s: any) => s.codigo);
    assert.ok(codigos.includes('R') && codigos.includes('P'));

    const doEmissor = await cadastros.listarSeries(emissorSoR);
    assert.deepEqual(doEmissor.map((s: any) => s.id), [SERIE_R]);
  });
});

/* ================= multiempresa: criar empresa, liberar série por usuário ================= */

test('criarEmpresa() — só quem tem gerir_series cria; documento inválido é recusado', async () => {
  await ctx(async () => {
    await assert.rejects(
      () => cadastros.criarEmpresa(emissorSoR, {
        razaoSocial: 'Empresa do Emissor Ltda', cnpjCpf: '11.222.333/0001-81',
      }),
      SemPermissao,
    );

    await assert.rejects(
      () => cadastros.criarEmpresa(proprietario, {
        razaoSocial: 'Empresa Documento Ruim Ltda', cnpjCpf: '11.222.333/0001-00',
      }),
      DadosInvalidos,
    );

    const { empresa: nova, acimaDoPlano } = await cadastros.criarEmpresa(proprietario, {
      razaoSocial: 'Dona Conceição Eventos Ltda', nomeFantasia: 'Dona Conceição Eventos',
      cnpjCpf: '24123400019929', cidade: 'Ipatinga', uf: 'MG',
    });
    assert.equal(nova.razao_social, 'Dona Conceição Eventos Ltda');
    assert.equal(nova.tenant_id, TENANT);
    assert.equal(typeof acimaDoPlano, 'boolean');

    // a série nova empresa só aparece nas séries dela, isolada da primeira
    const serieNova = await cadastros.criarSerie(proprietario, {
      companyId: nova.id, codigo: 'R', direcao: 'RECEBIMENTO',
    });
    const seriesDaNova = await cadastros.listarSeries(proprietario, nova.id);
    assert.deepEqual(seriesDaNova.map((s: any) => s.id), [serieNova.id]);
  });
});

test('criarEmpresa() NÃO bloqueia acima do plano — sinaliza acimaDoPlano, cobrança fica pro Klavo', async () => {
  await ctx(async () => {
    // já existe pelo menos 1 empresa (a fixture); limite 0 força "acima do plano"
    const cadastrosLimitado = new CadastrosService(pool as any, () => 0);
    const resultado = await cadastrosLimitado.criarEmpresa(proprietario, {
      razaoSocial: 'Empresa Fora Do Plano Ltda', cnpjCpf: '09117400015501',
    });
    assert.equal(resultado.acimaDoPlano, true, 'com limite 0 e ao menos 1 empresa ativa, tem que estar acima');
    assert.equal(resultado.contratadas, 0);
    assert.ok(resultado.totalAtivas >= 1);
    assert.ok(resultado.empresa.id, 'a empresa foi criada mesmo acima do plano — não bloqueou');
  });
});

test('definirPermissoesDeSerie() — liberar série de outra empresa pro emissor, e ele passa a emitir nela', async () => {
  await ctx(async () => {
    const { empresa: outraEmpresa } = await cadastros.criarEmpresa(proprietario, {
      razaoSocial: 'Segunda Empresa do Teste Ltda', cnpjCpf: '31204800011192',
    });
    const serieDaOutra = await cadastros.criarSerie(proprietario, {
      companyId: outraEmpresa.id, codigo: 'R', direcao: 'RECEBIMENTO',
    });

    // antes de liberar, o emissor (só tem SERIE_R da primeira empresa) não pode
    await assert.rejects(
      () => recibos.salvarRascunho(emissorSoR, {
        seriesId: serieDaOutra.id, valorCentavos: 1000, descricao: 'x',
        formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
      }),
      SemPermissao,
    );

    await usuarios.definirPermissoesDeSerie(proprietario, emissorSoR.id, [SERIE_R, serieDaOutra.id]);
    const emissorAtualizado = { ...emissorSoR, seriesPermitidas: [SERIE_R, serieDaOutra.id] };

    const rascunho = await recibos.salvarRascunho(emissorAtualizado, {
      seriesId: serieDaOutra.id, valorCentavos: 1000, descricao: 'agora pode',
      formaPagamento: 'PIX', dataPagamento: '2026-09-11', localPagamento: 'Ipatinga/MG',
    });
    assert.equal(rascunho.series_id, serieDaOutra.id);

    // e o listar() confirma que a permissão ficou gravada
    const lista = await usuarios.listar();
    const registro = lista.find((u: any) => u.id === emissorSoR.id);
    assert.ok(registro?.seriesPermitidas.includes(serieDaOutra.id));
  });
});
