<?php
declare(strict_types=1);

/**
 * cartao/api.php
 * Rotas:
 * - GET  ?action=cartao_buscar&codigo=12345
 * - GET  ?action=cartao_movimentos&codigo=12345
 * - POST ?action=cartao_recarregar
 * - POST ?action=cartao_debitar           (GARÇOM)
 * - POST ?action=cartao_estornar          (somente D)
 * - POST ?action=cartao_fechar
 * - GET  ?action=cartao_buscar_cliente&term=...
 * - POST ?action=cartao_transferir        (origem -> LIVRE, destino -> EM_USO)
 *
 * Observação:
 * - saldo e movimentos são sempre da SESSÃO ATUAL (>= aberto_em)
 * - cartão fechado (LIVRE/fechado_em) NÃO retorna movimentações
 */
require_once __DIR__ . "/_bootstrap.php";
// ===== sessão com cookie válido para o site todo =====

// ===== JSON helpers (precisam vir antes dos handlers) =====
function jsonOut(bool $ok, array $data = [], int $httpCode = 200): void {
  while (ob_get_level() > 0) { ob_end_clean(); }
  http_response_code($httpCode);
  header('Content-Type: application/json; charset=utf-8');
  echo json_encode(array_merge(['ok' => $ok], $data), JSON_UNESCAPED_UNICODE);
  exit;
}
function err(string $msg, int $httpCode = 400): void {
  jsonOut(false, ['error' => $msg], $httpCode);
}

// ===== hardening =====
ini_set('display_errors', '0');
error_reporting(E_ALL);
ob_start();

register_shutdown_function(function () {
  $e = error_get_last();
  if (!$e) return;
  $fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
  if (!in_array($e['type'], $fatalTypes, true)) return;
  while (ob_get_level() > 0) { ob_end_clean(); }
  http_response_code(500);
  header('Content-Type: application/json; charset=utf-8');
  echo json_encode(['ok' => false, 'error' => "Erro fatal PHP: {$e['message']} em {$e['file']}:{$e['line']}"], JSON_UNESCAPED_UNICODE);
  exit;
});

set_error_handler(function ($severity, $message, $file, $line) {
  throw new ErrorException($message, 0, $severity, $file, $line);
});
set_exception_handler(function (Throwable $e) {
  jsonOut(false, ['error' => 'Erro interno: ' . $e->getMessage()], 500);
});

// ===== DB =====
require_once __DIR__ . "/../../includes/db.php";
if (!isset($pdo) || !($pdo instanceof PDO)) {
  err('Config inválida: $pdo não encontrado no db.php', 500);
}


// ===== integração com CAIXA (lançamentos do balcão) =====
function tableExists(PDO $pdo, string $table): bool {
  try {
    $st = $pdo->prepare("SHOW TABLES LIKE ?");
    $st->execute([$table]);
    return (bool)$st->fetchColumn();
  } catch (Throwable $_e) {
    return false;
  }
}

function caixaEnabled(PDO $pdo): bool {
  return tableExists($pdo, 'caixas');
}

function caixaEnsureLancamentosTable(PDO $pdo): void {
  // cria só se existir "caixas" (para não quebrar instalações antigas)
  if (!caixaEnabled($pdo)) return;

  $pdo->exec("
    CREATE TABLE IF NOT EXISTS caixa_lancamentos (
      id INT AUTO_INCREMENT PRIMARY KEY,
      caixa_id INT NOT NULL,
      usuario_id INT NULL,
      tipo ENUM('ENTRADA','SAIDA') NOT NULL DEFAULT 'ENTRADA',
      categoria VARCHAR(40) NOT NULL DEFAULT 'OUTRO',
      valor DECIMAL(10,2) NOT NULL,
      descricao VARCHAR(255) NULL,
      ref_table VARCHAR(40) NULL,
      ref_id INT NULL,
      criado_em DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
      INDEX (caixa_id),
      INDEX (criado_em),
      INDEX (categoria),
      CONSTRAINT fk_caixa_lancamentos_caixa
        FOREIGN KEY (caixa_id) REFERENCES caixas(id)
        ON DELETE CASCADE
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  ");
}

function caixaOpenId(PDO $pdo): ?int {
  if (!caixaEnabled($pdo)) return null;
  $st = $pdo->query("SELECT id FROM caixas WHERE status='ABERTO' ORDER BY id DESC LIMIT 1");
  $id = $st ? $st->fetchColumn() : null;
  return $id ? (int)$id : null;
}

function caixaRequireOpen(PDO $pdo): int {
  $id = caixaOpenId($pdo);
  if ($id === null) {
    err('Não há caixa ABERTO. Abra o caixa para registrar recargas/pagamentos.', 409);
  }
  return $id;
}

function caixaLancamento(PDO $pdo, int $caixaId, ?int $usuarioId, string $tipo, string $categoria, float $valor, ?string $descricao=null, ?string $refTable=null, ?int $refId=null): void {
  if (!caixaEnabled($pdo)) return;

  caixaEnsureLancamentosTable($pdo);

  $st = $pdo->prepare("
    INSERT INTO caixa_lancamentos (caixa_id, usuario_id, tipo, categoria, valor, descricao, ref_table, ref_id)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  ");
  $st->execute([
    $caixaId,
    $usuarioId,
    $tipo,
    $categoria,
    number_format($valor, 2, '.', ''),
    $descricao,
    $refTable,
    $refId
  ]);
}

// ===== helpers =====
function readJsonBody(): array {
  $raw = file_get_contents('php://input');
  if ($raw === false || trim($raw) === '') return [];
  $j = json_decode($raw, true);
  if (!is_array($j)) err('JSON inválido no corpo da requisição.');
  return $j;
}

function getCartaoByCodigo(PDO $pdo, string $codigo): array {
  $st = $pdo->prepare("SELECT * FROM cartoes WHERE codigo=?");
  $st->execute([$codigo]);
  $c = $st->fetch(PDO::FETCH_ASSOC);
  if (!$c) err('Cartão não encontrado.', 404);
  return $c;
}

function getCartaoByCodigoForUpdate(PDO $pdo, string $codigo): array {
  $st = $pdo->prepare("SELECT * FROM cartoes WHERE codigo=? FOR UPDATE");
  $st->execute([$codigo]);
  $c = $st->fetch(PDO::FETCH_ASSOC);
  if (!$c) err('Cartão não encontrado.', 404);
  return $c;
}

function cartaoEmUsoAberto(array $c): bool {
  $status = strtoupper((string)($c['status'] ?? ''));
  return ($status === 'EM_USO' && empty($c['fechado_em']) && !empty($c['aberto_em']));
}

function cartaoFechado(array $c): bool {
  $status = strtoupper((string)($c['status'] ?? ''));
  return (!empty($c['fechado_em']) || $status === 'LIVRE');
}

function saldoSessao(PDO $pdo, array $cartao): float {
  if (!cartaoEmUsoAberto($cartao)) return 0.0;

  $st = $pdo->prepare("
    SELECT COALESCE(SUM(
      CASE
        WHEN operacao='C' THEN valor
        WHEN operacao='E' THEN valor
        WHEN operacao='D' THEN -valor
        ELSE 0
      END
    ),0) saldo
    FROM cartao_movimentos
    WHERE cartao_id = ?
      AND criado_em >= ?
  ");
  $st->execute([(int)$cartao['id'], $cartao['aberto_em']]);
  return (float)$st->fetchColumn();
}

function movimentosSessao(PDO $pdo, array $cartao): array {
  // ✅ regra: após fechar não exibir movimentações
  if (!cartaoEmUsoAberto($cartao)) return [];

  $st = $pdo->prepare("
    SELECT *
    FROM cartao_movimentos
    WHERE cartao_id=?
      AND criado_em >= ?
    ORDER BY id DESC
  ");
  $st->execute([(int)$cartao['id'], $cartao['aberto_em']]);
  return $st->fetchAll(PDO::FETCH_ASSOC);
}

function jaTeveRecargaNaSessao(PDO $pdo, array $cartao): bool {
  if (!cartaoEmUsoAberto($cartao)) return false;

  $st = $pdo->prepare("
    SELECT COUNT(*)
    FROM cartao_movimentos
    WHERE cartao_id=?
      AND criado_em >= ?
      AND operacao='C'
      AND motivo='RECARGA'
  ");
  $st->execute([(int)$cartao['id'], $cartao['aberto_em']]);
  return ((int)$st->fetchColumn() > 0);
}

function requireGarcom(): void {
  // login único
  if (empty($_SESSION['staff']['id'])) {
    err('Não autenticado. Faça login novamente.', 401);
  }

  // precisa ter permissão de garçom OU admin
  $role = strtoupper((string)($_SESSION['staff']['role'] ?? ''));
  if (!in_array($role, ['GARCOM','ADMIN'], true)) {
    err('Sem permissão para debitar como garçom.', 403);
  }
}


// ===== router =====
$action = trim((string)($_GET['action'] ?? ''));
if ($action === '') err('Ação não informada.');

// -------------------------
// GET cartao_buscar
// -------------------------
if ($action === 'cartao_buscar') {
  $codigo = trim((string)($_GET['codigo'] ?? ''));
  if ($codigo === '') err('Código do cartão não informado.');

  $cartao = getCartaoByCodigo($pdo, $codigo);
  $saldo  = saldoSessao($pdo, $cartao);

  jsonOut(true, ['cartao' => $cartao, 'saldo' => $saldo]);
}

// -------------------------
// GET cartao_movimentos
// -------------------------
if ($action === 'cartao_movimentos') {
  $codigo = trim((string)($_GET['codigo'] ?? ''));
  if ($codigo === '') err('Código do cartão não informado.');

  $cartao = getCartaoByCodigo($pdo, $codigo);

  // ✅ fechado => não retorna movimentos
  if (!cartaoEmUsoAberto($cartao)) {
    jsonOut(true, ['cartao' => $cartao, 'saldo' => 0, 'movimentos' => []]);
  }

  $movs  = movimentosSessao($pdo, $cartao);
  $saldo = saldoSessao($pdo, $cartao);

  jsonOut(true, ['cartao' => $cartao, 'saldo' => $saldo, 'movimentos' => $movs]);
}

// -------------------------
// POST cartao_recarregar (BALCÃO/ADMIN)
// -------------------------
if ($action === 'cartao_recarregar') {
  $data = readJsonBody();
  $codigo = trim((string)($data['codigo'] ?? ''));
  $valor  = (float)($data['valor'] ?? 0);

  if ($codigo === '' || !($valor > 0)) err('Dados inválidos para recarga.');

  $pdo->beginTransaction();
  try {
    $cartao = getCartaoByCodigoForUpdate($pdo, $codigo);

    // ✅ Cartão BLOQUEADO pode voltar a ser usado após uma NOVA recarga.
    // Ao recarregar um BLOQUEADO, abrimos uma nova sessão e limpamos vínculos antigos.
    if (strtoupper((string)$cartao['status']) === 'BLOQUEADO') {
      $pdo->prepare("UPDATE cartoes SET status='EM_USO', aberto_em=NOW(), fechado_em=NULL, nome=NULL, celular=NULL, mesa=NULL WHERE id=?")
        ->execute([(int)$cartao['id']]);
      $cartao = getCartaoByCodigoForUpdate($pdo, $codigo);
    }

    // Se fechado/LIVRE, abre nova sessão
    if (cartaoFechado($cartao)) {
      $pdo->prepare("UPDATE cartoes SET status='EM_USO', aberto_em=NOW(), fechado_em=NULL WHERE id=?")
        ->execute([(int)$cartao['id']]);
      $cartao = getCartaoByCodigoForUpdate($pdo, $codigo);
    }

    $temRecarga = jaTeveRecargaNaSessao($pdo, $cartao);

    $nomeNovo = isset($data['nome']) ? trim((string)$data['nome']) : '';
    $celNovo  = isset($data['celular']) ? trim((string)$data['celular']) : '';

    // Após primeira recarga, não deixa trocar dados
    if ($temRecarga) {
      if ($nomeNovo !== '' && trim((string)$cartao['nome']) !== '' && strcasecmp($nomeNovo, (string)$cartao['nome']) !== 0) {
        throw new RuntimeException('Não é permitido trocar o nome do cliente após a recarga inicial (cartão em uso).');
      }
      if ($celNovo !== '' && trim((string)$cartao['celular']) !== '' && $celNovo !== (string)$cartao['celular']) {
        throw new RuntimeException('Não é permitido trocar o celular após a recarga inicial (cartão em uso).');
      }
    } else {
      // na 1a recarga pode setar nome/celular
      if ($nomeNovo !== '' || $celNovo !== '') {
        $pdo->prepare("UPDATE cartoes SET nome=?, celular=? WHERE id=?")
          ->execute([
            $nomeNovo !== '' ? $nomeNovo : $cartao['nome'],
            $celNovo  !== '' ? $celNovo  : $cartao['celular'],
            (int)$cartao['id']
          ]);
        $cartao = getCartaoByCodigoForUpdate($pdo, $codigo);
      }
    }

    $obs = isset($data['observacao']) ? trim((string)$data['observacao']) : null;
    if ($obs === '') $obs = null;

    $stMov = $pdo->prepare("
      INSERT INTO cartao_movimentos (cartao_id, operacao, valor, motivo, origem, observacao, criado_em)
      VALUES (?, 'C', ?, 'RECARGA', 'BALCAO', ?, NOW())
    ");
    $stMov->execute([(int)$cartao['id'], $valor, $obs]);
    $movId = (int)$pdo->lastInsertId();

    // ✅ lança no CAIXA (se o módulo existir)
    if (caixaEnabled($pdo)) {
      $caixaId = caixaRequireOpen($pdo);
      $uid = !empty($_SESSION['staff']['id']) ? (int)$_SESSION['staff']['id'] : null;
      caixaLancamento($pdo, $caixaId, $uid, 'ENTRADA', 'RECARGA_CARTAO', $valor, "Recarga cartão {$codigo}", 'cartao_movimentos', $movId);
    }

    $pdo->commit();

    $cartao = getCartaoByCodigo($pdo, $codigo);
    jsonOut(true, ['cartao' => $cartao, 'saldo' => saldoSessao($pdo, $cartao)]);

  } catch (Throwable $e) {
    if ($pdo->inTransaction()) $pdo->rollBack();
    err($e->getMessage());
  }
}

// -------------------------
// POST cartao_debitar (GARÇOM)
// -------------------------
if ($action === 'cartao_debitar') {
  requireGarcom();
  $data = readJsonBody();

  $codigo = trim((string)($data['codigo'] ?? ''));
  $valor  = (float)($data['valor'] ?? 0);
  if ($codigo === '' || !($valor > 0)) err('Dados inválidos para débito.');

  $motivo = trim((string)($data['motivo'] ?? 'CONSUMO'));
  if ($motivo === '') $motivo = 'CONSUMO';

  $obs = isset($data['observacao']) ? trim((string)$data['observacao']) : null;
  if ($obs === '') $obs = null;

  $pdo->beginTransaction();
  try {
    $cartao = getCartaoByCodigoForUpdate($pdo, $codigo);

    if (strtoupper((string)$cartao['status']) === 'BLOQUEADO') {
      throw new RuntimeException('Cartão BLOQUEADO.');
    }
    if (!cartaoEmUsoAberto($cartao)) {
      throw new RuntimeException('Cartão precisa estar EM_USO (sessão aberta) para debitar.');
    }

    $saldoAtual = saldoSessao($pdo, $cartao);
    if ($saldoAtual < $valor) {
      throw new RuntimeException('Saldo insuficiente para este débito.');
    }

    $pdo->prepare("
      INSERT INTO cartao_movimentos (cartao_id, operacao, valor, motivo, origem, observacao, criado_em)
      VALUES (?, 'D', ?, ?, 'GARCOM', ?, NOW())
    ")->execute([(int)$cartao['id'], $valor, $motivo, $obs]);

    $pdo->commit();

    $cartao = getCartaoByCodigo($pdo, $codigo);
    jsonOut(true, ['cartao' => $cartao, 'saldo' => $saldoAtual - $valor]);

  } catch (Throwable $e) {
    if ($pdo->inTransaction()) $pdo->rollBack();
    err($e->getMessage());
  }
}

// -------------------------
// POST cartao_estornar (somente D)
// -------------------------
if ($action === 'cartao_estornar') {
  $data = readJsonBody();
  $movId = (int)($data['mov_id'] ?? 0);
  if ($movId <= 0) err('Movimento inválido.');

  $st = $pdo->prepare("
    SELECT m.*, c.codigo AS cartao_codigo, c.status, c.fechado_em, c.aberto_em
    FROM cartao_movimentos m
    JOIN cartoes c ON c.id = m.cartao_id
    WHERE m.id = ?
  ");
  $st->execute([$movId]);
  $mov = $st->fetch(PDO::FETCH_ASSOC);
  if (!$mov) err('Movimento não encontrado.', 404);

  // ✅ só permite estorno em cartão EM_USO aberto e sessão atual
  if (!cartaoEmUsoAberto($mov)) err('Estorno permitido somente para cartões em uso (abertos).');

  // ✅ só estorna D
  if (strtoupper((string)$mov['operacao']) !== 'D') {
    err('Estorno permitido somente para movimentações de Débito (D).');
  }

  if (strtoupper((string)$mov['motivo']) === 'ESTORNO') err('Este movimento já é um estorno.');

  // evita estorno duplicado
  $st = $pdo->prepare("SELECT COUNT(*) FROM cartao_movimentos WHERE ref_mov_id=?");
  $st->execute([$movId]);
  if ((int)$st->fetchColumn() > 0) err('Este movimento já foi estornado.');

  // estorno de D => C
  $pdo->prepare("
    INSERT INTO cartao_movimentos (cartao_id, operacao, valor, motivo, origem, ref_mov_id, criado_em)
    VALUES (?, 'E', ?, 'ESTORNO', 'BALCAO', ?, NOW())
  ")->execute([(int)$mov['cartao_id'], (float)$mov['valor'], $movId]);

  jsonOut(true, ['msg' => 'Estorno realizado']);
}

// -------------------------
// POST cartao_fechar
// -------------------------
if ($action === 'cartao_fechar') {
  $data = readJsonBody();
  $codigo = trim((string)($data['codigo'] ?? ''));
  $metodo = strtoupper(trim((string)($data['metodo'] ?? 'DINHEIRO'))); // PIX | DINHEIRO

  if ($codigo === '') err('Código não informado.');
  if (!in_array($metodo, ['PIX','DINHEIRO'], true)) $metodo = 'DINHEIRO';

  $pdo->beginTransaction();
  try {
    $cartao = getCartaoByCodigoForUpdate($pdo, $codigo);

    if (!cartaoEmUsoAberto($cartao)) {
      throw new RuntimeException('Cartão já está fechado ou não está em uso.');
    }

    // ✅ saldo da sessão atual
    $saldo = saldoSessao($pdo, $cartao);

    // ✅ se ainda houver saldo, devolver ao cliente (PIX/DINHEIRO) e lançar no caixa
    if ($saldo > 0.00001) {
      // 1) zera o saldo via débito (registro auditável no histórico do cartão)
      $obsDev = "Devolução de saldo no fechamento ({$metodo})";
      $stDev = $pdo->prepare("
        INSERT INTO cartao_movimentos (cartao_id, operacao, valor, motivo, origem, observacao, criado_em)
        VALUES (?, 'D', ?, 'DEVOLUCAO_SALDO', 'BALCAO', ?, NOW())
      ");
      $stDev->execute([(int)$cartao['id'], $saldo, $obsDev]);
      $movId = (int)$pdo->lastInsertId();

      // 2) lança no caixa (se o módulo existir)
      if (caixaEnabled($pdo)) {
        $caixaId = caixaRequireOpen($pdo);
        $uid = !empty($_SESSION['staff']['id']) ? (int)$_SESSION['staff']['id'] : null;

        // pedido do negócio: "troco" (dinheiro) ou "estorno cartão" (pix)
        $categoria = ($metodo === 'PIX') ? 'ESTORNO_CARTAO' : 'TROCO';
        $desc = "Devolução saldo cartão {$codigo} ({$metodo})";

        caixaLancamento($pdo, $caixaId, $uid, 'SAIDA', $categoria, $saldo, $desc, 'cartao_movimentos', $movId);
      }
    } elseif ($saldo < -0.00001) {
      throw new RuntimeException('Saldo negativo no cartão. Verifique os lançamentos antes de fechar.');
    }

    // ✅ fecha e limpa dados do cliente na tabela cartoes
    $pdo->prepare("
      UPDATE cartoes
      SET
        status='LIVRE',
        fechado_em=NOW(),
        nome=NULL,
        celular=NULL,
        mesa=NULL,
        aberto_em=NULL
      WHERE id=?
    ")->execute([(int)$cartao['id']]);

    $pdo->commit();

    $cartao = getCartaoByCodigo($pdo, $codigo);
    jsonOut(true, [
      'cartao' => $cartao,
      'saldo' => 0,
      'saldo_devolvido' => max(0, $saldo),
      'metodo' => $metodo
    ]);

  } catch (Throwable $e) {
    if ($pdo->inTransaction()) $pdo->rollBack();
    err($e->getMessage());
  }
}


// -------------------------
// GET cartao_buscar_cliente
// -------------------------
if ($action === 'cartao_buscar_cliente') {
  $term = trim((string)($_GET['term'] ?? ''));
  if ($term === '') err('Informe um termo para busca.');

  $like = '%' . $term . '%';
  $st = $pdo->prepare("
    SELECT id, codigo, nome, celular, status, aberto_em, fechado_em
    FROM cartoes
    WHERE nome LIKE ? OR celular LIKE ?
    ORDER BY id DESC
    LIMIT 30
  ");
  $st->execute([$like, $like]);
  jsonOut(true, ['resultados' => $st->fetchAll(PDO::FETCH_ASSOC)]);
}

// -------------------------
// POST cartao_transferir
// regra: TRANSFERÊNCIA POR PERDA
// - origem: cartão EM_USO (aberto) que foi perdido
// - destino: QUALQUER cartão LIVRE
// Efeito:
// - mantém saldo e TODAS movimentações da sessão (recargas, débitos, estornos)
//   migrando os registros para o cartão destino
// - cartão perdido fica BLOQUEADO
// -------------------------
if ($action === 'cartao_transferir') {
  $data = readJsonBody();
  $orig = trim((string)($data['orig_codigo'] ?? ''));
  $novo = trim((string)($data['novo_codigo'] ?? ''));

  if ($orig === '' || $novo === '') err('Informe cartão de origem (perdido) e destino (livre).');
  if ($orig === $novo) err('Origem e destino não podem ser o mesmo cartão.');

  $pdo->beginTransaction();
  try {
    // trava os 2 cartões
    $cOrig = getCartaoByCodigoForUpdate($pdo, $orig);
    $cNovo = getCartaoByCodigoForUpdate($pdo, $novo);

    if (strtoupper((string)$cOrig['status']) === 'BLOQUEADO') {
      throw new RuntimeException('Cartão de origem já está BLOQUEADO.');
    }
    if (!cartaoEmUsoAberto($cOrig)) {
      throw new RuntimeException('Cartão de origem precisa estar EM_USO (sessão aberta) para transferir.');
    }

    $statusDest = strtoupper((string)($cNovo['status'] ?? ''));
    if ($statusDest !== 'LIVRE') {
      throw new RuntimeException('Cartão de destino deve estar LIVRE.');
    }

    // ✅ precisa ter algo para transferir (saldo != 0 ou ao menos 1 movimento na sessão)
    $saldo = saldoSessao($pdo, $cOrig);
    $stCnt = $pdo->prepare("SELECT COUNT(*) FROM cartao_movimentos WHERE cartao_id=? AND criado_em >= ?");
    $stCnt->execute([(int)$cOrig['id'], $cOrig['aberto_em']]);
    $qt = (int)$stCnt->fetchColumn();
    if ($qt <= 0) {
      throw new RuntimeException('Não há movimentações na sessão atual para transferir.');
    }

    // ✅ abre o destino usando o MESMO aberto_em da origem (para a sessão não "sumir")
    $pdo->prepare("UPDATE cartoes SET status='EM_USO', aberto_em=?, fechado_em=NULL, nome=?, celular=?, mesa=? WHERE id=?")
      ->execute([
        $cOrig['aberto_em'],
        $cOrig['nome'],
        $cOrig['celular'],
        $cOrig['mesa'],
        (int)$cNovo['id']
      ]);

    // ✅ migra TODAS as movimentações da sessão (mantém histórico e saldo)
    $pdo->prepare("UPDATE cartao_movimentos SET cartao_id=? WHERE cartao_id=? AND criado_em >= ?")
      ->execute([(int)$cNovo['id'], (int)$cOrig['id'], $cOrig['aberto_em']]);

    // ✅ bloqueia o cartão perdido
    $pdo->prepare("UPDATE cartoes SET status='BLOQUEADO', fechado_em=NOW() WHERE id=?")
      ->execute([(int)$cOrig['id']]);

    $pdo->commit();

    $cOrig = getCartaoByCodigo($pdo, $orig);
    $cNovo = getCartaoByCodigo($pdo, $novo);
    jsonOut(true, [
      'origem' => $cOrig,
      'destino' => $cNovo,
      'saldo_origem' => $saldo,
      'saldo_destino' => saldoSessao($pdo, $cNovo),
    ]);

  } catch (Throwable $e) {
    if ($pdo->inTransaction()) $pdo->rollBack();
    err($e->getMessage());
  }
}

// fallback
err('Ação inválida: ' . $action, 404);