cyb/src/pages/Mining/Mining.tsx

import { useCallback, useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { Display, DisplayTitle, MainContainer } from 'src/components';
import Pill from 'src/components/Pill/Pill';
import useQueryContract from 'src/hooks/contract/useQueryContract';
import { LITIUM_MINE_CONTRACT, LITIUM_CORE_CONTRACT, LITIUM_STAKE_CONTRACT, UHASH_RELAY_URL, SUBMIT_COOLDOWN_MS } from 'src/constants/mining';
import { RPC_URL } from 'src/constants/config';
import { isTauri } from 'src/utils/tauri';
import { trimString } from 'src/utils/utils';
import { compactLi, formatLi } from './utils/formatLi';
import type {
  ExecuteMsg,
  WindowStatusResponse,
  ConfigResponse,
} from 'src/generated/lithium/LitiumMine.types';
import type { TotalMintedResponse, BurnStatsResponse } from 'src/generated/lithium/LitiumCore.types';
import type { TotalStakedResponse } from 'src/generated/lithium/LitiumStake.types';

type ActivateAccountResponse = {
  ok?: boolean;
  tx_hash?: string;
  error?: string;
};

type SubmitErrorKind =
  | 'account_not_found'
  | 'transport'
  | 'contract'
  | 'unknown';
import useAutoSigner from './hooks/useAutoSigner';
import useRewardEstimate from './hooks/useRewardEstimate';
import useHashrateSamples from './hooks/useHashrateSamples';
import useMinerStats from './hooks/useMinerStats';
import usePeerEstimate from './hooks/usePeerEstimate';
import useLatestBlock from './hooks/useLatestBlock';
import useNewBlockSubscription from './hooks/useNewBlockSubscription';
import useCountdown from './hooks/useCountdown'; // TESTING: countdown โ€” remove after testing phase
import HashrateHero from './components/HashrateHero';
import StatCard from './components/StatCard';
import ProofLogEntry from './components/ProofLogEntry';
import StakingSection from './components/StakingSection';
import ReferralSection, { loadReferrer, saveReferrer } from './components/ReferralSection';
import ConfigPanel from './components/ConfigPanel';
import MiningActionBar from './MiningActionBar';
import DownloadSection from './components/DownloadSection';
import { useAppSelector } from 'src/redux/hooks';
import { WasmMiner } from './wasmMiner';
import { buildMiningReport, type ReportParams } from './buildMiningReport';
import { useAutoSaveLogs } from './hooks/useAutoSaveLogs';
import useSharedTxSender from './hooks/useSharedTxSender';
import usePendingTxVerifier from './hooks/usePendingTxVerifier';
import styles from './Mining.module.scss';

type MiningStatus = {
  mining: boolean;
  hashrate: number;
  total_hashes: number;
  elapsed_secs: number;
  pending_proofs: number;
  backend?: string;
};

type ProofStatus = 'submitted' | 'pending' | 'success' | 'failed' | 'retrying';

type RetryEntry = {
  proof: Proof;
  challenge: string;
  blockTimestamp: number;
  attempts: number;
  nextRetryAt: number;
};

const MAX_RETRY_QUEUE = 10;
const MAX_RETRY_ATTEMPTS = 3;
const RETRY_BACKOFF_BASE = 6000; // 6s, 12s, 24s
const CONSECUTIVE_FAILS_THRESHOLD = 3;

type ProofLogEntry_ = {
  hash: string;
  nonce: number;
  txHash?: string;
  error?: string;
  status?: ProofStatus;
  timestamp: number;
  reward?: number;
};

const PROOF_LOG_KEY = 'mining_proof_log';
const SESSION_LI_KEY = 'mining_session_li';
const MINING_ACTIVE_KEY = 'mining_active';
const MINING_ADDRESS_KEY = 'mining_active_address';
const USER_DIFFICULTY_KEY = 'mining_user_difficulty';
const COUNTDOWN_MODE_KEY = 'mining_countdown_mode';
const ACTION_LOG_KEY = 'mining_action_log';
const DEFAULT_DIFFICULTY = 12;

type ActionLogEntry = {
  action: string;
  detail?: string;
  timestamp: number;
  result?: 'ok' | 'error';
  error?: string;
};

function loadActionLog(): ActionLogEntry[] {
  try {
    const raw = localStorage.getItem(ACTION_LOG_KEY);
    return raw ? JSON.parse(raw) : [];
  } catch {
    return [];
  }
}

function saveActionLog(log: ActionLogEntry[]) {
  try {
    localStorage.setItem(ACTION_LOG_KEY, JSON.stringify(log.slice(0, 500)));
  } catch {
    // ignore
  }
}

function loadProofLog(): ProofLogEntry_[] {
  try {
    const raw = localStorage.getItem(PROOF_LOG_KEY);
    if (!raw) return [];
    const entries: ProofLogEntry_[] = JSON.parse(raw);
    return entries.map((e) => {
      // Migrate legacy entries without status field
      if (!e.status) {
        if (e.error) return { ...e, status: 'failed' as const };
        if (e.txHash) return { ...e, status: 'success' as const };
      }
      return e;
    });
  } catch {
    return [];
  }
}

function saveProofLog(log: ProofLogEntry_[]) {
  try {
    localStorage.setItem(PROOF_LOG_KEY, JSON.stringify(log.slice(0, 200)));
  } catch {
    // ignore
  }
}

function loadSessionLi(): number {
  try {
    return Number(localStorage.getItem(SESSION_LI_KEY)) || 0;
  } catch {
    return 0;
  }
}

function loadUserDifficulty(): number {
  try {
    const saved = localStorage.getItem(USER_DIFFICULTY_KEY);
    if (saved) {
      const n = Number(saved);
      if (n >= 1 && n <= 64) return n;
    }
  } catch {
    // ignore
  }
  return DEFAULT_DIFFICULTY;
}

function formatElapsed(seconds: number): string {
  if (seconds < 60) {
    return `${seconds.toFixed(0)}s`;
  }
  if (seconds < 3600) {
    return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
  }
  return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}

type Proof = { hash: string; nonce: number; challenge?: string };

function formatHashrate(hps: number): string {
  if (hps >= 1_000_000) return `${(hps / 1_000_000).toFixed(1)} MH/s`;
  if (hps >= 1_000) return `${(hps / 1_000).toFixed(1)} KH/s`;
  return `${hps.toFixed(0)} H/s`;
}


function normalizeErrorText(error: unknown): string {
  if (!error) {
    return '';
  }
  if (typeof error === 'string') {
    return error;
  }
  if (error instanceof Error) {
    return error.message;
  }
  try {
    return JSON.stringify(error);
  } catch {
    return String(error);
  }
}

function classifySubmitError(error: unknown): SubmitErrorKind {
  const anyError = error as
    | { code?: number; message?: string; rawLog?: string }
    | undefined;
  if (anyError?.code === 5) {
    return 'account_not_found';
  }

  const message = normalizeErrorText(error).toLowerCase();
  if (
    /does not exist on chain|account .*not found|unknown address|code\s*[:=]\s*5/.test(
      message
    )
  ) {
    return 'account_not_found';
  }
  if (
    /network|fetch|timeout|timed out|connection|econn|socket|dns|unavailable|503|502/.test(
      message
    )
  ) {
    return 'transport';
  }
  if (
    /failed to execute|codespace|wasm|out of gas|unauthorized|insufficient/.test(
      message
    )
  ) {
    return 'contract';
  }

  return 'unknown';
}

async function activateAccount(
  minerAddress: string
): Promise<boolean> {
  try {
    const res = await fetch(UHASH_RELAY_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ miner_address: minerAddress }),
    });
    const data = (await res.json()) as ActivateAccountResponse;
    console.log('[Mining] Account activation result:', data);
    return !!data.ok;
  } catch (err) {
    console.error('[Mining] Account activation failed:', err);
    return false;
  }
}

// Module-level miners โ€” survive component unmount/remount during navigation.
// Mining is a background process; only explicit user action (Stop button) or
// closing the app/tab should terminate it.
let persistentWasmMiner: WasmMiner | null = null;

/** Accessor for app-level polling (useMiningMonitor) */
export function getWasmMiner(): WasmMiner | null {
  return persistentWasmMiner;
}

function Mining() {
  const reduxMiningActive = useAppSelector((s) => s.mining.active);
  const defaultAccount = useAppSelector((s) => s.pocket.defaultAccount);
  const { signer, signingClient, address } = useAutoSigner();

  // Window status replaces epoch_status + difficulty + target + proof_stats
  const { data: windowData, refetch: refetchWindow } = useQueryContract(LITIUM_MINE_CONTRACT, {
    window_status: {},
  });
  const { data: configData, refetch: refetchConfig } = useQueryContract(LITIUM_MINE_CONTRACT, {
    config: {},
  });

  const windowStatus = windowData as WindowStatusResponse | undefined;
  const config = configData as ConfigResponse | undefined;
  const minDifficulty = config?.min_difficulty ?? 8;

  // Client-chosen difficulty โ€” persisted to localStorage
  const [userDifficulty, setUserDifficulty] = useState(loadUserDifficulty);
  useEffect(() => {
    try {
      localStorage.setItem(USER_DIFFICULTY_KEY, String(userDifficulty));
    } catch {
      // ignore
    }
  }, [userDifficulty]);

  const { block: latestBlock, refetchBlock } = useLatestBlock();
  const { data: totalMintedData, refetch: refetchTotalMinted } = useQueryContract(
    LITIUM_CORE_CONTRACT,
    { total_minted: {} }
  );
  const totalMinted = totalMintedData as TotalMintedResponse | undefined;
  const { data: totalStakedData, refetch: refetchTotalStaked } = useQueryContract(
    LITIUM_STAKE_CONTRACT,
    { total_staked: {} }
  );
  const totalStaked = totalStakedData as TotalStakedResponse | undefined;
  const { data: burnStatsData, refetch: refetchBurnStats } = useQueryContract(
    LITIUM_CORE_CONTRACT,
    { burn_stats: {} }
  );
  const burnStats = burnStatsData as BurnStatsResponse | undefined;
  const { data: pendingRewardsData, refetch: refetchPendingRewards } = useQueryContract(
    LITIUM_STAKE_CONTRACT,
    { total_pending_rewards: {} }
  );
  const pendingRewards = pendingRewardsData as { total_pending_rewards: string } | undefined;

  // Effective supply = minted - burned + pending staking rewards (unminted but accrued).
  // Including pending rewards prevents the "sawtooth" effect where claiming staking
  // rewards mass-mints tokens and suddenly changes the S-ratio.
  const circulatingSupply = totalMinted
    ? Number(totalMinted.total_minted) - Number(burnStats?.total_burned ?? 0)
      + Number(pendingRewards?.total_pending_rewards ?? 0)
    : 0;

  // PoW share from real on-chain state: powShare = 1 - S^alpha
  // S = total_staked / circulating_supply, alpha from window_status (already a decimal string, e.g. "0.500000")
  const stakedFraction = circulatingSupply > 0 && totalStaked
    ? Number(totalStaked.total_staked) / circulatingSupply
    : 0;
  const alphaExp = windowStatus ? Number(windowStatus.alpha) : 0;
  const powShare = stakedFraction > 0 && alphaExp > 0
    ? 1 - Math.pow(stakedFraction, alphaExp)
    : 1;

  // Mining status from Redux (kept in sync by useMiningMonitor in App.tsx)
  const miningStatus = useAppSelector((s) => s.mining.status);

  const [autoMining, setAutoMining] = useState(() => {
    // Tauri: use Redux state (kept in sync by useMiningMonitor) to avoid flash
    if (isTauri()) return reduxMiningActive;
    try {
      return !!localStorage.getItem(MINING_ACTIVE_KEY);
    } catch {
      return false;
    }
  });
  const [submitting, setSubmitting] = useState(false);
  const [proofLog, setProofLog] = useState<ProofLogEntry_[]>(loadProofLog);
  const [actionLog, setActionLog] = useState<ActionLogEntry[]>(loadActionLog);
  const logAction = useCallback((action: string, detail?: string, result?: 'ok' | 'error', error?: string) => {
    const entry: ActionLogEntry = { action, detail, timestamp: Date.now(), result, error };
    console.log('[Mining][action]', action, detail || '', result || '', error || '');
    setActionLog((prev) => {
      const next = [entry, ...prev].slice(0, 500);
      saveActionLog(next);
      return next;
    });
  }, []);
  const [cpuCores, setCpuCores] = useState(() => navigator.hardwareConcurrency || 4);
  const [threadCount, setThreadCount] = useState(() => {
    const cores = navigator.hardwareConcurrency || 4;
    if (isTauri()) {
      return Math.max(1, cores - 1); // Native: use all but 1 core
    }
    return Math.max(1, Math.floor(cores / 2)); // WASM: use half cores to stay responsive
  });
  const [backend, setBackend] = useState<string>('cpu');
  const [availableBackends, setAvailableBackends] = useState<string[]>(['cpu']);
  const [sessionLiMined, setSessionLiMined] = useState(loadSessionLi);
  const [configOpen, setConfigOpen] = useState(false);
  const [countdownMode, setCountdownMode] = useState(() => {
    try { return !!localStorage.getItem(COUNTDOWN_MODE_KEY); } catch { return false; }
  });
  useEffect(() => {
    try {
      if (countdownMode) localStorage.setItem(COUNTDOWN_MODE_KEY, '1');
      else localStorage.removeItem(COUNTDOWN_MODE_KEY);
    } catch { /* ignore */ }
  }, [countdownMode]);
  const [proofPage, setProofPage] = useState(1);
  const [referrer, setReferrer] = useState(() => {
    // Check URL ?ref= param first, then localStorage
    const params = new URLSearchParams(window.location.search);
    const refParam = params.get('ref');
    if (refParam) {
      saveReferrer(refParam);
      return refParam;
    }
    return loadReferrer();
  });

  const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const autoMiningRef = useRef(false);
  const miningAddressRef = useRef<string | undefined>(undefined);
  const wasmMinerRef = useRef<WasmMiner | null>(null);
  const stopReadyRef = useRef(false);
  const sessionStart = useAppSelector((s) => s.mining.sessionStart) ?? Date.now();
  const isNative = isTauri();

  // Fetch available backends on mount
  useEffect(() => {
    if (isNative) {
      invoke('get_mining_params')
        .then((params: any) => {
          if (params?.available_backends) {
            setAvailableBackends(params.available_backends);
          }
          if (params?.cpu_cores && params.cpu_cores > 0) {
            const cores = params.cpu_cores as number;
            setCpuCores(cores);
            // Bump thread count if it was capped by navigator.hardwareConcurrency
            setThreadCount((prev) => {
              const browserMax = Math.max(1, (navigator.hardwareConcurrency || 4) - 1);
              return prev >= browserMax ? Math.max(1, cores - 1) : prev;
            });
          }
        })
        .catch(() => {});
    } else {
      setAvailableBackends(['cpu']);
    }
  }, [isNative]);

  // Track current challenge so proof submission uses the values from when mining started
  const challengeRef = useRef<string>('');
  const blockTimestampRef = useRef<number>(0);
  const challengeCreatedAtRef = useRef<number>(0);

  // Connection health
  const [rpcOnline, setRpcOnline] = useState(true);
  const consecutiveFailsRef = useRef(0);
  const retryQueueRef = useRef<RetryEntry[]>([]);

  // Shared TX sender โ€” serializes all TXs (proofs + staking) through one sequence
  const { sendContractTx, broadcastContractTx, resetSequence, seqRef } = useSharedTxSender(signer, signingClient, address);

  // Proof submission queue
  const proofQueueRef = useRef<Proof[]>([]);
  const submittingRef = useRef(false);
  const lastSubmitTimeRef = useRef(0);

  const hashrate = miningStatus?.hashrate ?? 0;
  const elapsed = miningStatus?.elapsed_secs ?? 0;
  const canMine = userDifficulty >= minDifficulty && !!address && !!latestBlock;

  // TESTING: Genesis countdown โ€” remove after testing phase
  const genesisInFuture = config?.genesis_time
    ? config.genesis_time > Math.floor(Date.now() / 1000)
    : false;
  // Always tick when genesis is in the future (show countdown immediately on page load)
  const countdownTarget = genesisInFuture ? config?.genesis_time : undefined;
  const countdownSeconds = useCountdown(countdownTarget);


  const { data: cw20BalData, refetch: refreshBalance } = useQueryContract(
    LITIUM_CORE_CONTRACT,
    address ? { balance: { address } } : { token_info: {} }
  );
  const liBalance = address && cw20BalData && 'balance' in (cw20BalData as object)
    ? Number((cw20BalData as { balance: string }).balance) / 1_000_000
    : 0;
  const { rewardPerProof, grossRewardPerProof, estimatedLiPerHour, refetch: refetchReward } =
    useRewardEstimate(userDifficulty, hashrate, powShare);
  const samples = useHashrateSamples(hashrate, autoMining);
  const { uniqueMiners, totalProofs, avgDifficulty, refetch: refetchMinerStats } = useMinerStats();
  const {
    dRate, similarDevices, windowEntries, windowSize, baseRate,
    refetchWindow: refetchPeerWindow,
  } = usePeerEstimate(hashrate);

  // Event-driven refresh โ€” zero timer polling.
  // Three triggers: new block (WS), after proof submit, platform recovery.
  // WS fallback: 30s interval only when WebSocket is disconnected.
  const refetchAll = useCallback(() => {
    refetchWindow();
    refetchBlock();
    refetchPeerWindow();
    refetchTotalMinted();
    refetchTotalStaked();
    refetchBurnStats();
    refetchPendingRewards();
    refetchMinerStats();
    refetchReward();
    refreshBalance();
  }, [refetchWindow, refetchBlock, refetchPeerWindow, refetchTotalMinted, refetchTotalStaked, refetchBurnStats, refetchPendingRewards, refetchMinerStats, refetchReward, refreshBalance]);

  // Resync config + balance after proof submit (success or permanent failure)
  const resyncAfterProof = useCallback(() => {
    refetchConfig();
    refreshBalance();
  }, [refetchConfig, refreshBalance]);

  // Async TX verification โ€” confirms proof TXs on each new block
  const handleProofConfirmed = useCallback((txHash: string, proofHash: string, reward: number) => {
    setProofLog((prev) =>
      prev.map((p) =>
        p.hash === proofHash ? { ...p, txHash, status: 'success' as const, reward } : p
      )
    );
    setSessionLiMined((prev) => prev + reward);
    refreshBalance();
    logAction('proof_confirmed', `txHash=${txHash} reward=${reward.toFixed(6)}`, 'ok');
    if (isNative) invoke('report_proof_submitted').catch(() => {});
  }, [refreshBalance, logAction, isNative]);

  const handleProofFailed = useCallback((txHash: string, proofHash: string, error: string) => {
    setProofLog((prev) =>
      prev.map((p) =>
        p.hash === proofHash && (p.status === 'pending' || p.status === 'submitted')
          ? { ...p, txHash, error, status: 'failed' as const }
          : p
      )
    );
    logAction('proof_verify_failed', `txHash=${txHash} error=${error}`, 'error', error);
    if (isNative) invoke('report_proof_failed').catch(() => {});
    resyncAfterProof();
  }, [logAction, isNative, resyncAfterProof]);

  const verifier = usePendingTxVerifier(signingClient, handleProofConfirmed, handleProofFailed);

  // WebSocket-driven: refetch all display data on every new block + verify pending TXs
  const onNewBlock = useCallback(() => {
    refetchAll();
    verifier.checkAll();
  }, [refetchAll, verifier]);
  const { connected: wsConnected } = useNewBlockSubscription(onNewBlock);

  // Polling safety net: always poll at moderate interval.
  // WS provides instant updates when working, but if WS events stop
  // flowing (proxy issues, stale connection) this keeps data fresh.
  useEffect(() => {
    const interval = wsConnected ? 15_000 : 10_000;
    if (!wsConnected) {
      // Immediate refresh when WS is down
      refetchAll();
      refetchConfig();
    }
    const timer = setInterval(() => {
      refetchAll();
      refetchConfig();
      verifier.checkAll(); // verify pending TXs even when WS is down
    }, interval);
    return () => clearInterval(timer);
  }, [wsConnected, refetchAll, refetchConfig, verifier]);

  // Handle sleep/wake and network reconnection using platform-native events.
  // Desktop Tauri: window focus event (fires on Cmd+Tab, dock click, sleep/wake)
  // Mobile Tauri:  'app-resumed' custom event (emitted from Rust on RunEvent::Resumed)
  // Web:           visibilitychange (standard browser API)
  // All:           'online' event for network reconnection
  useEffect(() => {
    const cleanups: (() => void)[] = [];

    // Debounce: don't refetch more than once per 5s from any signal
    let lastRefetch = 0;
    const debouncedRefetch = async (source: string) => {
      const now = Date.now();
      if (now - lastRefetch < 5000) return;
      lastRefetch = now;
      console.log(`[Mining][wake] ${source}, refreshing...`);
      refetchAll();
      refetchConfig();

      // Probe RPC health to restore online state
      try {
        const probeOk = await fetch(RPC_URL + '/status', {
          signal: AbortSignal.timeout(5000),
        }).then((r) => r.ok).catch(() => false);
        if (probeOk) {
          setRpcOnline(true);
          consecutiveFailsRef.current = 0;
          console.log('[Mining][wake] RPC probe OK โ€” marking online');
        }
      } catch {
        // probe failed, stay in current state
      }
    };

    if (isNative) {
      let cancelled = false;

      // Desktop: window focus event
      import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
        if (cancelled) return;
        getCurrentWindow().onFocusChanged(({ payload: focused }) => {
          if (focused) {
            debouncedRefetch('Window focused');
          }
        }).then((unlisten) => {
          if (cancelled) { unlisten(); } else { cleanups.push(unlisten); }
        });
      });

      // Mobile: app-resumed event (RunEvent::Resumed โ€” iOS/Android foreground)
      import('@tauri-apps/api/event').then(({ listen }) => {
        if (cancelled) return;
        listen('app-resumed', () => {
          debouncedRefetch('App resumed (mobile)');
        }).then((unlisten) => {
          if (cancelled) { unlisten(); } else { cleanups.push(unlisten); }
        });
      });

      cleanups.push(() => { cancelled = true; });
    } else {
      // Web: use visibilitychange
      const handleVisibility = () => {
        if (!document.hidden) {
          debouncedRefetch('Page visible');
        }
      };
      document.addEventListener('visibilitychange', handleVisibility);
      cleanups.push(() => document.removeEventListener('visibilitychange', handleVisibility));
    }

    // All platforms: network reconnection
    const handleOnline = () => debouncedRefetch('Network online');
    window.addEventListener('online', handleOnline);
    cleanups.push(() => window.removeEventListener('online', handleOnline));

    return () => cleanups.forEach((fn) => fn());
  }, [refetchAll, refetchConfig, isNative]);

  // Keep autoMining ref in sync with state and persist to localStorage.
  // Redux mining state is managed by useMiningMonitor at app level.
  useEffect(() => {
    console.log('[Mining][sync] autoMining changed to:', autoMining);
    autoMiningRef.current = autoMining;
    try {
      localStorage.setItem(MINING_ACTIVE_KEY, autoMining ? '1' : '');
      if (autoMining && miningAddressRef.current) {
        localStorage.setItem(MINING_ADDRESS_KEY, miningAddressRef.current);
      } else if (!autoMining) {
        localStorage.removeItem(MINING_ADDRESS_KEY);
      }
    } catch {
      // ignore
    }
  }, [autoMining]);

  // Persist proof log and session LI to localStorage
  useEffect(() => {
    saveProofLog(proofLog);
  }, [proofLog]);

  // Resolve stale 'submitted'/'retrying' entries from previous session on mount
  useEffect(() => {
    const stale = proofLog.filter(
      (p) => p.status === 'submitted' || p.status === 'retrying'
    );
    if (stale.length === 0) return;

    // Entries without txHash were never broadcast โ€” resolve immediately
    const neverBroadcast = stale.filter((p) => !p.txHash);
    if (neverBroadcast.length > 0) {
      setProofLog((prev) =>
        prev.map((p) =>
          (p.status === 'submitted' || p.status === 'retrying') && !p.txHash
            ? { ...p, status: 'failed' as const, error: 'Never broadcast' }
            : p
        )
      );
    }

    // Entries with txHash โ€” verify on-chain once client is ready
    if (!signingClient) return;
    const withTx = stale.filter((p) => p.txHash);
    if (withTx.length === 0) return;

    (async () => {
      const updates: Record<string, { status: ProofStatus; error?: string }> = {};
      for (const entry of withTx) {
        try {
          const tx = await signingClient.getTx(entry.txHash!);
          if (tx) {
            updates[entry.hash] = tx.code === 0
              ? { status: 'success' }
              : { status: 'failed', error: tx.rawLog || `code ${tx.code}` };
          } else {
            updates[entry.hash] = { status: 'failed', error: 'TX not found on chain' };
          }
        } catch {
          // Can't verify โ€” leave as-is, will retry next launch
        }
      }
      if (Object.keys(updates).length > 0) {
        setProofLog((prev) =>
          prev.map((p) =>
            updates[p.hash] ? { ...p, ...updates[p.hash] } : p
          )
        );
      }
    })();
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [!!signingClient]);

  useEffect(() => {
    try {
      localStorage.setItem(SESSION_LI_KEY, String(sessionLiMined));
    } catch {
      // ignore
    }
  }, [sessionLiMined]);

  const stopPolling = useCallback(() => {
    if (pollRef.current) {
      clearInterval(pollRef.current);
      pollRef.current = null;
    }
  }, []);

  const startMiningRound = useCallback(async () => {
    if (!address || userDifficulty < minDifficulty) {
      console.log('[Mining] Cannot start: missing address or difficulty too low');
      return;
    }

    // Native path still uses block hash (Tauri backend manages its own challenge rotation)
    if (isNative) {
      if (!latestBlock) {
        console.log('[Mining] Cannot start native: no block data');
        return;
      }
      const challenge = latestBlock.blockHash;
      challengeRef.current = challenge;
      blockTimestampRef.current = latestBlock.timestamp;
      try {
        console.log('[Mining] Starting native mining, difficulty:', userDifficulty, 'challenge:', challenge.slice(0, 16));
        await invoke('start_mining', {
          address,
          challengeHex: challenge,
          difficulty: userDifficulty,
          blockTimestamp: latestBlock.timestamp,
          threads: threadCount,
          backend,
        });
      } catch (err) {
        console.error('[Mining] Failed to start native mining', err);
      }
      return;
    }

    // Web path: generate random challenge client-side (like lithium-cli).
    // The contract accepts any 32-byte challenge โ€” no need to use block hash.
    // This avoids restarting workers every ~6s block change.
    const randomBytes = new Uint8Array(32);
    crypto.getRandomValues(randomBytes);
    const challenge = Array.from(randomBytes, (b) => b.toString(16).padStart(2, '0')).join('');
    // Use local wall clock minus 5s safety margin (like lithium-cli)
    const timestamp = Math.floor(Date.now() / 1000) - 5;
    challengeRef.current = challenge;
    blockTimestampRef.current = timestamp;
    challengeCreatedAtRef.current = Date.now();

    try {
      console.log('[Mining] Starting WASM mining, difficulty:', userDifficulty, 'challenge:', challenge.slice(0, 16));
      if (!persistentWasmMiner) {
        const miner = new WasmMiner(threadCount);
        await miner.init();
        persistentWasmMiner = miner;
      }
      wasmMinerRef.current = persistentWasmMiner;
      persistentWasmMiner.start(challenge, userDifficulty);
    } catch (err) {
      console.error('[Mining] Failed to start WASM mining', err);
    }
  }, [userDifficulty, minDifficulty, address, latestBlock, threadCount, backend, isNative]);

  // Submit a single proof to chain (fire-and-forget โ€” verification is async via usePendingTxVerifier)
  const submitSingleProof = useCallback(
    async (proof: Proof) => {
      if (!signer || !address) {
        console.log('[Mining] Cannot submit: no signer/address');
        return;
      }

      // Safety: don't submit if challenge refs aren't populated
      if (!challengeRef.current || !blockTimestampRef.current) {
        console.warn('[Mining] Skipping submit: challenge refs not populated yet',
          { challenge: challengeRef.current, timestamp: blockTimestampRef.current });
        return;
      }

      const [account] = await signer.getAccounts();
      if (account.address !== address) {
        console.warn('[Mining] Signer address mismatch:', account.address, '!==', address);
        return;
      }
      // Use the challenge embedded in the proof (mined against), not the current one
      const proofChallenge = proof.challenge || challengeRef.current;
      const msg: ExecuteMsg = {
        submit_proof: {
          hash: proof.hash,
          nonce: Number(proof.nonce),
          miner_address: address,
          challenge: proofChallenge,
          difficulty: userDifficulty,
          timestamp: blockTimestampRef.current,
          referrer: referrer || undefined,
        },
      };

      console.log(
        '[Mining] Broadcasting proof:',
        `${proof.hash.slice(0, 16)}...`,
        'difficulty:',
        userDifficulty
      );

      let broadcastResult: { txHash: string };
      try {
        broadcastResult = await broadcastContractTx(LITIUM_MINE_CONTRACT, msg as unknown as Record<string, unknown>);
      } catch (executeErr: any) {
        const errText = normalizeErrorText(executeErr).toLowerCase();
        const kind = classifySubmitError(executeErr);

        // Sequence mismatch โ€” already reset by broadcastContractTx, retry once
        if (/account sequence mismatch/.test(errText)) {
          console.log('[Mining] Sequence mismatch, retrying...');
          try {
            broadcastResult = await broadcastContractTx(LITIUM_MINE_CONTRACT, msg as unknown as Record<string, unknown>);
          } catch (retryErr) {
            if (isNative) {
              invoke('report_proof_failed').catch(() => {});
            }
            throw new Error(
              `Retry after sequence refresh failed: ${normalizeErrorText(retryErr)}`
            );
          }
        } else if (kind === 'account_not_found') {
          console.log('[Mining] Account not on chain, activating...');
          const activated = await activateAccount(account.address);
          if (activated) {
            console.log('[Mining] Account activated, waiting for tx inclusion...');
            await new Promise<void>((resolve) => {
              setTimeout(resolve, 7000);
            });
            console.log('[Mining] Retrying proof submission...');
            resetSequence();
            try {
              broadcastResult = await broadcastContractTx(LITIUM_MINE_CONTRACT, msg as unknown as Record<string, unknown>);
            } catch (retryErr) {
              if (isNative) {
                invoke('report_proof_failed').catch(() => {});
              }
              throw new Error(
                `Retry after activation failed: ${normalizeErrorText(retryErr)}`
              );
            }
          } else {
            if (isNative) {
              invoke('report_proof_failed').catch(() => {});
            }
            throw new Error('Account activation failed โ€” cannot submit proof');
          }
        } else {
          if (isNative) {
            invoke('report_proof_failed').catch(() => {});
          }
          throw new Error(
            `Submit ${kind} error: ${normalizeErrorText(executeErr)}`
          );
        }
      }

      // Broadcast succeeded โ€” TX accepted into mempool (fire-and-forget)
      console.log('[Mining] TX in mempool, txHash:', broadcastResult.txHash);
      logAction('proof_broadcast', `txHash=${broadcastResult.txHash} hash=${proof.hash.slice(0, 16)}`, 'ok');

      // Mark proof as submitted, register with verifier for confirmation tracking
      setProofLog((prev) =>
        prev.map((p) =>
          p.hash === proof.hash && (p.status === 'submitted' || p.status === 'retrying' || !p.txHash)
            ? { ...p, txHash: broadcastResult.txHash, status: 'submitted' }
            : p
        )
      );

      // Verifier will poll getTx, extract miner_reward, and call handleProofConfirmed
      verifier.addPending({
        txHash: broadcastResult.txHash,
        proofHash: proof.hash,
        proofNonce: proof.nonce,
        submittedAt: Date.now(),
      });
      if (isNative) invoke('report_proof_submitted').catch(() => {});
    },
    [signer, address, userDifficulty, referrer, isNative, logAction, broadcastContractTx, resetSequence, verifier]
  );

  // Process the proof queue
  const processQueue = useCallback(async () => {
    if (submittingRef.current) {
      return;
    }

    const now = Date.now();

    // --- Primary queue ---
    if (proofQueueRef.current.length > 0) {
      const elapsed_ = now - lastSubmitTimeRef.current;
      if (elapsed_ < SUBMIT_COOLDOWN_MS) {
        return;
      }

      // When offline, hold proofs โ€” don't submit or discard
      if (!rpcOnline) {
        console.log('[Mining] Offline โ€” holding', proofQueueRef.current.length, 'proofs in queue');
        return;
      }

      submittingRef.current = true;
      setSubmitting(true);

      const queue = [...proofQueueRef.current];
      proofQueueRef.current = [];

      if (queue.length === 0) {
        submittingRef.current = false;
        setSubmitting(false);
        return;
      }
      let bestIdx = 0;
      for (let i = 1; i < queue.length; i++) {
        if (queue[i].hash < queue[bestIdx].hash) {
          bestIdx = i;
        }
      }
      const best = queue[bestIdx];

      const discarded = queue.length - 1;
      if (discarded > 0) {
        console.log(
          `[Mining] Submitting best of ${
            discarded + 1
          } proofs, discarded ${discarded}`
        );
      }

      // Add "submitted" entry immediately
      setProofLog((prev) => [
        {
          hash: best.hash,
          nonce: best.nonce,
          status: 'submitted',
          timestamp: Date.now(),
        },
        ...prev,
      ]);

      try {
        lastSubmitTimeRef.current = Date.now();
        logAction('proof_submit', `hash=${best.hash.slice(0, 16)} nonce=${best.nonce}`);
        await submitSingleProof(best);
        consecutiveFailsRef.current = 0;
        resyncAfterProof();
      } catch (err: any) {
        console.error('[Mining] Submit failed:', err);
        logAction('proof_submit', `hash=${best.hash.slice(0, 16)}`, 'error', normalizeErrorText(err).slice(0, 120));
        const kind = classifySubmitError(err);

        if (kind === 'transport') {
          // Transport error โ†’ move to retry queue instead of marking failed
          consecutiveFailsRef.current += 1;
          console.log('[Mining] Transport error, consecutive fails:', consecutiveFailsRef.current);

          if (consecutiveFailsRef.current >= CONSECUTIVE_FAILS_THRESHOLD) {
            setRpcOnline(false);
            console.log('[Mining] RPC marked offline after', consecutiveFailsRef.current, 'consecutive failures');
          }

          if (retryQueueRef.current.length < MAX_RETRY_QUEUE) {
            retryQueueRef.current.push({
              proof: best,
              challenge: challengeRef.current,
              blockTimestamp: blockTimestampRef.current,
              attempts: 1,
              nextRetryAt: Date.now() + RETRY_BACKOFF_BASE,
            });
            // Update log entry to "retrying"
            setProofLog((prev) =>
              prev.map((p) =>
                p.hash === best.hash && p.status === 'submitted'
                  ? { ...p, error: 'Network error โ€” will retry', status: 'retrying' }
                  : p
              )
            );
          } else {
            // Retry queue full โ€” mark as failed
            setProofLog((prev) =>
              prev.map((p) =>
                p.hash === best.hash && p.status === 'submitted'
                  ? { ...p, error: 'Retry queue full', status: 'failed' }
                  : p
              )
            );
          }
        } else {
          // Contract/unknown error โ†’ permanent failure (no retry)
          setProofLog((prev) =>
            prev.map((p) =>
              p.hash === best.hash && p.status === 'submitted'
                ? { ...p, error: err?.message || 'Failed', status: 'failed' }
                : p
            )
          );
          resyncAfterProof();
        }
      } finally {
        submittingRef.current = false;
        setSubmitting(false);
      }
    }

    // --- Retry queue --- (only when online and not already submitting)
    if (!rpcOnline || submittingRef.current || retryQueueRef.current.length === 0) {
      return;
    }

    const retryNow = Date.now();
    const ready = retryQueueRef.current.findIndex(
      (r) => r.nextRetryAt <= retryNow
    );
    if (ready === -1) return;

    const entry = retryQueueRef.current[ready];
    retryQueueRef.current.splice(ready, 1);

    // Check if challenge is still current
    if (entry.challenge !== challengeRef.current) {
      console.log('[Mining] Retry discarded โ€” stale challenge');
      setProofLog((prev) =>
        prev.map((p) =>
          p.hash === entry.proof.hash && p.status === 'retrying'
            ? { ...p, error: 'Stale challenge', status: 'failed' }
            : p
        )
      );
      return;
    }

    submittingRef.current = true;
    setSubmitting(true);
    console.log('[Mining] Retrying proof:', entry.proof.hash.slice(0, 16), 'attempt:', entry.attempts + 1);

    try {
      lastSubmitTimeRef.current = Date.now();
      await submitSingleProof(entry.proof);
      consecutiveFailsRef.current = 0;
      resyncAfterProof();
      // submitSingleProof registers with verifier โ€” confirmation is async
    } catch (err: any) {
      const kind = classifySubmitError(err);

      if (kind === 'transport' && entry.attempts < MAX_RETRY_ATTEMPTS) {
        consecutiveFailsRef.current += 1;
        if (consecutiveFailsRef.current >= CONSECUTIVE_FAILS_THRESHOLD) {
          setRpcOnline(false);
        }
        // Put back with increased backoff
        retryQueueRef.current.push({
          ...entry,
          attempts: entry.attempts + 1,
          nextRetryAt: Date.now() + RETRY_BACKOFF_BASE * Math.pow(2, entry.attempts),
        });
      } else {
        // Max retries exceeded or non-transport error
        setProofLog((prev) =>
          prev.map((p) =>
            p.hash === entry.proof.hash && p.status === 'retrying'
              ? { ...p, error: err?.message || 'Failed after retries', status: 'failed' }
              : p
          )
        );
        resyncAfterProof();
      }
    } finally {
      submittingRef.current = false;
      setSubmitting(false);
    }
  }, [submitSingleProof, rpcOnline, resyncAfterProof]);

  // Store processQueue in a ref so the poll interval never needs to be recreated
  const processQueueRef = useRef(processQueue);
  processQueueRef.current = processQueue;

  // Poll for proof submission only.
  // Mining status display is handled by useMiningMonitor โ†’ Redux.
  const startPolling = useCallback(() => {
    stopPolling();
    pollRef.current = setInterval(async () => {  // eslint-disable-line
      try {
        if (!autoMiningRef.current) return;

        // Check pending proofs from backend
        const activeMiner = wasmMinerRef.current;
        let pendingProofs = 0;
        if (isNative) {
          const status = (await invoke('get_mining_status')) as MiningStatus;
          pendingProofs = status.pending_proofs;
        } else if (activeMiner) {
          pendingProofs = activeMiner.getStatus().pending_proofs;
        }

        if (pendingProofs > 0) {
          let proofs: Proof[];
          if (isNative) {
            proofs = (await invoke('take_proofs')) as Proof[];
          } else if (activeMiner) {
            proofs = activeMiner.takeProofs();
          } else {
            proofs = [];
          }

          if (proofs.length > 0) {
            // Tag each proof with the challenge it was mined against
            const currentChallenge = challengeRef.current;
            const tagged = proofs.map((p) => ({ ...p, challenge: p.challenge || currentChallenge }));
            const MAX_QUEUE = 50;
            if (proofQueueRef.current.length < MAX_QUEUE) {
              proofQueueRef.current.push(...tagged.slice(0, MAX_QUEUE - proofQueueRef.current.length));
            }
            console.log(
              `[Mining] ${proofs.length} proof(s) queued, total pending: ${proofQueueRef.current.length}`
            );
          }
        }

        processQueueRef.current();
      } catch (err) {
        console.error('[Mining] Poll error', err);
      }
    }, 1000);
  }, [stopPolling, isNative]);

  // Cleanup on unmount โ€” stop polling only.
  // Mining is a background process that persists across navigation:
  //   Tauri: Rust backend keeps running
  //   WASM: module-level persistentWasmMiner keeps workers alive
  // Only explicit Stop button or closing the app/tab kills mining.
  useEffect(() => {
    // Block stale click events from previous page's ActionBar hitting
    // our Stop button in the same event loop tick as mount.
    stopReadyRef.current = false;
    const raf = requestAnimationFrame(() => { stopReadyRef.current = true; });
    console.log('[Mining][lifecycle] mount, isNative:', isNative);
    return () => {
      cancelAnimationFrame(raf);
      console.log('[Mining][lifecycle] unmount cleanup (polling only), autoMining:', autoMiningRef.current);
      stopPolling();
    };
  }, [stopPolling]);

  // On mount: resume mining if it was active before reload
  useEffect(() => {
    if (isNative) {
      // Tauri: check backend mining state and restore refs from stored params
      let cancelled = false;
      console.log('[Mining][resume] checking backend state...');
      (async () => {
        try {
          const status = (await invoke('get_mining_status')) as MiningStatus & {
            challenge_hex?: string;
            block_timestamp?: number;
          };
          console.log('[Mining][resume] got status, mining:', status.mining, 'cancelled:', cancelled);
          if (cancelled) return;
          if (status.mining) {
            console.log('[Mining][resume] resuming UI, address:', address?.slice(0, 16));
            // Restore refs from Rust-stored params
            if (status.challenge_hex) {
              challengeRef.current = status.challenge_hex;
            }
            if (status.block_timestamp !== undefined) {
              blockTimestampRef.current = status.block_timestamp;
            }
            miningAddressRef.current = address;
            setAutoMining(true);
            startPolling();
          } else {
            console.log('[Mining][resume] backend not mining');
          }
        } catch (err) {
          console.log('[Mining][resume] error:', err);
        }
      })();
      return () => {
        console.log('[Mining][resume] cleanup, setting cancelled=true');
        cancelled = true;
      };
    }

    // Check if persistent miner is still running (survives navigation)
    if (persistentWasmMiner) {
      const status = persistentWasmMiner.getStatus();
      if (status.mining) {
        console.log('[Mining][resume] persistent WASM miner still running, resuming UI');
        wasmMinerRef.current = persistentWasmMiner;
        miningAddressRef.current = localStorage.getItem(MINING_ADDRESS_KEY) || address;
        setAutoMining(true);
        startPolling();
        return undefined;
      }
    }

    // WASM: autoMining is already initialized from localStorage in useState.
    // Restore miningAddressRef so the auto-start effect can proceed.
    if (autoMining) {
      try {
        const savedAddr = localStorage.getItem(MINING_ADDRESS_KEY);
        if (savedAddr && address && savedAddr !== address) {
          console.warn('[Mining] Saved mining address does not match current, stopping');
          setAutoMining(false);
        } else {
          miningAddressRef.current = savedAddr || address;
        }
      } catch {
        // ignore
      }
    }
    return undefined;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Auto-start web mining when autoMining is true but no miner is running yet
  // (happens after reload when deps become ready)
  useEffect(() => {
    if (!autoMining || isNative || persistentWasmMiner || !canMine) return;
    console.log('[Mining] Dependencies ready, starting web miners');
    startMiningRound().then(() => startPolling());
  }, [autoMining, canMine, startMiningRound, startPolling, isNative]);

  // Native: hot-swap challenge when a new block arrives while mining is active.
  useEffect(() => {
    if (!autoMining || !isNative || !latestBlock) return;
    const newChallenge = latestBlock.blockHash;
    if (newChallenge === challengeRef.current) return;

    console.log('[Mining] Block changed, updating native challenge:', newChallenge.slice(0, 16));
    challengeRef.current = newChallenge;
    blockTimestampRef.current = latestBlock.timestamp;

    invoke('update_challenge', {
      challengeHex: newChallenge,
      blockTimestamp: latestBlock.timestamp,
    }).catch((err) => console.warn('[Mining] update_challenge failed:', err));
  }, [autoMining, latestBlock, isNative]);

  // Web mining: rotate challenge before it expires (max_proof_age).
  // Unlike native which uses block hashes, web miners generate random challenges
  // and mine continuously. Rotate at 80% of max_proof_age to leave submission margin.
  useEffect(() => {
    const activeMiner = persistentWasmMiner;
    if (!autoMining || isNative || !activeMiner) return;
    const maxAge = config?.max_proof_age ?? 3600;
    const rotateAfterMs = maxAge * 0.8 * 1000; // 80% of max age

    const timer = setInterval(() => {
      const elapsed_ = Date.now() - challengeCreatedAtRef.current;
      if (elapsed_ >= rotateAfterMs) {
        const randomBytes = new Uint8Array(32);
        crypto.getRandomValues(randomBytes);
        const newChallenge = Array.from(randomBytes, (b) => b.toString(16).padStart(2, '0')).join('');
        const timestamp = Math.floor(Date.now() / 1000) - 5;
        console.log('[Mining] Rotating challenge (age:', Math.round(elapsed_ / 1000), 's), new:', newChallenge.slice(0, 16));
        challengeRef.current = newChallenge;
        blockTimestampRef.current = timestamp;
        challengeCreatedAtRef.current = Date.now();
        activeMiner.start(newChallenge, userDifficulty);
      }
    }, 30_000); // Check every 30s

    return () => clearInterval(timer);
  }, [autoMining, isNative, config?.max_proof_age, userDifficulty]);

  // Auto-adjust difficulty if contract min_difficulty increases above user setting
  useEffect(() => {
    if (config && config.min_difficulty > userDifficulty) {
      console.log('[Mining] Config min_difficulty increased, adjusting:', config.min_difficulty);
      setUserDifficulty(config.min_difficulty);
    }
  }, [config, userDifficulty]);

  const handleStartMining = useCallback(async () => {
    // If genesis is in the future, enter countdown mode instead of mining
    console.log('[Mining][start] genesis check:', {
      genesis_time: config?.genesis_time,
      now: Math.floor(Date.now() / 1000),
      inFuture: config?.genesis_time ? config.genesis_time > Math.floor(Date.now() / 1000) : 'no config',
    });
    if (config?.genesis_time && config.genesis_time > Math.floor(Date.now() / 1000)) {
      setCountdownMode(true);
      logAction('start_countdown', `genesis_time=${config.genesis_time}`);
      return;
    }
    miningAddressRef.current = address;
    setAutoMining(true);
    logAction('start_mining', `difficulty=${userDifficulty} threads=${threadCount} backend=${backend}`);
    await startMiningRound();
    startPolling();
  }, [startMiningRound, startPolling, address, config?.genesis_time, logAction, userDifficulty, threadCount, backend]);

  const handleStopMining = useCallback(async () => {
    // Guard: ignore click events from the same frame as mount (stale click-through
    // from previous page's ActionBar back button hitting our Stop button).
    if (!stopReadyRef.current) {
      console.log('[Mining][stop] IGNORED โ€” stale click before first frame');
      return;
    }
    console.log('[Mining][stop] handleStopMining called', new Error().stack?.split('\n').slice(1, 4).join(' <- '));
    logAction('stop_mining');
    setCountdownMode(false);
    setAutoMining(false);
    retryQueueRef.current = [];
    consecutiveFailsRef.current = 0;
    setRpcOnline(true);
    try {
      if (isNative) {
        await invoke('stop_mining');
      } else {
        if (persistentWasmMiner) {
          persistentWasmMiner.destroy();
          persistentWasmMiner = null;
          wasmMinerRef.current = null;
        }
      }
    } catch (err) {
      console.error('[Mining] Failed to stop mining', err);
    }
    stopPolling();
    proofQueueRef.current = [];
    // Resolve any "submitted" proofs that are still pending (tx may still confirm in background)
    setProofLog((prev) =>
      prev.map((p) =>
        p.status === 'submitted' && !p.txHash
          ? { ...p, status: 'failed' as const, error: 'Mining stopped' }
          : p
      )
    );
  }, [stopPolling, isNative, logAction]);

  // Stop mining when account switches away from the address that started it
  useEffect(() => {
    console.log('[Mining][account-switch] effect, autoMining:', autoMining, 'address:', address?.slice(0, 16), 'miningAddressRef:', miningAddressRef.current?.slice(0, 16));
    if (!autoMining) return;
    if (miningAddressRef.current && address !== miningAddressRef.current) {
      console.warn('[Mining][account-switch] STOPPING: address mismatch', address, '!==', miningAddressRef.current);
      handleStopMining();
    }
  }, [address, autoMining, handleStopMining]);

  // Stop mining if contract is paused
  useEffect(() => {
    console.log('[Mining][pause-check] effect, paused:', config?.paused, 'autoMining:', autoMining);
    if (config?.paused && autoMining) {
      console.log('[Mining][pause-check] STOPPING: contract paused');
      handleStopMining();
    }
  }, [config?.paused, autoMining, handleStopMining]);

  // TESTING: Countdown auto-start โ€” remove after testing phase
  useEffect(() => {
    if (!countdownMode || countdownSeconds > 0) return;
    // Double-check genesis is actually in the past (guards against stale-0 race)
    if (config?.genesis_time && config.genesis_time > Math.floor(Date.now() / 1000)) return;
    console.log('[Mining] Countdown reached zero โ€” launching mining');
    setCountdownMode(false);
    miningAddressRef.current = address;
    setAutoMining(true);
    startMiningRound().then(() => startPolling());
  }, [countdownMode, countdownSeconds, address, startMiningRound, startPolling, config?.genesis_time]);

  const handleCopyAddress = useCallback(() => {
    if (address) {
      navigator.clipboard.writeText(address);
    }
  }, [address]);

  // Build report params from current state (sync โ€” no Tauri invoke)
  const getReportParams = useCallback(
    (tauriStatus: Record<string, unknown> | null = null, tauriParams: Record<string, unknown> | null = null): ReportParams => ({
      isNative,
      address,
      referrer,
      liBalance,
      autoMining,
      userDifficulty,
      minDifficulty,
      threadCount,
      backend,
      availableBackends,
      hashrate,
      totalHashes: miningStatus?.total_hashes ?? 0,
      elapsed,
      pendingProofs: miningStatus?.pending_proofs ?? 0,
      sessionLiMined,
      tauriStatus,
      tauriParams,
      latestBlock: latestBlock
        ? { height: latestBlock.height, blockHash: latestBlock.blockHash, timestamp: latestBlock.timestamp }
        : null,
      wsConnected,
      samples,
      config,
      windowStatus,
      uniqueMiners,
      totalProofs,
      avgDifficulty,
      dRate,
      similarDevices,
      windowEntries,
      baseRate,
      rewardPerProof,
      grossRewardPerProof,
      estimatedLiPerHour,
      rpcOnline,
      retryQueueSize: retryQueueRef.current.length,
      consecutiveFails: consecutiveFailsRef.current,
      proofLog,
      sessionStart,
      actionLog,
    }),
    [
      address, referrer, liBalance, autoMining, userDifficulty, minDifficulty,
      threadCount, backend, availableBackends, hashrate, miningStatus, elapsed,
      sessionLiMined, latestBlock, wsConnected, samples, config, windowStatus,
      uniqueMiners, totalProofs, avgDifficulty, dRate,
      similarDevices, windowEntries, baseRate, rewardPerProof,
      grossRewardPerProof, estimatedLiPerHour, proofLog, isNative, rpcOnline,
      sessionStart, actionLog,
    ]
  );

  // Auto-save mining logs to disk (Tauri only)
  const { saveToDailyFile, clearAllLogs, revealInFinder } = useAutoSaveLogs({
    enabled: isNative && autoMining,
    sessionStart,
    buildReport: useCallback(
      () => buildMiningReport(getReportParams()),
      [getReportParams]
    ),
  });

  // Clear stale local data when genesis hasn't started (post-reset)
  useEffect(() => {
    if (!genesisInFuture) return;
    localStorage.removeItem(PROOF_LOG_KEY);
    localStorage.removeItem(SESSION_LI_KEY);
    localStorage.removeItem(MINING_ACTIVE_KEY);
    localStorage.removeItem(MINING_ADDRESS_KEY);
    localStorage.removeItem(ACTION_LOG_KEY);
    setProofLog([]);
    setSessionLiMined(0);
    setActionLog([]);
    setAutoMining(false);
    clearAllLogs();
  }, [genesisInFuture, clearAllLogs]);

  const handleExportLogs = useCallback(async () => {
    logAction('export_logs', isNative ? 'tauri' : 'web');
    // Tauri: save to daily sessions file + reveal in Finder
    if (isNative) {
      const saved = await saveToDailyFile();
      if (saved) {
        console.log('[Mining] Exported to daily file:', saved);
        revealInFinder(saved);
      }
      return;
    }

    // Web: browser file download
    try {
      const report = buildMiningReport(getReportParams());
      const text = JSON.stringify(report, null, 2);
      const filename = `mining-log-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
      const blob = new Blob([text], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      a.click();
      URL.revokeObjectURL(url);
    } catch (err) {
      console.error('[Mining] Export failed:', err);
      logAction('export_logs', 'web', 'error', String(err));
    }
  }, [isNative, getReportParams, saveToDailyFile, revealInFinder, logAction]);

  // PoW share from on-chain state: 1 - S^alpha (100% when nothing staked)
  const powSharePercent = (powShare * 100).toFixed(1);
  // Miner's network share
  const userDRate = hashrate > 0 && userDifficulty > 0
    ? userDifficulty * (hashrate / Math.pow(2, userDifficulty))
    : 0;
  const networkSharePercent = userDRate > 0 && dRate > 0
    ? Math.min((userDRate / dRate) * 100, 100)
    : 0;

  return (
    <MainContainer>
      <Display title={<DisplayTitle title="Mining" />}>
        <div className={styles.wrapper}>
          {/* Header: wallet + status + simulator toggle */}
          <div className={styles.header}>
            <div className={styles.walletInfo}>
              {address ? trimString(address, 12, 6) : 'No wallet'}
              {address && (
                <button
                  type="button"
                  className={styles.copyBtn}
                  onClick={handleCopyAddress}
                  title="Copy address"
                >
                  copy
                </button>
              )}
            </div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
              <button
                type="button"
                className={styles.simToggleBtn}
                onClick={() => setConfigOpen((v) => !v)}
              >
                {configOpen ? 'Hide Config' : 'Config'}
              </button>
              <button
                type="button"
                className={styles.simToggleBtn}
                onClick={handleExportLogs}
                title="Download mining report as JSON file"
              >
                Export Logs
              </button>
              {genesisInFuture
                ? <Pill color="yellow" text={countdownMode ? 'Launching' : 'Countdown'} />
                : autoMining && !rpcOnline
                  ? <Pill color="yellow" text="Offline" />
                  : <Pill color="black" text={autoMining ? 'Mining' : 'Idle'} />}
            </div>
          </div>

          {/* Config panel (collapsible) */}
          <ConfigPanel open={configOpen} config={config} onConfigUpdated={refetchConfig} />

          {/* Desktop download CTA (web only โ€” first thing users see) */}
          {!isNative && <DownloadSection address={address} accountName={defaultAccount?.name || undefined} />}

          {/* Hero: big hashrate + sparkline */}
          <HashrateHero
            hashrate={hashrate}
            isActive={autoMining}
            samples={samples}
            sessionAvg={elapsed > 0 ? (miningStatus?.total_hashes ?? 0) / elapsed : undefined}
            countdown={countdownSeconds > 0 ? countdownSeconds : undefined}
            genesisTimeSec={genesisInFuture ? config?.genesis_time : undefined}
          />

          {/* 4-card stat grid */}
          <div className={styles.statsGrid}>
            <StatCard
              label="LI Mined"
              value={compactLi(sessionLiMined)}
              suffix="LI"
            />
            <StatCard
              label="Proofs"
              value={proofLog.filter((p) => p.status === 'success').length}
            />
            <StatCard
              label="Est. LI/hr"
              value={`~${compactLi(estimatedLiPerHour)}`}
            />
            <StatCard label="Elapsed" value={formatElapsed(elapsed)} />
          </div>

          {/* LI Balance row */}
          <div className={styles.balanceRow}>
            <span>LI Balance</span>
            <span>{compactLi(liBalance)} LI</span>
          </div>

          {/* Reward โ€” per-proof math chain */}
          <div className={styles.sectionBox}>
            <span className={styles.sectionTitle}>Reward</span>
            <div className={styles.statsGrid}>
              <StatCard label="Gross / proof" value={compactLi(grossRewardPerProof)} suffix="LI" />
              <StatCard label="PoW share" value={`${powSharePercent}%`} />
              <StatCard label="Referral / pool cut" value="10%" />
              <StatCard label="Net / proof" value={compactLi(rewardPerProof)} suffix="LI" />
            </div>
          </div>

          {/* Network info */}
          <div className={styles.sectionBox}>
            <div className={styles.networkHeader}>
              <span className={styles.sectionTitle}>Network</span>
              <span className={styles.refreshBadge}>
                {wsConnected ? 'live' : 'reconnecting...'}
              </span>
            </div>
            <div className={styles.statsGrid}>
              <StatCard
                label="Your share"
                value={networkSharePercent > 0 ? `${networkSharePercent.toFixed(1)}%` : '\u2014'}
              />
              <StatCard label="Window" value={`${windowEntries} / ${windowSize || '...'}`}>
                {windowSize > 0 && (
                  <div className={styles.windowProgress}>
                    <div
                      className={styles.windowProgressFill}
                      style={{ width: `${Math.min((windowEntries / windowSize) * 100, 100)}%` }}
                    />
                  </div>
                )}
              </StatCard>
              {totalMinted && (
                <StatCard
                  label="Supply"
                  value={`${formatLi(totalMinted.total_minted)} / ${formatLi(totalMinted.supply_cap)}`}
                  suffix="LI"
                />
              )}
              <StatCard
                label="Active miners"
                value={uniqueMiners > 0 ? uniqueMiners : '...'}
              />
              <StatCard
                label="Avg difficulty"
                value={avgDifficulty > 0 ? avgDifficulty.toFixed(1) : '...'}
                suffix="bits"
              />
            </div>
          </div>

          {/* Staking section */}
          <StakingSection logAction={logAction} sendContractTx={sendContractTx} />

          {/* Referral section */}
          <ReferralSection
            referrer={referrer}
            onReferrerChange={(v: string | null) => { setReferrer(v); logAction('set_referrer', v || 'none'); }}
          />

          {/* Proof summary + paginated list */}
          {proofLog.length > 0 && (() => {
            const accepted = proofLog.filter((p) => p.status === 'success').length;
            const failed = proofLog.filter((p) => p.status === 'failed' || (p.error && !p.status)).length;
            const retrying = proofLog.filter((p) => p.status === 'retrying').length;
            const pending = proofLog.filter((p) => p.status === 'submitted' || p.status === 'pending').length;
            const total = accepted + failed;
            const rate = total > 0 ? ((accepted / total) * 100).toFixed(0) : '\u2014';
            const PAGE_SIZE = 20;
            const visibleProofs = proofLog.slice(0, proofPage * PAGE_SIZE);
            const hasMore = proofLog.length > visibleProofs.length;
            return (
              <div className={styles.sectionBox}>
                <span className={styles.sectionTitle}>Proofs</span>
                <div className={styles.statsGrid}>
                  <StatCard label="Accepted" value={accepted} />
                  <StatCard label="Failed" value={failed} />
                  {retrying > 0 && <StatCard label="Retrying" value={retrying} />}
                  <StatCard label="Success rate" value={`${rate}%`} />
                </div>
                <div className={styles.proofLog}>
                  {visibleProofs.map((p, i) => (
                    <ProofLogEntry
                      key={`${p.hash}-${p.timestamp}`}
                      index={proofLog.length - i}
                      hash={p.hash}
                      txHash={p.txHash}
                      error={p.error}
                      status={p.status}
                      timestamp={p.timestamp}
                    />
                  ))}
                </div>
                {hasMore && (
                  <button
                    type="button"
                    className={styles.showMoreBtn}
                    onClick={() => setProofPage((p) => p + 1)}
                  >
                    Show more ({proofLog.length - visibleProofs.length} remaining)
                  </button>
                )}
              </div>
            );
          })()}
        </div>
      </Display>

      <MiningActionBar
        difficulty={userDifficulty}
        minDifficulty={minDifficulty}
        address={address}
        blockReady={!!latestBlock}
        autoMining={autoMining}
        submitting={submitting}
        miningStatus={miningStatus}
        onStartMining={handleStartMining}
        onStopMining={handleStopMining}
        onDifficultyChange={(v: number) => { setUserDifficulty(v); logAction('set_difficulty', `${v}`); }}
        backend={backend}
        onBackendChange={(v: string) => { setBackend(v); logAction('set_backend', v); }}
        availableBackends={availableBackends}
        activeBackend={miningStatus?.backend}
        threadCount={threadCount}
        onThreadCountChange={(v: number) => { setThreadCount(v); logAction('set_threads', `${v}`); }}
        maxThreads={isNative ? Math.max(1, cpuCores - 1) : Math.max(1, Math.floor(cpuCores / 2))}
        isNative={isNative}
        countdownMode={countdownMode}
        countdownSeconds={countdownSeconds}
      />
    </MainContainer>
  );
}

export default Mining;

Neighbours