const express = require('express');
const { MongoClient, ObjectId } = require('mongodb');
const blockService = require('../services/blockService');
const callbackService = require('../services/callbackService');
const chainTransactionService = require('../services/chainTransactionService');
const nodeParticipantService = require('../services/nodeParticipantService');
const proofLedgerService = require('../services/proofLedgerService');
const rewardAuditService = require('../services/rewardAuditService');
const rewardLedgerService = require('../services/rewardLedgerService');
const rewardPolicyService = require('../services/rewardPolicyService');
const rewardReviewService = require('../services/rewardReviewService');
const testnetService = require('../services/testnetService');
const canonicalLedgerService = require('../services/canonicalLedgerService');
const trxCollateralService = require('../services/trxCollateralService');
const walletService = require('../services/walletService');
const { toDisplayAmount } = require('../config/coinConfig');
const { attachWalletUser } = require('../middleware/walletUserAuth');

const router = express.Router();
router.use(attachWalletUser);

const AISFOR_SOURCE_DB_URI = process.env.AISFOR_SOURCE_DB_URI || process.env.AISFOR_DB_URI || 'mongodb://127.0.0.1:27017/aisfor';
const AISFOR_NODE_ONLINE_TTL_MS = 1000 * 60 * 3;
let aisforSourceClient = null;

async function getAisforSourceDb() {
  if (!aisforSourceClient) {
    aisforSourceClient = new MongoClient(AISFOR_SOURCE_DB_URI, { maxPoolSize: 3 });
    await aisforSourceClient.connect();
  }
  return aisforSourceClient.db();
}

const EXPLORER_TEXT = {
  ko: {
    htmlLang: 'ko', switchLabel: 'English', modeLabel: 'plais Chain Testnet', title: 'plais 체인 익스플로러',
    mainnetLabel: 'plais Chain 메인넷', testnetLabel: 'plais Chain 테스트넷',
    subtitle: '노드, 담보, 보상, 증명 원장을 한 곳에서 확인합니다.', searchPlaceholder: '주소, txHash, 블록 높이/해시, proof id/hash 검색', search: '검색', wallet: '지갑', admin: '관리자', port: '포트',
    latestBlock: '최신 블록', chainVerify: '체인 검증', readiness: '준비도', network: '네트워크', notFound: '검색 결과가 없습니다',
    metrics: { wallets: '지갑', circulating: '유통량', blocks: '블록', finalized: '확정', recentTx: '최근 TX', proofs: '최근 Proof', rewards: '최근 보상', audits: '보상 감사', reviews: '보상 검토', nodes: '검증자 / 노드' },
    participantDashboard: '내 노드 현황', networkDashboard: '전체 노드 현황',
    dashboardNote: '관리자와 공개 화면은 전체 네트워크를 보여주고, 로그인한 참여자는 본인 노드와 담보 상태만 확인합니다.',
    activeNodes: '활성 노드', totalCapacity: '총 용량', requiredCollateral: '필요 담보', confirmedCollateral: '확정 담보', pendingCollateral: '대기 담보', coverage: '담보 충족률',
    policyTitle: '참여 정책', policyText: '현재 정책은 1TB당 %TRX% TRX입니다. 담보가 확인된 노드만 참여 보상 대상입니다.',
    policyWarning: '전송 전 TRX 주소와 네트워크를 확인하세요. 잘못 보낸 전송은 자동 복구되지 않을 수 있습니다.',
    recentCollateral: '최근 담보 확인', noEvents: '아직 담보 확인 기록이 없습니다.', nodeLedger: '노드 담보 원장', node: '노드', status: '상태', capacity: '용량', required: '필요', confirmedPending: '확정 / 대기', lastHeartbeat: '마지막 하트비트', collateralMemo: '담보 메모', noNodes: '표시할 노드가 없습니다.',
    recentBlocks: '최신 블록', testnet: '테스트넷', symbol: '심볼', issuance: '발행 상태', treasury: '트레저리', genesis: '제네시스', recentChainTx: '최근 체인 TX', recentProofs: '최근 Proof', recentRewards: '최근 보상', recentAudits: '최근 보상 감사', recentReviews: '최근 보상 검토', recentWalletTx: '최근 지갑 전송', walletList: '지갑 목록', callbacks: '콜백', validators: '검증자 / 노드', heightFinality: '높이 / 확정성', noRecords: '기록이 없습니다.', fullRecentList: '최근 기록',
    explorerTitle: 'plais 체인 익스플로러',
    networkLabel: 'plais Chain Testnet',
    langToggle: 'English',
    heroTitle: '노드, 담보, 보상, 증명 원장을 한 곳에서 추적합니다.',
    heroSubtitle: '관리자는 전체 네트워크를 보고, 로그인한 참여자는 본인 노드와 담보 상태를 확인합니다.',
    searchButton: '검색',
    notFoundLabel: '검색 결과가 없습니다',
    metricsLabel: '익스플로러 지표',
    wallets: '지갑',
    circulating: '유통량',
    blocks: '블록',
    finalized: '확정',
    recentTx: '최근 TX',
    rewardAudits: '보상 감사',
    rewardReviews: '보상 검토',
    participantLedger: '참여자 노드 원장',
    policySubtext: '각 노드는 고유 메모로 추적되므로 같은 지갑에서 여러 노드 담보를 보내도 노드별로 배정할 수 있습니다.',
    noCollateral: '아직 담보 확인 기록이 없습니다.',
    uptimeVerify: '가동 / 검증',
    usedPrefix: '사용',
    recentTransactions: '최근 체인 TX',
    legacyArchiveTitle: '레거시 모의 원장 보관소', legacyReadOnly: '읽기 전용', legacyArchived: '보관', legacyQuarantined: '격리',
    statusMap: { active: '활성', waiting_deposit: '담보 대기', pending: '대기', inactive: '비활성', online: '온라인', offline: '오프라인' },
    commonText: { active: '활성', nodes: '노드', pending: '대기', uptime: '가동', last: '최근', heightFinality: '높이 / 확정성' },
    transferText: { ledger: '지갑 전송 원장', mine: '내 지갑 전송 현황', all: '전체 지갑 전송 현황', helpMine: '내 지갑으로 들어오고 나간 plais 전송을 최신 순서로 확인합니다.', helpAll: '네트워크 지갑 전송 흐름을 최신 순서로 확인합니다.', count: '전송 건수', received: '받은 금액', sent: '보낸 금액', net: '순변동', time: '시간', type: '유형', direction: '방향', amount: '수량', memo: '메모', from: '보낸 지갑', to: '받는 지갑', status: '상태', ledgerHeight: '원장', hash: '해시', noRows: '표시할 전송 기록이 없습니다.', in: '받음', out: '보냄', self: '내부', transfer: '전송' },
    rewardText: { ledger: '메인넷형 보상 원장', mine: '내가 받은 코인 보상', all: '전체 코인 보상 흐름', help: '앱 사용 active 보상, 스토리지 보상, 노드 active 보상을 메인넷과 같은 코인 보상 원장 방식으로 보여줍니다.', total: '순 보상', items: '건', app: '앱 사용 active', storage: '스토리지', node: '노드 active', other: '감점 / 정정', review: '정책 확인', blockRecorded: '블록 기록', rejected: '거절', pending: '보상 반영', blockPending: '블록 기록 대기', recipient: '받은 사람', noRows: '표시할 보상 기록이 아직 없습니다.' }
  },
  en: {
    htmlLang: 'en', switchLabel: 'Korean', modeLabel: 'plais Chain Testnet', title: 'plais Chain Explorer',
    mainnetLabel: 'plais Chain Mainnet', testnetLabel: 'plais Chain Testnet',
    subtitle: 'Track nodes, collateral, rewards, and proof ledger activity in one place.', searchPlaceholder: 'Search address, txHash, block height/hash, proof id/hash', search: 'Search', wallet: 'Wallet', admin: 'Admin', port: 'Port',
    latestBlock: 'Latest Block', chainVerify: 'Chain Verify', readiness: 'Readiness', network: 'Network', notFound: 'No result found',
    metrics: { wallets: 'Wallets', circulating: 'Circulating', blocks: 'Blocks', finalized: 'Finalized', recentTx: 'Recent Tx', proofs: 'Recent Proofs', rewards: 'Recent Rewards', audits: 'Reward Audits', reviews: 'Reward Reviews', nodes: 'Validators / Nodes' },
    participantDashboard: 'My Node Dashboard', networkDashboard: 'Network Node Dashboard',
    dashboardNote: 'Admin and public views show the whole network. Signed-in participants see only their own node and collateral status.',
    activeNodes: 'Active Nodes', totalCapacity: 'Total Capacity', requiredCollateral: 'Required Collateral', confirmedCollateral: 'Confirmed Collateral', pendingCollateral: 'Pending Collateral', coverage: 'Collateral Coverage',
    policyTitle: 'Participation Policy', policyText: 'Current policy is %TRX% TRX per 1TB. Only collateral-confirmed nodes are eligible for participant rewards.',
    policyWarning: 'Check the TRX address and network before sending. Incorrect transfers may not be recoverable automatically.',
    recentCollateral: 'Recent Collateral Confirmations', noEvents: 'No collateral confirmations yet.', nodeLedger: 'Node Collateral Ledger', node: 'Node', status: 'Status', capacity: 'Capacity', required: 'Required', confirmedPending: 'Confirmed / Pending', lastHeartbeat: 'Last Heartbeat', collateralMemo: 'Collateral Memo', noNodes: 'No nodes to display.',
    recentBlocks: 'Latest Blocks', testnet: 'Testnet', symbol: 'Symbol', issuance: 'Issuance', treasury: 'Treasury', genesis: 'Genesis', recentChainTx: 'Recent Chain Tx', recentProofs: 'Recent Proofs', recentRewards: 'Recent Rewards', recentAudits: 'Recent Reward Audits', recentReviews: 'Recent Reward Reviews', recentWalletTx: 'Recent Wallet Transfers', walletList: 'Wallet List', callbacks: 'Callbacks', validators: 'Validators / Nodes', heightFinality: 'height / finality', noRecords: 'No records.', fullRecentList: 'Recent Records',
    explorerTitle: 'plais Chain Explorer',
    networkLabel: 'plais Chain Testnet',
    langToggle: 'Korean',
    heroTitle: 'Track nodes, collateral, rewards, and proof ledger activity in one place.',
    heroSubtitle: 'Admins see the whole network. Signed-in participants see only their own nodes and collateral status.',
    searchButton: 'Search',
    notFoundLabel: 'No result found',
    metricsLabel: 'Explorer metrics',
    wallets: 'Wallets',
    circulating: 'Circulating',
    blocks: 'Blocks',
    finalized: 'Finalized',
    recentTx: 'Recent Tx',
    rewardAudits: 'Reward Audits',
    rewardReviews: 'Reward Reviews',
    participantLedger: 'PARTICIPANT NODE LEDGER',
    policySubtext: 'Each node is tracked by a unique memo, so multiple node deposits from the same wallet can still be assigned per node.',
    noCollateral: 'No collateral confirmations yet.',
    uptimeVerify: 'Uptime / Verify',
    usedPrefix: 'used',
    recentTransactions: 'Recent Chain Tx',
    legacyArchiveTitle: 'Legacy Mock Ledger Archive', legacyReadOnly: 'read-only', legacyArchived: 'archived', legacyQuarantined: 'quarantined',
    statusMap: { active: 'Active', waiting_deposit: 'Waiting Deposit', pending: 'Pending', inactive: 'Inactive', online: 'Online', offline: 'Offline' },
    commonText: { active: 'active', nodes: 'nodes', pending: 'pending', uptime: 'uptime', last: 'last', heightFinality: 'height / finality' },
    transferText: { ledger: 'Wallet Transfer Ledger', mine: 'My Wallet Transfers', all: 'Network Wallet Transfers', helpMine: 'Track plais transfers sent from and received by your wallet in latest order.', helpAll: 'Track the latest plais wallet transfer flow across the network.', count: 'Transfers', received: 'Received', sent: 'Sent', net: 'Net Change', time: 'Time', type: 'Type', direction: 'Direction', amount: 'Amount', memo: 'Memo', from: 'From', to: 'To', status: 'Status', ledgerHeight: 'Ledger', hash: 'Hash', noRows: 'No transfer records to display.', in: 'Received', out: 'Sent', self: 'Internal', transfer: 'Transfer' },
    rewardText: { ledger: 'Mainnet-style Reward Ledger', mine: 'My Coin Rewards', all: 'Network Coin Reward Flow', help: 'App active, storage, and node active rewards are shown in the same mainnet-style coin reward ledger.', total: 'Net Rewards', items: 'items', app: 'App Active', storage: 'Storage', node: 'Node Active', other: 'Penalty / Correction', review: 'Policy Check', blockRecorded: 'Block Recorded', rejected: 'Rejected', pending: 'Reward Added', blockPending: 'Waiting for Block', recipient: 'Recipient', noRows: 'No reward records to display yet.' }
  }
};

const EXPLORER_SUPPORTED_LANGS = ['ko', 'en', 'zh', 'ja'];

Object.assign(EXPLORER_TEXT.ko, {
  navChainHome: '체인 홈',
  navPlaiforHome: 'PLAIFOR 홈'
});

Object.assign(EXPLORER_TEXT.en, {
  navChainHome: 'Chain Home',
  navPlaiforHome: 'PLAIFOR Home'
});

EXPLORER_TEXT.zh = Object.assign({}, EXPLORER_TEXT.en, {
  htmlLang: 'zh',
  switchLabel: '韩语',
  modeLabel: 'plais Chain 测试网',
  mainnetLabel: 'plais Chain 主网', testnetLabel: 'plais Chain 测试网',
  title: 'plais 链浏览器',
  subtitle: '在一个地方查看节点、质押、奖励和证明账本活动。',
  explorerTitle: 'plais 链浏览器',
  navChainHome: '链首页',
  navPlaiforHome: 'PLAIFOR 首页',
  wallet: '钱包',
  admin: '管理员',
  port: '端口',
  networkLabel: 'plais Chain Testnet',
  heroTitle: '在一个地方追踪节点、质押、奖励和证明账本。',
  heroSubtitle: '管理员查看整个网络，登录参与者查看自己的节点和质押状态。',
  searchPlaceholder: '搜索地址、txHash、区块高度/哈希、proof id/hash',
  search: '搜索',
  searchButton: '搜索',
  latestBlock: '最新区块',
  chainVerify: '链验证',
  readiness: '准备度',
  network: '网络',
  notFound: '未找到结果',
  metrics: { wallets: '钱包', circulating: '流通量', blocks: '区块', finalized: '已确认', recentTx: '最近交易', proofs: '最近证明', rewards: '最近奖励', audits: '奖励审计', reviews: '奖励审核', nodes: '验证者 / 节点' },
  metricsLabel: '浏览器指标',
  wallets: '钱包',
  circulating: '流通量',
  blocks: '区块',
  finalized: '已确认',
  recentTx: '最近交易',
  recentProofs: '最近证明',
  recentRewards: '最近奖励',
  rewardAudits: '奖励审计',
  rewardReviews: '奖励审核',
  validators: '验证者 / 节点',
  participantLedger: '参与者节点账本',
  participantDashboard: '我的节点概览',
  networkDashboard: '全网节点概览',
  dashboardNote: '管理员和公开页面显示整个网络；已登录参与者仅查看自己的节点与质押状态。',
  noRecords: '没有记录。',
  notFoundLabel: '未找到结果',
  statusMap: { active: '活跃', waiting_deposit: '等待质押', pending: '待处理', inactive: '未激活', online: '在线', offline: '离线' },
  activeNodes: '活跃节点',
  totalCapacity: '总容量',
  requiredCollateral: '所需质押',
  confirmedCollateral: '已确认质押',
  pendingCollateral: '待确认质押',
  coverage: '质押覆盖率',
  policyTitle: '参与政策',
  policyText: '当前政策为每 1TB %TRX% TRX。只有质押确认的节点才有资格获得参与奖励。',
  policyWarning: '发送前请确认 TRX 地址和网络。错误转账可能无法自动恢复。',
  policySubtext: '每个节点都用唯一备注追踪，因此同一钱包的多个节点质押也能按节点分配。',
  recentCollateral: '最近质押确认', noEvents: '暂无质押确认记录。', nodeLedger: '节点质押账本', required: '所需', lastHeartbeat: '最后心跳',
  noCollateral: '暂无质押确认记录。',
  node: '节点', status: '状态', capacity: '容量', confirmedPending: '已确认 / 待确认', collateralMemo: '质押备注', noNodes: '没有可显示的节点。', uptimeVerify: '运行 / 验证', usedPrefix: '已用',
  recentBlocks: '最新区块', testnet: '测试网', symbol: '符号', issuance: '发行状态', treasury: '金库', genesis: '创世', recentChainTx: '最近链上交易', recentAudits: '最近奖励审计', recentReviews: '最近奖励审核', recentWalletTx: '最近钱包转账', recentTransactions: '最近链上交易', walletList: '钱包列表', callbacks: '回调', heightFinality: '高度 / 确认', fullRecentList: '最近记录',
  langToggle: '韩语',
  legacyArchiveTitle: '旧版模拟账本存档', legacyReadOnly: '只读', legacyArchived: '已存档', legacyQuarantined: '已隔离',
  commonText: { active: '活跃', nodes: '节点', pending: '待确认', uptime: '运行', last: '最近', heightFinality: '高度 / 确认' },
  transferText: { ledger: '钱包转账账本', mine: '我的钱包转账', all: '全网钱包转账', helpMine: '按最新顺序查看我的钱包收发 plais 记录。', helpAll: '按最新顺序查看全网 plais 钱包转账流。', count: '转账数', received: '收到', sent: '发送', net: '净变化', time: '时间', type: '类型', direction: '方向', amount: '数量', memo: '备注', from: '发送钱包', to: '接收钱包', status: '状态', ledgerHeight: '账本', hash: '哈希', noRows: '没有可显示的转账记录。', in: '收到', out: '发送', self: '内部', transfer: '转账' },
  rewardText: { ledger: '主网式奖励账本', mine: '我收到的币奖励', all: '全网币奖励流', help: '应用 active、存储和节点 active 奖励以主网式币奖励账本显示。', total: '净奖励', items: '项', app: '应用 active', storage: '存储', node: '节点 active', other: '扣减 / 修正', review: '政策检查', blockRecorded: '区块记录', rejected: '拒绝', pending: '奖励已反映', blockPending: '等待区块记录', recipient: '接收人', noRows: '暂无可显示的奖励记录。' }
});

EXPLORER_TEXT.ja = Object.assign({}, EXPLORER_TEXT.en, {
  htmlLang: 'ja',
  switchLabel: '韓国語',
  modeLabel: 'plais Chain テストネット',
  mainnetLabel: 'plais Chain メインネット', testnetLabel: 'plais Chain テストネット',
  title: 'plais チェーン エクスプローラー',
  subtitle: 'ノード、担保、報酬、証明台帳の活動を一か所で確認します。',
  explorerTitle: 'plais チェーン エクスプローラー',
  navChainHome: 'チェーンホーム',
  navPlaiforHome: 'PLAIFOR ホーム',
  wallet: 'ウォレット',
  admin: '管理者',
  port: 'ポート',
  networkLabel: 'plais Chain Testnet',
  heroTitle: 'ノード、担保、報酬、証明台帳を一か所で追跡します。',
  heroSubtitle: '管理者は全体ネットワークを確認し、ログイン参加者は自分のノードと担保状態を確認します。',
  searchPlaceholder: 'アドレス、txHash、ブロック高/ハッシュ、proof id/hash を検索',
  search: '検索',
  searchButton: '検索',
  latestBlock: '最新ブロック',
  chainVerify: 'チェーン検証',
  readiness: '準備度',
  network: 'ネットワーク',
  notFound: '検索結果がありません',
  metrics: { wallets: 'ウォレット', circulating: '流通量', blocks: 'ブロック', finalized: '確定', recentTx: '最近のTx', proofs: '最近の証明', rewards: '最近の報酬', audits: '報酬監査', reviews: '報酬レビュー', nodes: '検証者 / ノード' },
  metricsLabel: 'エクスプローラー指標',
  wallets: 'ウォレット',
  circulating: '流通量',
  blocks: 'ブロック',
  finalized: '確定',
  recentTx: '最近のTx',
  recentProofs: '最近の証明',
  recentRewards: '最近の報酬',
  rewardAudits: '報酬監査',
  rewardReviews: '報酬レビュー',
  validators: '検証者 / ノード',
  participantLedger: '参加者ノード台帳',
  participantDashboard: '自分のノード概要',
  networkDashboard: 'ネットワークノード概要',
  dashboardNote: '管理者と公開画面にはネットワーク全体を表示し、ログイン参加者には自分のノードと担保状態のみを表示します。',
  noRecords: '記録がありません。',
  notFoundLabel: '検索結果がありません',
  statusMap: { active: '稼働中', waiting_deposit: '担保待ち', pending: '保留中', inactive: '非稼働', online: 'オンライン', offline: 'オフライン' },
  activeNodes: '稼働ノード',
  totalCapacity: '総容量',
  requiredCollateral: '必要担保',
  confirmedCollateral: '確認済み担保',
  pendingCollateral: '保留中担保',
  coverage: '担保充足率',
  policyTitle: '参加ポリシー',
  policyText: '現在のポリシーは 1TB あたり %TRX% TRX です。担保が確認されたノードのみ参加報酬の対象です。',
  policyWarning: '送信前にTRXアドレスとネットワークを確認してください。誤送信は自動復旧できない場合があります。',
  policySubtext: '各ノードは固有メモで追跡されるため、同じウォレットから複数ノードの担保を送ってもノード別に割り当てられます。',
  recentCollateral: '最近の担保確認', noEvents: '担保確認記録はまだありません。', nodeLedger: 'ノード担保台帳', required: '必要', lastHeartbeat: '最終ハートビート',
  noCollateral: '担保確認記録はまだありません。',
  node: 'ノード', status: '状態', capacity: '容量', confirmedPending: '確認済み / 保留中', collateralMemo: '担保メモ', noNodes: '表示するノードがありません。', uptimeVerify: '稼働 / 検証', usedPrefix: '使用',
  recentBlocks: '最新ブロック', testnet: 'テストネット', symbol: 'シンボル', issuance: '発行状態', treasury: 'トレジャリー', genesis: 'ジェネシス', recentChainTx: '最近のチェーンTx', recentAudits: '最近の報酬監査', recentReviews: '最近の報酬レビュー', recentWalletTx: '最近のウォレット転送', recentTransactions: '最近のチェーンTx', walletList: 'ウォレット一覧', callbacks: 'コールバック', heightFinality: '高さ / 確定性', fullRecentList: '最近の記録',
  langToggle: '韓国語',
  legacyArchiveTitle: '旧モック台帳アーカイブ', legacyReadOnly: '読み取り専用', legacyArchived: '保管済み', legacyQuarantined: '隔離済み',
  commonText: { active: '稼働中', nodes: 'ノード', pending: '保留中', uptime: '稼働', last: '最近', heightFinality: '高さ / 確定性' },
  transferText: { ledger: 'ウォレット転送台帳', mine: '自分のウォレット転送', all: 'ネットワークのウォレット転送', helpMine: '自分のウォレットで送受信した plais 転送を新しい順で確認します。', helpAll: 'ネットワーク全体の plais ウォレット転送を新しい順で確認します。', count: '転送数', received: '受取額', sent: '送信額', net: '純変動', time: '時間', type: '種類', direction: '方向', amount: '数量', memo: 'メモ', from: '送信元ウォレット', to: '受取ウォレット', status: '状態', ledgerHeight: '台帳', hash: 'ハッシュ', noRows: '表示する転送記録がありません。', in: '受取', out: '送信', self: '内部', transfer: '転送' },
  rewardText: { ledger: 'メインネット型報酬台帳', mine: '自分が受け取ったコイン報酬', all: 'ネットワークのコイン報酬フロー', help: 'アプリ active、ストレージ、ノード active 報酬をメインネット型コイン報酬台帳として表示します。', total: '純報酬', items: '件', app: 'アプリ active', storage: 'ストレージ', node: 'ノード active', other: '減点 / 修正', review: 'ポリシー確認', blockRecorded: 'ブロック記録', rejected: '拒否', pending: '報酬反映', blockPending: 'ブロック記録待ち', recipient: '受取人', noRows: '表示する報酬記録はまだありません。' }
});

EXPLORER_TEXT.ko.detailText = {
  addressDetail: '주소 상세', addressNotFound: '주소를 찾을 수 없음', addressNotFoundDesc: '요청한 지갑 주소를 찾을 수 없습니다.', balance: '잔액', available: '사용 가능', locked: '잠김', rewards: '보상', nonce: 'Nonce', transactions: '거래', rows: '건', noWalletTx: '아직 지갑 거래가 없습니다.',
  txDetail: '거래 상세', txNotFound: '거래를 찾을 수 없음', txNotFoundDesc: '요청한 거래를 찾을 수 없습니다.', transaction: '거래', legacyBlock: '레거시 블록', transfer: '전송', value: '값', fee: '수수료', created: '생성', confirmed: '확정', participants: '참여자', addressActor: '주소 / 행위자', ledgerBlock: '원장 블록', ledgerHash: '원장 해시', mockTxBlock: '모의 TX 블록', chainTx: '체인 TX', metadata: '메타데이터',
  reviewDecisionTrace: '검토 결정 추적', decision: '결정', reviewer: '검토자', reward: '보상', rewardLedger: '보상 원장', rewardAudit: '보상 감사', ledgerBlockAnchor: '원장 블록 앵커', validationOk: '검증 정상', review: '검토',
  canonicalTx: 'Besu QBFT 거래', networkStatus: '네트워크 상태', canonicalTransaction: '정규 체인 거래', chainId: '체인 ID', valueWei: '값 (wei)', gasLimit: '가스 한도', gasUsed: '사용 가스', canonicalInclusion: '정규 체인 포함 정보', block: '블록', blockHash: '블록 해시', contract: '컨트랙트', input: '입력 데이터',
  canonicalBlock: 'Besu QBFT 블록', canonicalBlockNotFound: '정규 체인 블록을 찾을 수 없음', finalizedBlock: 'QBFT 확정 블록', qbftValidators: 'QBFT 검증자', canonicalBlockIdentity: '정규 체인 블록 식별 정보', parentHash: '부모 해시', proposer: '제안자', baseFee: '기본 수수료', noBlockTx: '이 블록에는 거래가 없습니다.',
  legacyMockBlock: '레거시 모의 블록', blockNotFound: '블록을 찾을 수 없음', blockNotFoundDesc: '요청한 블록을 찾을 수 없습니다.', archivedHeight: '보관된 모의 블록 높이', proofs: '증명', votes: '투표', blockHashes: '블록 해시', chainLink: '체인 연결', previousHash: '이전 해시', merkleRoot: '머클 루트', finalized: '확정', chainTransactions: '체인 거래', noChainTx: '이 블록에는 체인 거래가 없습니다.', noProofs: '이 블록에는 증명이 없습니다.', noRewards: '이 블록에는 보상이 없습니다.', flags: '플래그',
  proofDetail: '증명 상세', proofNotFound: '증명을 찾을 수 없음', proofNotFoundDesc: '요청한 증명을 찾을 수 없습니다.', viewTx: 'TX 보기', proofSummary: '증명 요약', ledger: '원장', actor: '행위자', target: '대상', node: '노드', device: '기기', mined: '채굴', hashes: '해시', privacySafe: '개인정보 보호', proofHash: '증명 해시', contentHash: '콘텐츠 해시', chunkHash: '청크 해시', payloadHash: '페이로드 해시'
};

EXPLORER_TEXT.en.detailText = {
  addressDetail: 'Address Detail', addressNotFound: 'Address Not Found', addressNotFoundDesc: 'The requested wallet address could not be found.', balance: 'Balance', available: 'Available', locked: 'Locked', rewards: 'Rewards', nonce: 'Nonce', transactions: 'Transactions', rows: 'rows', noWalletTx: 'No wallet transactions yet.',
  txDetail: 'Transaction Detail', txNotFound: 'Transaction Not Found', txNotFoundDesc: 'The requested transaction could not be found.', transaction: 'Transaction', legacyBlock: 'Legacy Block', transfer: 'Transfer', value: 'Value', fee: 'Fee', created: 'Created', confirmed: 'Confirmed', participants: 'Participants', addressActor: 'address / actor', ledgerBlock: 'Ledger Block', ledgerHash: 'Ledger Hash', mockTxBlock: 'Mock Tx Block', chainTx: 'Chain Tx', metadata: 'Metadata',
  reviewDecisionTrace: 'Review Decision Trace', decision: 'Decision', reviewer: 'Reviewer', reward: 'Reward', rewardLedger: 'Reward Ledger', rewardAudit: 'Reward Audit', ledgerBlockAnchor: 'Ledger Block Anchor', validationOk: 'validation ok', review: 'review',
  canonicalTx: 'Besu QBFT Transaction', networkStatus: 'Network Status', canonicalTransaction: 'Canonical Transaction', chainId: 'chain ID', valueWei: 'Value (wei)', gasLimit: 'Gas Limit', gasUsed: 'Gas Used', canonicalInclusion: 'Canonical Inclusion', block: 'Block', blockHash: 'Block Hash', contract: 'Contract', input: 'Input',
  canonicalBlock: 'Besu QBFT Block', canonicalBlockNotFound: 'Canonical Block Not Found', finalizedBlock: 'QBFT Finalized Block', qbftValidators: 'QBFT Validators', canonicalBlockIdentity: 'Canonical Block Identity', parentHash: 'Parent Hash', proposer: 'Proposer', baseFee: 'Base Fee', noBlockTx: 'No transactions in this block.',
  legacyMockBlock: 'Legacy Mock Block', blockNotFound: 'Block Not Found', blockNotFoundDesc: 'The requested block could not be found.', archivedHeight: 'Archived Mock Height', proofs: 'Proofs', votes: 'Votes', blockHashes: 'Block Hashes', chainLink: 'chain link', previousHash: 'Previous Hash', merkleRoot: 'Merkle Root', finalized: 'Finalized', chainTransactions: 'Chain Transactions', noChainTx: 'No chain transactions in this block.', noProofs: 'No proofs in this block.', noRewards: 'No rewards in this block.', flags: 'flags',
  proofDetail: 'Proof Detail', proofNotFound: 'Proof Not Found', proofNotFoundDesc: 'The requested proof could not be found.', viewTx: 'View Tx', proofSummary: 'Proof Summary', ledger: 'ledger', actor: 'Actor', target: 'Target', node: 'Node', device: 'Device', mined: 'Mined', hashes: 'Hashes', privacySafe: 'privacy-safe', proofHash: 'Proof Hash', contentHash: 'Content Hash', chunkHash: 'Chunk Hash', payloadHash: 'Payload Hash'
};

EXPLORER_TEXT.zh.detailText = {
  addressDetail: '地址详情', addressNotFound: '未找到地址', addressNotFoundDesc: '找不到请求的钱包地址。', balance: '余额', available: '可用', locked: '锁定', rewards: '奖励', nonce: 'Nonce', transactions: '交易', rows: '条', noWalletTx: '暂无钱包交易。',
  txDetail: '交易详情', txNotFound: '未找到交易', txNotFoundDesc: '找不到请求的交易。', transaction: '交易', legacyBlock: '旧版区块', transfer: '转账', value: '数值', fee: '手续费', created: '创建时间', confirmed: '确认时间', participants: '参与者', addressActor: '地址 / 行为者', ledgerBlock: '账本区块', ledgerHash: '账本哈希', mockTxBlock: '模拟 TX 区块', chainTx: '链上 TX', metadata: '元数据',
  reviewDecisionTrace: '审核决定追踪', decision: '决定', reviewer: '审核人', reward: '奖励', rewardLedger: '奖励账本', rewardAudit: '奖励审计', ledgerBlockAnchor: '账本区块锚点', validationOk: '验证正常', review: '审核',
  canonicalTx: 'Besu QBFT 交易', networkStatus: '网络状态', canonicalTransaction: '主链交易', chainId: '链 ID', valueWei: '数值 (wei)', gasLimit: 'Gas 上限', gasUsed: '已用 Gas', canonicalInclusion: '主链收录信息', block: '区块', blockHash: '区块哈希', contract: '合约', input: '输入数据',
  canonicalBlock: 'Besu QBFT 区块', canonicalBlockNotFound: '未找到主链区块', finalizedBlock: 'QBFT 已确认区块', qbftValidators: 'QBFT 验证者', canonicalBlockIdentity: '主链区块标识', parentHash: '父区块哈希', proposer: '提议者', baseFee: '基础手续费', noBlockTx: '此区块中没有交易。',
  legacyMockBlock: '旧版模拟区块', blockNotFound: '未找到区块', blockNotFoundDesc: '找不到请求的区块。', archivedHeight: '已存档模拟区块高度', proofs: '证明', votes: '投票', blockHashes: '区块哈希', chainLink: '链连接', previousHash: '前一区块哈希', merkleRoot: 'Merkle 根', finalized: '已确认', chainTransactions: '链上交易', noChainTx: '此区块中没有链上交易。', noProofs: '此区块中没有证明。', noRewards: '此区块中没有奖励。', flags: '标记',
  proofDetail: '证明详情', proofNotFound: '未找到证明', proofNotFoundDesc: '找不到请求的证明。', viewTx: '查看 TX', proofSummary: '证明摘要', ledger: '账本', actor: '行为者', target: '目标', node: '节点', device: '设备', mined: '打包时间', hashes: '哈希', privacySafe: '隐私保护', proofHash: '证明哈希', contentHash: '内容哈希', chunkHash: '分片哈希', payloadHash: '载荷哈希'
};

EXPLORER_TEXT.ja.detailText = {
  addressDetail: 'アドレス詳細', addressNotFound: 'アドレスが見つかりません', addressNotFoundDesc: '指定されたウォレットアドレスが見つかりません。', balance: '残高', available: '利用可能', locked: 'ロック中', rewards: '報酬', nonce: 'Nonce', transactions: 'トランザクション', rows: '件', noWalletTx: 'ウォレット取引はまだありません。',
  txDetail: 'トランザクション詳細', txNotFound: 'トランザクションが見つかりません', txNotFoundDesc: '指定されたトランザクションが見つかりません。', transaction: 'トランザクション', legacyBlock: '旧ブロック', transfer: '転送', value: '値', fee: '手数料', created: '作成', confirmed: '確定', participants: '参加者', addressActor: 'アドレス / 実行者', ledgerBlock: '台帳ブロック', ledgerHash: '台帳ハッシュ', mockTxBlock: 'モックTXブロック', chainTx: 'チェーンTX', metadata: 'メタデータ',
  reviewDecisionTrace: 'レビュー決定トレース', decision: '決定', reviewer: 'レビュー担当者', reward: '報酬', rewardLedger: '報酬台帳', rewardAudit: '報酬監査', ledgerBlockAnchor: '台帳ブロックアンカー', validationOk: '検証正常', review: 'レビュー',
  canonicalTx: 'Besu QBFTトランザクション', networkStatus: 'ネットワーク状態', canonicalTransaction: '正規チェーントランザクション', chainId: 'チェーンID', valueWei: '値 (wei)', gasLimit: 'Gas上限', gasUsed: '使用Gas', canonicalInclusion: '正規チェーン収録情報', block: 'ブロック', blockHash: 'ブロックハッシュ', contract: 'コントラクト', input: '入力データ',
  canonicalBlock: 'Besu QBFTブロック', canonicalBlockNotFound: '正規チェーンブロックが見つかりません', finalizedBlock: 'QBFT確定ブロック', qbftValidators: 'QBFT検証者', canonicalBlockIdentity: '正規チェーンブロック識別情報', parentHash: '親ハッシュ', proposer: '提案者', baseFee: '基本手数料', noBlockTx: 'このブロックにはトランザクションがありません。',
  legacyMockBlock: '旧モックブロック', blockNotFound: 'ブロックが見つかりません', blockNotFoundDesc: '指定されたブロックが見つかりません。', archivedHeight: '保管済みモックブロック高', proofs: '証明', votes: '投票', blockHashes: 'ブロックハッシュ', chainLink: 'チェーン接続', previousHash: '前のハッシュ', merkleRoot: 'マークルルート', finalized: '確定', chainTransactions: 'チェーントランザクション', noChainTx: 'このブロックにはチェーントランザクションがありません。', noProofs: 'このブロックには証明がありません。', noRewards: 'このブロックには報酬がありません。', flags: 'フラグ',
  proofDetail: '証明詳細', proofNotFound: '証明が見つかりません', proofNotFoundDesc: '指定された証明が見つかりません。', viewTx: 'TXを表示', proofSummary: '証明概要', ledger: '台帳', actor: '実行者', target: '対象', node: 'ノード', device: 'デバイス', mined: '採掘', hashes: 'ハッシュ', privacySafe: 'プライバシー保護', proofHash: '証明ハッシュ', contentHash: 'コンテンツハッシュ', chunkHash: 'チャンクハッシュ', payloadHash: 'ペイロードハッシュ'
};



const EXPLORER_REWARD_REASON_TEXT = {
  ko: {
    petGrowth: 'PLAI펫 성장 보상',
    chunkVerified: 'chunk 검증 성공 보상',
    nodeOnline: '온라인 유지 보상',
    storageVerified: '저장소 검증 보상'
  },
  en: {
    petGrowth: 'PLAI pet growth reward',
    chunkVerified: 'Chunk verification reward',
    nodeOnline: 'Online uptime reward',
    storageVerified: 'Storage verification reward'
  },
  zh: {
    petGrowth: 'PLAI 宠物成长奖励',
    chunkVerified: 'chunk 验证成功奖励',
    nodeOnline: '在线维持奖励',
    storageVerified: '存储验证奖励'
  },
  ja: {
    petGrowth: 'PLAIペット成長報酬',
    chunkVerified: 'chunk検証成功報酬',
    nodeOnline: 'オンライン維持報酬',
    storageVerified: 'ストレージ検証報酬'
  }
};

function translateExplorerRewardNote(note, lang = 'ko') {
  const raw = String(note || '').trim();
  if (!raw) return '';
  const text = EXPLORER_REWARD_REASON_TEXT[lang] || EXPLORER_REWARD_REASON_TEXT.ko;
  const normalized = raw.toLowerCase();

  if (normalized.includes('pet') || normalized.includes('ais펫') || normalized.includes('plai펫') || raw.includes('성장')) {
    return text.petGrowth;
  }
  if (normalized.includes('chunk') || raw.includes('검증 성공') || raw.includes('검증성공')) {
    return text.chunkVerified;
  }
  if (normalized.includes('node_online') || normalized.includes('uptime') || raw.includes('온라인 유지')) {
    return text.nodeOnline;
  }
  if (normalized.includes('storage') || raw.includes('저장소')) {
    return text.storageVerified;
  }
  return raw;
}

function getExplorerLang(req) {
  const lang = String(req.query.lang || '').toLowerCase();
  return EXPLORER_SUPPORTED_LANGS.includes(lang) ? lang : 'ko';
}

function isCanonicalMainnetOperational(status) {
  const canonical = status?.canonical || {};
  const expectedValidators = Number(canonical.configuredValidatorCount || status?.config?.validatorCount || 4);
  return canonical.online === true
    && canonical.id === 'besu-qbft'
    && Number(canonical.chainId || 0) === Number(status?.config?.chainId || 0)
    && String(canonical.consensus || '').toUpperCase() === 'QBFT'
    && Number(canonical.validatorCount || 0) >= expectedValidators;
}

function buildExplorerLocale(req, status) {
  const lang = getExplorerLang(req);
  const baseText = EXPLORER_TEXT[lang] || EXPLORER_TEXT.ko;
  return {
    lang,
    text: {
      ...baseText,
      networkLabel: isCanonicalMainnetOperational(status) ? baseText.mainnetLabel : baseText.testnetLabel
    },
    urlWithLang: (path) => withLang(path, lang)
  };
}

function withLang(path, lang) {
  if (!lang || lang === 'ko') return path;
  const joiner = path.includes('?') ? '&' : '?';
  return `${path}${joiner}lang=${encodeURIComponent(lang)}`;
}

function buildLangUrl(req, nextLang) {
  const basePath = req.baseUrl || '/explorer';
  const params = new URLSearchParams(req.query || {});
  if (nextLang === 'ko') params.delete('lang'); else params.set('lang', nextLang);
  const query = params.toString();
  return `${basePath}${query ? `?${query}` : ''}`;
}

function normalizeKey(value) {
  return String(value || '').trim().toLowerCase();
}

function getSessionUser(req) {
  const session = req.session || {};
  return req.walletUser || req.user || session.user || session.aisforUser || session.walletUser || null;
}

function getSessionUserKeys(user) {
  if (!user) return [];
  const metadata = user.metadata || {};
  return [
    user.userId,
    user.userid,
    user.username,
    user.loginId,
    user.mongoId,
    user.id,
    user._id,
    metadata.userId,
    metadata.userid,
    metadata.username,
    metadata.loginId,
    metadata.mongoId
  ].map(normalizeKey).filter(Boolean);
}

function getSessionUserId(user) {
  return getSessionUserKeys(user)[0] || '';
}

function hasSessionUser(user) {
  return getSessionUserKeys(user).length > 0;
}

function isAdminUser(user) {
  const role = normalizeKey(user && (user.role || user.userRole || user.type));
  return Boolean(user && (user.isAdmin === true || user.admin === true || role === 'admin' || role === 'manager'));
}

function isAdminRequest(req, user) {
  const session = req.session || {};
  return Boolean(session.chainAdminAuthed || session.adminAuthed || session.adminAuthenticated || session.chainAdmin || session.isAdmin || session.user?.isAdmin || isAdminUser(user));
}

function latestRows(rows, limit = 20) {
  return (rows || []).slice().sort((a, b) => {
    const at = new Date(a.confirmedAt || a.createdAt || a.updatedAt || a.timestamp || 0).getTime();
    const bt = new Date(b.confirmedAt || b.createdAt || b.updatedAt || b.timestamp || 0).getTime();
    return bt - at;
  }).slice(0, limit);
}

function shortHash(value, left = 10, right = 8) {
  const text = String(value || '');
  if (text.length <= left + right + 3) return text;
  return `${text.slice(0, left)}...${text.slice(-right)}`;
}

function firstFiniteNumber(...values) {
  for (const value of values) {
    const number = Number(value);
    if (Number.isFinite(number) && number > 0) return number;
  }
  return 0;
}

function getNodeCapacityTb(node, config = {}) {
  const metadata = node.metadata || {};
  const directTb = firstFiniteNumber(node.capacityTb, node.storageTb, node.providedTb, metadata.capacityTb, metadata.storageTb, metadata.providedTb);
  if (directTb) return directTb;
  const directGb = firstFiniteNumber(node.capacityGb, node.storageGb, node.providedGb, metadata.capacityGb, metadata.storageGb, metadata.providedGb);
  if (directGb) return directGb / 1000;
  const bytes = firstFiniteNumber(node.storageLimitBytes, node.maxStorageBytes, node.availableStorageBytes, metadata.storageLimitBytes, metadata.maxStorageBytes);
  if (bytes) return bytes / (1024 * 1024 * 1024 * 1024);
  return Number(config.minimumCapacityTb || config.baseCapacityTb || 1) || 1;
}

function formatNodeCapacity(capacityTb) {
  const value = Number(capacityTb || 0);
  if (!Number.isFinite(value) || value <= 0) return '0 TB';
  if (value < 1) return `${Math.round(value * 1000).toLocaleString('en-US')} GB`;
  if (Number.isInteger(value)) return `${value.toLocaleString('en-US')} TB`;
  return `${value.toLocaleString('en-US', { maximumFractionDigits: 2 })} TB`;
}

function bytesLabel(bytes) {
  const value = Number(bytes || 0);
  if (!Number.isFinite(value) || value <= 0) return '0 B';
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  let size = value;
  let unit = 0;
  while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1; }
  return `${size.toLocaleString('en-US', { maximumFractionDigits: unit === 0 ? 0 : 1 })} ${units[unit]}`;
}

function findNodeAccount(node, accounts = []) {
  const nodeId = String(node.nodeId || node.id || node.deviceId || '');
  return accounts.find((account) => String(account.ownerType || '') === 'node' && String(account.ownerId || account.nodeId || account.id || '') === nodeId) || null;
}

function isExplorerParticipantNode(node) {
  const metadata = node.metadata || {};
  const endpoint = String(node.endpoint || '');
  const deviceName = String(node.deviceName || node.name || '');
  if (metadata.seeded === true) return false;
  if (endpoint.startsWith('mock://')) return false;
  if (/mock participant/i.test(deviceName)) return false;
  return true;
}

function collectNodeUserKeys(node, account) {
  const metadata = node.metadata || {};
  const accountMetadata = account?.metadata || {};
  return [
    node.userId,
    node.userid,
    node.username,
    node.loginId,
    node.userIdHash,
    node.ownerId,
    node.ownerUserId,
    node.ownerMongoId,
    node.accountId,
    node.aisforUserId,
    node.createdBy,
    metadata.userId,
    metadata.userid,
    metadata.username,
    metadata.loginId,
    metadata.userIdHash,
    metadata.ownerId,
    metadata.ownerUserId,
    metadata.ownerMongoId,
    metadata.ownerUserIdHash,
    metadata.accountId,
    metadata.aisforUserId,
    metadata.createdBy,
    account?.userId,
    account?.userid,
    account?.username,
    account?.loginId,
    account?.userIdHash,
    account?.ownerId,
    account?.ownerUserId,
    account?.ownerMongoId,
    account?.accountId,
    accountMetadata.userId,
    accountMetadata.userid,
    accountMetadata.username,
    accountMetadata.loginId,
    accountMetadata.userIdHash,
    accountMetadata.ownerId,
    accountMetadata.ownerUserId,
    accountMetadata.ownerMongoId,
    accountMetadata.ownerUserIdHash,
    accountMetadata.accountId
  ]
    .map(normalizeKey).filter(Boolean);
}

function nodeMatchesSessionUser(node, account, sessionUser) {
  const userIds = getSessionUserKeys(sessionUser);
  if (!userIds.length) return false;
  const nodeKeys = collectNodeUserKeys(node, account);
  return userIds.some((userId) => nodeKeys.includes(userId));
}

function getCollateralRequired(capacityTb, config) {
  const rate = Number(config.trxPerTb || config.trxPerUnit || 0);
  const base = Number(config.baseCapacityTb || config.minimumCapacityTb || 1) || 1;
  if (!rate) return 0;
  return Math.max(rate * (Number(capacityTb || 0) / base), Number(config.minimumNodeCollateralTrx || 0));
}

function firstStringValue(source, keys) {
  if (!source) return '';
  const metadata = source.metadata || {};
  for (const key of keys) {
    const value = source[key] ?? metadata[key];
    if (value && typeof value === 'object') {
      const nested = value.address || value.walletAddress || value.publicAddress || value.id || value._id;
      if (nested !== undefined && nested !== null && String(nested).trim()) return String(nested).trim();
      continue;
    }
    if (value !== undefined && value !== null && String(value).trim()) return String(value).trim();
  }
  return '';
}

function walletAddressValue(wallet) {
  return firstStringValue(wallet, ['address', 'walletAddress', 'publicAddress', 'id']);
}

function walletAddressKey(wallet) {
  return normalizeKey(walletAddressValue(wallet));
}

function collectWalletUserKeys(wallet) {
  const metadata = wallet?.metadata || {};
  return [
    wallet?.userId, wallet?.userid, wallet?.username, wallet?.loginId,
    wallet?.userIdHash, wallet?.ownerId, wallet?.ownerUserId,
    wallet?.ownerMongoId, wallet?.accountId, wallet?.aisforUserId, wallet?.createdBy,
    metadata.userId, metadata.userid, metadata.username, metadata.loginId,
    metadata.userIdHash, metadata.ownerId, metadata.ownerUserId,
    metadata.ownerMongoId, metadata.ownerUserIdHash, metadata.accountId,
    metadata.aisforUserId, metadata.createdBy
  ].map(normalizeKey).filter(Boolean);
}

function walletMatchesSessionUser(wallet, sessionUser) {
  const userIds = getSessionUserKeys(sessionUser);
  if (!userIds.length) return false;
  const walletKeys = collectWalletUserKeys(wallet);
  return userIds.some((userId) => walletKeys.includes(userId));
}

function collectTransactionUserKeys(tx) {
  const metadata = tx?.metadata || {};
  return [
    tx?.userId, tx?.userid, tx?.username, tx?.loginId,
    tx?.fromUserId, tx?.toUserId, tx?.ownerUserId, tx?.accountId, tx?.createdBy,
    metadata.userId, metadata.userid, metadata.username, metadata.loginId,
    metadata.fromUserId, metadata.toUserId, metadata.ownerUserId,
    metadata.accountId, metadata.createdBy
  ].map(normalizeKey).filter(Boolean);
}

function transactionAddress(tx, side) {
  const fromKeys = ['fromAddress', 'from', 'senderAddress', 'sender', 'sourceAddress', 'source', 'fromWalletAddress', 'fromWallet'];
  const toKeys = ['toAddress', 'to', 'recipientAddress', 'recipient', 'targetAddress', 'target', 'toWalletAddress', 'toWallet'];
  return firstStringValue(tx, side === 'from' ? fromKeys : toKeys);
}

function walletTxAmount(tx) {
  const metadata = tx?.metadata || {};
  const values = [tx?.amount, tx?.amountPla, tx?.amountPLAI, tx?.value, tx?.quantity, metadata.amount, metadata.value];
  for (const value of values) {
    const number = Number(value);
    if (Number.isFinite(number) && number !== 0) return Math.abs(number);
  }
  return 0;
}

function buildWalletTransferExplorer(wallets = [], walletTransactions = [], sessionUser = null) {
  const personalMode = hasSessionUser(sessionUser);
  const ownedWallets = personalMode ? wallets.filter((wallet) => walletMatchesSessionUser(wallet, sessionUser)) : wallets;
  const ownedAddresses = new Set(ownedWallets.map(walletAddressKey).filter(Boolean));
  const sessionKeys = getSessionUserKeys(sessionUser);
  const txRows = latestRows(walletTransactions, 240).map((tx) => {
    const fromAddress = transactionAddress(tx, 'from');
    const toAddress = transactionAddress(tx, 'to');
    const fromMine = ownedAddresses.has(normalizeKey(fromAddress));
    const toMine = ownedAddresses.has(normalizeKey(toAddress));
    let direction = String(tx.direction || tx.metadata?.direction || '').toUpperCase();
    if (personalMode) {
      if (fromMine && toMine) direction = 'SELF';
      else if (fromMine) direction = 'OUT';
      else if (toMine) direction = 'IN';
      else if (!direction) direction = 'TRANSFER';
    } else if (!['IN', 'OUT', 'SELF'].includes(direction)) {
      direction = 'TRANSFER';
    }
    return {
      id: tx.id || tx.txId || tx.txHash || '',
      txHash: tx.txHash || tx.hash || tx.id || tx.txId || '',
      chainHash: tx.chainTxHash || tx.chainTransactionHash || tx.metadata?.chainTxHash || tx.metadata?.chainHash || '',
      ledgerHeight: tx.ledgerHeight || tx.blockHeight || tx.metadata?.ledgerHeight || tx.metadata?.blockHeight || '',
      type: tx.type || tx.kind || 'TRANSFER',
      direction,
      amount: walletTxAmount(tx),
      memo: tx.memo || tx.metadata?.memo || '',
      status: tx.status || tx.metadata?.status || '',
      fromAddress,
      toAddress,
      createdAt: tx.createdAt || tx.timestamp || tx.updatedAt,
      matchesUser: personalMode ? (fromMine || toMine || collectTransactionUserKeys(tx).some((key) => sessionKeys.includes(key))) : true
    };
  }).filter((row) => !personalMode || row.matchesUser).slice(0, 30);
  const summary = txRows.reduce((acc, row) => {
    acc.txCount += 1;
    if (row.direction === 'IN') acc.received += row.amount;
    else if (row.direction === 'OUT') acc.sent += row.amount;
    else if (row.direction === 'SELF') acc.self += row.amount;
    return acc;
  }, { txCount: 0, walletCount: ownedWallets.length, received: 0, sent: 0, self: 0 });
  summary.net = summary.received - summary.sent;
  return { rows: txRows, isPersonal: personalMode, summary };
}

function objectIdOrNull(value) {
  const text = String(value || '').trim();
  return ObjectId.isValid(text) ? new ObjectId(text) : null;
}

function aisforSourceDeviceMatchesSessionUser(device = {}, sessionUser = null) {
  const sessionKeys = getSessionUserKeys(sessionUser);
  if (!sessionKeys.length) return true;
  const deviceKeys = [
    device.userId,
    device.userid,
    device.ownerUserId,
    device.ownerMongoId,
    device.username,
    device.loginId,
    device.email,
    device._id
  ].map(normalizeKey).filter(Boolean);
  return sessionKeys.some(key => deviceKeys.includes(key));
}

async function loadAisforNodeDisplayMap(nodes = [], sessionUser = null) {
  const nodeIds = Array.from(new Set((nodes || [])
    .map(node => String(node.nodeId || node.id || node.deviceId || '').trim())
    .filter(value => ObjectId.isValid(value))));
  if (!nodeIds.length) return new Map();

  try {
    const db = await getAisforSourceDb();
    const onlineSince = new Date(Date.now() - AISFOR_NODE_ONLINE_TTL_MS);
    const distNodes = await db.collection('distributednodes').find({
      _id: { $in: nodeIds.map(id => new ObjectId(id)) },
      $or: [
        { status: 'online' },
        { lastHeartbeatAt: { $gte: onlineSince } }
      ]
    }).project({
      _id: 1,
      nodeName: 1,
      hostname: 1,
      deviceId: 1,
      nodeStoragePath: 1,
      status: 1,
      lastHeartbeatAt: 1
    }).toArray();

    const deviceIds = distNodes.map(node => objectIdOrNull(node.deviceId)).filter(Boolean);
    const devices = deviceIds.length
      ? await db.collection('syncdevices').find({
        _id: { $in: deviceIds },
        revokedAt: null
      }).project({
        _id: 1,
        userId: 1,
        userid: 1,
        ownerUserId: 1,
        ownerMongoId: 1,
        username: 1,
        loginId: 1,
        email: 1,
        hostname: 1,
        deviceName: 1
      }).toArray()
      : [];
    const deviceMap = new Map(devices
      .filter(device => aisforSourceDeviceMatchesSessionUser(device, sessionUser))
      .map(device => [String(device._id), device]));

    return new Map(distNodes.map(node => {
      const device = deviceMap.get(String(node.deviceId || ''));
      if (hasSessionUser(sessionUser) && !device) return null;
      const displayName = device?.hostname || node.hostname || node.nodeName || device?.deviceName || '';
      return [String(node._id), {
        displayName,
        originalName: node.nodeName || '',
        hostname: device?.hostname || node.hostname || '',
        nodeStoragePath: node.nodeStoragePath || '',
        status: node.status || '',
        lastHeartbeatAt: node.lastHeartbeatAt || null,
        isCurrent: Boolean(displayName)
      }];
    }).filter(Boolean));
  } catch (err) {
    console.warn('[explorer] AISFOR node display map skipped:', err.message);
    return new Map();
  }
}

async function buildNodeExplorer(nodes = [], sessionUser = null) {
  const aisforDisplayMap = await loadAisforNodeDisplayMap(nodes, sessionUser);
  const config = trxCollateralService.refreshCollateralConfig ? trxCollateralService.refreshCollateralConfig() : (trxCollateralService.getCollateralConfig ? trxCollateralService.getCollateralConfig() : {});
  const accounts = trxCollateralService.listAccounts ? trxCollateralService.listAccounts() : [];
  const events = trxCollateralService.listEvents ? trxCollateralService.listEvents({ limit: 160 }) : [];
  const rows = (nodes || []).filter(isExplorerParticipantNode).map((node) => {
    const account = findNodeAccount(node, accounts);
    const capacityTb = getNodeCapacityTb(node, config);
    const requiredCollateral = getCollateralRequired(capacityTb, config);
    const confirmed = Number(account?.confirmedTrx || account?.confirmedAmount || account?.confirmed || 0);
    const pending = Number(account?.pendingTrx || account?.pendingAmount || account?.pending || 0);
    const coverage = requiredCollateral > 0 ? Math.min(100, Math.round((confirmed / requiredCollateral) * 100)) : 0;
    const status = confirmed >= requiredCollateral && requiredCollateral > 0 ? 'active' : (account?.status || node.status || 'waiting_deposit');
    const nodeId = String(node.nodeId || node.id || node.deviceId || account?.ownerId || '');
    const aisforDisplay = aisforDisplayMap.get(nodeId) || null;
    return {
      node,
      account,
      nodeId,
      name: aisforDisplay?.displayName || node.deviceName || node.name || nodeId || 'Node',
      originalName: node.deviceName || node.name || '',
      isCurrentAisforNode: Boolean(aisforDisplay?.isCurrent),
      service: node.service || node.ownerService || node.metadata?.service || 'plais',
      status,
      capacityTb,
      capacityLabel: formatNodeCapacity(capacityTb),
      usedLabel: bytesLabel(node.usedBytes || node.usedStorageBytes || node.metadata?.usedBytes || 0),
      requiredCollateral,
      confirmedCollateral: confirmed,
      pendingCollateral: pending,
      confirmed,
      pending,
      coverage,
      lastHeartbeatAt: node.lastHeartbeatAt || node.heartbeatAt || node.updatedAt || node.createdAt,
      memo: account?.memo || account?.metadata?.memo || (account?.id ? `plais-${account.id}` : ''),
      isMine: nodeMatchesSessionUser(node, account, sessionUser)
    };
  });
  const personalMode = hasSessionUser(sessionUser);
  const personalRows = rows.filter(row => row.isMine);
  const visibleRows = personalMode ? personalRows.filter(row => row.isCurrentAisforNode) : rows;
  const visibleIds = new Set(visibleRows.flatMap(row => [row.nodeId, row.account?.id, row.account?.ownerId].filter(Boolean).map(String)));
  const recentEvents = latestRows(events.filter(event => visibleIds.has(String(event.accountId || event.ownerId || event.nodeId || event.metadata?.nodeId || ''))), 8);
  const summary = visibleRows.reduce((acc, row) => {
    acc.activeNodes += row.status === 'active' ? 1 : 0;
    acc.totalCapacityTb += row.capacityTb;
    acc.requiredCollateral += row.requiredCollateral;
    acc.confirmedCollateral += row.confirmed;
    acc.pendingCollateral += row.pending;
    return acc;
  }, { activeNodes: 0, totalCapacityTb: 0, requiredCollateral: 0, confirmedCollateral: 0, pendingCollateral: 0 });
  summary.totalNodes = visibleRows.length;
  summary.coverage = summary.requiredCollateral > 0 ? Math.round((summary.confirmedCollateral / summary.requiredCollateral) * 100) : 0;
  summary.totalCapacityLabel = formatNodeCapacity(summary.totalCapacityTb);
  return { config, rows: visibleRows, allRows: rows, recentEvents, isPersonal: personalMode, summary };
}

function getRewardClass(entry = {}) {
  const category = normalizeKey(entry.category);
  const actionType = normalizeKey(entry.actionType);
  const sourceService = normalizeKey(entry.sourceService);
  const refType = normalizeKey(entry.refType);
  const note = normalizeKey(entry.metadata?.note);
  const haystack = [category, actionType, sourceService, refType, note].join(' ');

  if (haystack.includes('slash') || haystack.includes('cancel') || Number(entry.rewardAmount || 0) < 0) {
    return { key: 'correction', labelKo: '차감 / 정정', labelEn: 'Correction' };
  }
  if (category === 'storage' || haystack.includes('storage') || haystack.includes('chunk') || haystack.includes('proof_bundle')) {
    return { key: 'storage', labelKo: '스토리지 보상', labelEn: 'Storage Reward' };
  }
  if (category === 'uptime' || haystack.includes('node_online') || haystack.includes('uptime') || haystack.includes('validator')) {
    return { key: 'node', labelKo: '노드 active 보상', labelEn: 'Node Active Reward' };
  }
  if (haystack.includes('activity') || haystack.includes('message') || haystack.includes('mall') || haystack.includes('group') || haystack.includes('cloud') || haystack.includes('pet') || category === 'service' || category === 'content' || category === 'commerce' || category === 'emoticon' || category === 'ecosystem') {
    return { key: 'app', labelKo: '앱 사용 active 보상', labelEn: 'App Active Reward' };
  }
  return { key: 'other', labelKo: '기타 보상', labelEn: 'Other Reward' };
}

function rewardMatchesSessionUser(entry = {}, sessionUser, nodeExplorer = {}) {
  const userKeys = getSessionUserKeys(sessionUser);
  if (!userKeys.length) return false;
  const actor = normalizeKey(entry.actorId);
  const metadata = entry.metadata || {};
  const capReview = metadata.capReview || {};
  const rewardKeys = [
    actor,
    metadata.userId,
    metadata.userid,
    metadata.username,
    metadata.loginId,
    metadata.ownerId,
    metadata.ownerUserId,
    metadata.nodeId,
    metadata.actorId,
    capReview.actorId
  ].map(normalizeKey).filter(Boolean);
  const myNodeIds = new Set((nodeExplorer.allRows || [])
    .filter(row => row.isMine)
    .map(row => normalizeKey(row.nodeId)));
  return userKeys.some(key => rewardKeys.includes(key)) || myNodeIds.has(actor);
}

function getCurrentPolicyFlags(entry = {}) {
  const validation = rewardPolicyService.validateRewardSubmission({
    sourceService: entry.sourceService,
    category: entry.category,
    actionType: entry.actionType,
    idempotencyKey: entry.metadata?.idempotencyKey || ''
  });
  return new Set([...(validation.flags || []), ...(validation.warnings || [])]);
}

function getActiveRewardFlags(entry = {}) {
  const currentPolicyFlags = getCurrentPolicyFlags(entry);
  return (entry.flags || []).filter((flag) => {
    if (flag === 'SOURCE_CATEGORY_NOT_ALLOWED' || flag === 'SOURCE_EVENT_NOT_LISTED') {
      return currentPolicyFlags.has(flag);
    }
    return true;
  });
}

function getRewardStatus(entry = {}) {
  if (entry.metadata?.reviewDecision?.decision === 'REJECT') return 'rejected';
  if (getActiveRewardFlags(entry).length) return 'review';
  if (entry.blockHeight) return 'anchored';
  return 'pending';
}

function buildRewardExplorer(rewards = [], sessionUser = null, nodeExplorer = {}, lang = 'ko', isAdmin = false) {
  const personalMode = hasSessionUser(sessionUser) && !isAdmin;
  const visibleRewards = personalMode
    ? rewards.filter(entry => rewardMatchesSessionUser(entry, sessionUser, nodeExplorer))
    : rewards;
  const rows = latestRows(visibleRewards, Math.max(visibleRewards.length, 1)).map((entry) => {
    const rewardClass = getRewardClass(entry);
    const rewardTextForLang = (EXPLORER_TEXT[lang] && EXPLORER_TEXT[lang].rewardText) || EXPLORER_TEXT.en.rewardText || {};
    const rewardClassKey = rewardClass.key === 'correction' ? 'other' : rewardClass.key;
    const rewardClassLabel = rewardTextForLang[rewardClassKey] || rewardClass.labelEn || rewardClass.labelKo || rewardClass.key;
    return {
      entry,
      id: entry.id,
      label: rewardClassLabel,
      classKey: rewardClass.key,
      sourceService: entry.sourceService || '-',
      actionType: entry.actionType || '-',
      actorId: entry.actorId || '-',
      amount: Number(entry.rewardAmount || 0),
      score: Number(entry.rewardScore || 0),
      symbol: entry.symbol || 'plais',
      status: getRewardStatus(entry),
      blockHeight: entry.blockHeight || null,
      blockHash: entry.blockHash || '',
      createdAt: entry.createdAt || entry.metadata?.sourceCreatedAt || null,
      note: translateExplorerRewardNote(entry.metadata?.note || entry.note || entry.memo || '', lang),
      flags: getActiveRewardFlags(entry),
      refId: entry.refId || entry.metadata?.externalEventKey || entry.metadata?.idempotencyKey || ''
    };
  });
  const summary = rows.reduce((acc, row) => {
    acc.totalAmount += row.amount;
    acc.totalCount += 1;
    if (!acc.byClass[row.classKey]) {
      acc.byClass[row.classKey] = { amount: 0, count: 0, label: row.label };
    }
    acc.byClass[row.classKey].amount += row.amount;
    acc.byClass[row.classKey].count += 1;
    acc.status[row.status] = (acc.status[row.status] || 0) + 1;
    return acc;
  }, {
    totalAmount: 0,
    totalCount: 0,
    byClass: {},
    status: {}
  });
  return {
    isPersonal: personalMode,
    rows,
    summary,
    recentRows: rows.slice(0, 14)
  };
}

function explorerService(testnet) {
  return {
    name: 'plais Chain Explorer',
    mode: testnet.config.chainName,
    port: process.env.PORT || 4317
  };
}

function findProof(value) {
  const key = String(value || '').trim();
  if (!key) return null;
  return proofLedgerService.getProof(key) || proofLedgerService.listProofs().find((proof) => (
    proof.proofHash === key ||
    proof.txHash === key ||
    proof.chainTransactionId === key ||
    proof.externalProofId === key ||
    proof.contentHash === key ||
    proof.chunkHash === key ||
    proof.payloadHash === key ||
    proof.bundleHash === key ||
    proof.merkleRoot === key
  )) || null;
}

function findReward(value) {
  const key = String(value || '').trim();
  if (!key) return null;
  return rewardLedgerService.listEntries().find((reward) => (
    reward.id === key ||
    reward.refId === key ||
    reward.metadata?.idempotencyKey === key ||
    reward.metadata?.externalEventKey === key ||
    reward.metadata?.externalProofHash === key
  )) || null;
}

function findRewardAudit(value) {
  return rewardAuditService.findEntry(value);
}

function findRewardReviewDecision(value) {
  const key = String(value || '').trim();
  if (!key) return null;
  return rewardReviewService.listDecisions().find((decision) => (
    decision.id === key ||
    decision.rewardId === key ||
    decision.chainTransactionId === key ||
    decision.txHash === key
  )) || null;
}

function getReviewDecisionTransaction(reviewDecision) {
  if (!reviewDecision) return null;
  return chainTransactionService.getTransaction(reviewDecision.chainTransactionId || reviewDecision.txHash);
}

function getReviewDecisionForTransaction(tx) {
  if (!tx) return null;
  const metadataDecisionId = tx.metadata?.reviewDecisionId || '';
  return findRewardReviewDecision(metadataDecisionId || tx.id || tx.txHash);
}

function redirectSearch(res, q, lang = 'ko') {
  const value = String(q || '').trim();
  if (!value) return res.redirect(withLang('/explorer', lang));

  const wallet = walletService.getWallet(value);
  if (wallet) return res.redirect(withLang(`/explorer/address/${encodeURIComponent(wallet.address)}`, lang));

  const walletTx = walletService.getWalletTransaction(value);
  if (walletTx) return res.redirect(withLang(`/explorer/tx/${encodeURIComponent(walletTx.txHash)}`, lang));

  const chainTx = chainTransactionService.getTransaction(value);
  if (chainTx) return res.redirect(withLang(`/explorer/tx/${encodeURIComponent(chainTx.txHash || chainTx.id)}`, lang));

  const block = blockService.getBlock(value);
  if (block) return res.redirect(withLang(`/explorer/legacy/block/${encodeURIComponent(block.height || block.blockHash || block.id)}`, lang));

  const proof = findProof(value);
  if (proof) return res.redirect(withLang(`/explorer/proof/${encodeURIComponent(proof.id || proof.proofHash)}`, lang));

  const reward = findReward(value);
  if (reward) return res.redirect(withLang(`/explorer/reward/${encodeURIComponent(reward.id)}`, lang));

  const rewardAudit = findRewardAudit(value);
  if (rewardAudit) return res.redirect(withLang(`/explorer/reward-audit/${encodeURIComponent(rewardAudit.id)}`, lang));

  const rewardReviewDecision = findRewardReviewDecision(value);
  if (rewardReviewDecision) return res.redirect(withLang(`/explorer/reward-review/${encodeURIComponent(rewardReviewDecision.id)}`, lang));

  return res.redirect(withLang(`/explorer?notFound=${encodeURIComponent(value)}`, lang));
}

router.get('/', async (req, res) => {
  const lang = getExplorerLang(req);
  if (req.query.q) return redirectSearch(res, req.query.q, lang);

  await testnetService.refreshStatus();

  const sessionUser = getSessionUser(req);
  const isAdmin = isAdminRequest(req, sessionUser);
  const hasWalletSsoUser = Boolean(req.walletUser && req.walletUser.userId);
  const showAdminNetworkView = isAdmin && !hasWalletSsoUser;
  const explorerUser = showAdminNetworkView ? null : sessionUser;
  const blocks = blockService.listBlocks();
  const proofs = proofLedgerService.listProofs();
  const transactions = chainTransactionService.listTransactions();
  const walletTransactions = walletService.listWalletTransactions();
  const callbacks = callbackService.listCallbacks();
  const rewards = rewardLedgerService.listEntries();
  const rewardAudits = rewardAuditService.listEntries();
  const rewardReviewDecisions = rewardReviewService.listDecisions();
  const nodes = nodeParticipantService.listNodes();
  const testnet = testnetService.getStatus();
  const { text } = buildExplorerLocale(req, testnet);
  const wallets = walletService.listWallets();
  const blockStats = testnet.blockStats;
  const blockChain = testnet.chainVerify;
  const orderedBlocks = blocks.slice().sort((a, b) => (b.height || 0) - (a.height || 0));
  const latestBlock = testnet.latestBlock || null;
  const nodeExplorer = await buildNodeExplorer(nodes, explorerUser);
  const rewardExplorer = buildRewardExplorer(rewards, explorerUser, nodeExplorer, lang, showAdminNetworkView);

  return res.render('explorer', {
    service: explorerService(testnet),
    testnet,
    lang,
    text,
    sessionUser,
    isAdmin: showAdminNetworkView,
    supportedLangs: EXPLORER_SUPPORTED_LANGS,
    langUrl: (nextLang) => buildLangUrl(req, nextLang),
    langToggleUrl: buildLangUrl(req, lang === 'ko' ? 'en' : 'ko'),
    urlWithLang: (path) => withLang(path, lang),
    walletStats: walletService.getStats(),
    wallets,
    latestWalletTransactions: latestRows(walletTransactions),
    blockStats,
    blockChain,
    legacyArchive: testnet.legacyArchive,
    latestBlock,
    blocks: orderedBlocks,
    latestProofs: latestRows(proofs),
    latestRewards: latestRows(rewards),
    latestRewardAudits: latestRows(rewardAudits),
    latestRewardReviewDecisions: latestRows(rewardReviewDecisions, 10),
    latestTransactions: latestRows(transactions),
    latestCallbacks: latestRows(callbacks),
    nodes,
    nodeExplorer,
    rewardExplorer,
    notFound: req.query.notFound || '',
    toDisplayAmount,
    shortHash
  });
});
router.get('/reward-audit/:auditId', (req, res) => {
  const testnet = testnetService.getStatus();
  const audit = findRewardAudit(req.params.auditId);
  const reward = audit?.rewardId ? findReward(audit.rewardId) : null;
  const reviewDecision = reward ? rewardReviewService.getDecisionForReward(reward.id) : null;
  const reviewDecisionTx = getReviewDecisionTransaction(reviewDecision);

  res.render('reward-audit-detail', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    audit,
    reward,
    reviewDecision,
    reviewDecisionTx,
    shortHash
  });
});

router.get('/reward/:rewardId', (req, res) => {
  const testnet = testnetService.getStatus();
  const reward = findReward(req.params.rewardId);
  const audit = reward ? findRewardAudit(reward.id) : null;
  const reviewDecision = reward ? rewardReviewService.getDecisionForReward(reward.id) : null;
  const reviewDecisionTx = getReviewDecisionTransaction(reviewDecision);

  res.render('reward-detail', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    reward,
    audit,
    reviewDecision,
    reviewDecisionTx,
    shortHash
  });
});

router.get('/reward-review/:decisionId', (req, res) => {
  const testnet = testnetService.getStatus();
  const reviewDecision = findRewardReviewDecision(req.params.decisionId);
  const reward = reviewDecision ? findReward(reviewDecision.rewardId) : null;
  const audit = reward ? findRewardAudit(reward.id) : null;
  const reviewDecisionTx = getReviewDecisionTransaction(reviewDecision);

  res.render('reward-review-detail', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    reviewDecision,
    reviewDecisionTx,
    reward,
    audit,
    shortHash
  });
});

router.get('/address/:address', (req, res) => {
  const wallet = walletService.getWallet(req.params.address);
  const testnet = testnetService.getStatus();
  if (!wallet) return res.status(404).render('address', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    wallet: null,
    transactions: [],
    walletStats: walletService.getStats(),
    toDisplayAmount,
    shortHash
  });

  res.render('address', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    wallet,
    transactions: walletService.getWalletTransactions(wallet.address),
    walletStats: walletService.getStats(),
    toDisplayAmount,
    shortHash
  });
});

router.get('/tx/:txId', async (req, res, next) => {
  const testnet = testnetService.getStatus();
  const walletTx = walletService.getWalletTransaction(req.params.txId);
  const chainTx = chainTransactionService.getTransaction(req.params.txId);
  const tx = walletTx || chainTx || null;
  if (!tx && /^0x[0-9a-fA-F]{64}$/.test(String(req.params.txId || ''))) {
    try {
      const canonicalTransaction = await canonicalLedgerService.getTransaction(req.params.txId);
      if (canonicalTransaction) return res.render('canonical-tx-detail', {
        service: explorerService(testnet),
        testnet,
        ...buildExplorerLocale(req, testnet),
        transaction: canonicalTransaction
      });
    } catch (error) {
      return next(error);
    }
  }
  const reviewDecision = getReviewDecisionForTransaction(chainTx);
  const reward = reviewDecision ? findReward(reviewDecision.rewardId) : null;
  const audit = reward ? findRewardAudit(reward.id) : null;

  res.render('tx-detail', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    tx,
    txKind: walletTx ? 'wallet' : (chainTx ? 'chain' : 'missing'),
    reviewDecision,
    reward,
    audit,
    toDisplayAmount,
    shortHash
  });
});

router.get('/block/:blockId', async (req, res, next) => {
  const testnet = testnetService.getStatus();
  try {
    const block = await canonicalLedgerService.getBlock(req.params.blockId);
    if (!block) return res.status(404).render('canonical-block-detail', {
      service: explorerService(testnet),
      testnet,
      ...buildExplorerLocale(req, testnet),
      block: null
    });
    return res.render('canonical-block-detail', {
      service: explorerService(testnet),
      testnet,
      ...buildExplorerLocale(req, testnet),
      block
    });
  } catch (error) {
    return next(error);
  }
});

router.get('/legacy/block/:blockId', (req, res) => {
  const testnet = testnetService.getStatus();
  const block = blockService.getBlock(req.params.blockId);
  if (!block) return res.status(404).render('block-detail', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    block: null,
    chainTransactions: [],
    chainTransactionTraces: [],
    proofs: [],
    rewards: [],
    votes: [],
    shortHash
  });

  const chainTransactions = chainTransactionService.listTransactions()
    .filter(tx => (block.transactionIds || []).includes(tx.id));
  const chainTransactionTraces = chainTransactions.map((tx) => {
    const reviewDecision = getReviewDecisionForTransaction(tx);
    const reward = reviewDecision ? findReward(reviewDecision.rewardId) : null;
    const audit = reward ? findRewardAudit(reward.id) : null;
    return {
      txId: tx.id,
      reviewDecision,
      reward,
      audit
    };
  });
  const proofs = proofLedgerService.listProofs()
    .filter(proof => (block.proofIds || []).includes(proof.id));
  const rewards = rewardLedgerService.listEntries()
    .filter(reward => (block.rewardIds || []).includes(reward.id));
  const votes = blockService.getVotesForBlock(block.id);

  return res.render('block-detail', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    block,
    chainTransactions,
    chainTransactionTraces,
    proofs,
    rewards,
    votes,
    shortHash
  });
});

router.get('/proof/:proofId', (req, res) => {
  const testnet = testnetService.getStatus();
  const proof = findProof(req.params.proofId);
  const chainTx = proof?.chainTransactionId ? chainTransactionService.getTransaction(proof.chainTransactionId) : null;
  const block = proof?.blockId ? blockService.getBlock(proof.blockId) : null;

  res.render('proof-detail', {
    service: explorerService(testnet),
    testnet,
    ...buildExplorerLocale(req, testnet),
    proof,
    chainTx,
    block,
    shortHash
  });
});

module.exports = router;
