/* global React, ReactDOM, io */
const { useState, useEffect, useRef, useCallback, useMemo } = React;
// formatPrice + useCurrencyTick are defined in components.jsx (loads first).

// ─── helpers ───────────────────────────────────────────
async function apiFetch(url, options = {}) {
  let res;
  try {
    res = await fetch(url, {
      credentials: 'include',
      headers: { 'Content-Type': 'application/json', ...options.headers },
      ...options,
    });
  } catch {
    throw Object.assign(new Error('Network error — check your connection'), { code: 'NETWORK_ERROR' });
  }
  const text = await res.text();
  let data = {};
  if (text) {
    try { data = JSON.parse(text); }
    catch { throw Object.assign(new Error(`Server returned ${res.status}`), { code: 'INVALID_RESPONSE' }); }
  }
  if (!res.ok || data.error) {
    throw Object.assign(new Error(data.error || `Request failed (${res.status})`), { code: data.code, status: res.status });
  }
  return data;
}

// Map CS2 rarity hex → kit rarity tier name
const RARITY_BY_HEX = {
  'b0c3d9': 'consumer',
  '5e98d9': 'industrial',
  '4b69ff': 'milspec',
  '8847ff': 'restricted',
  'd32ce6': 'classified',
  'eb4b4b': 'covert',
  'caab05': 'exceedingly',
};

function getWear(name) {
  if (/Factory New/.test(name)) return 'fn';
  if (/Minimal Wear/.test(name)) return 'mw';
  if (/Field-Tested/.test(name)) return 'ft';
  if (/Well-Worn/.test(name)) return 'ww';
  if (/Battle-Scarred/.test(name)) return 'bs';
  return 'fn';
}

function getShortName(name) {
  const cleaned = (name || '').replace(/^StatTrak™\s*/, '').replace(/^Souvenir\s*/, '');
  if (cleaned.startsWith('★')) return '★ Knife';
  if (/^Sticker \|/.test(cleaned)) return 'Sticker';
  if (/^Charm \|/.test(cleaned)) return 'Charm';
  if (/^Music Kit/.test(cleaned)) return 'Music';
  if (/Capsule|Package|Pin/.test(cleaned)) return 'Pins';
  const before = cleaned.split(/[|()]/)[0].trim();
  return before || 'Item';
}

function getRarity(item) {
  const color = (item.rarityColor || '').toLowerCase().replace('#', '');
  if (RARITY_BY_HEX[color]) return RARITY_BY_HEX[color];
  // Fallback by tag
  const tag = (item.tags || []).find(t => t.category === 'Rarity');
  if (tag?.color) return RARITY_BY_HEX[tag.color.toLowerCase()] || 'covert';
  return 'covert';
}

// Steam tag "Type" → category key. Steam's tag is the most reliable source —
// it's what Steam itself uses to bucket items in the Community Market.
const TYPE_TO_CATEGORY = {
  'Knife':         'knife',
  'Gloves':        'gloves',
  'Pistol':        'pistol',
  'SMG':           'smg',
  'Rifle':         'rifle',
  'Sniper Rifle':  'sniper',
  'Shotgun':       'shotgun',
  'Machinegun':    'mg',
  'Sticker':       'sticker',
  'Music Kit':     'music_kit',
  'Patch':         'patch',
  'Container':     'container',
  'Agent':         'agent',
};

// Fallback name-pattern matcher for items where the tag isn't present.
const KNIFE_NAMES = ['Bayonet','Karambit','M9 Bayonet','Butterfly Knife','Flip Knife','Gut Knife','Falchion Knife','Bowie Knife','Huntsman Knife','Shadow Daggers','Navaja Knife','Stiletto Knife','Talon Knife','Ursus Knife','Classic Knife','Paracord Knife','Survival Knife','Nomad Knife','Skeleton Knife','Kukri Knife'];
const CATEGORY_PATTERNS = [
  ['rifle',   /^(AK-47|M4A4|M4A1-S|AUG|SG 553|FAMAS|Galil AR)\b/],
  ['sniper',  /^(AWP|SSG 08|SCAR-20|G3SG1)\b/],
  ['smg',     /^(MP5-SD|MP7|MP9|MAC-10|UMP-45|P90|PP-Bizon)\b/],
  ['pistol',  /^(Desert Eagle|Glock-18|USP-S|P250|Five-SeveN|Tec-9|CZ75-Auto|Dual Berettas|P2000|R8 Revolver|Zeus x27)\b/],
  ['shotgun', /^(Nova|XM1014|Sawed-Off|MAG-7)\b/],
  ['mg',      /^(M249|Negev)\b/],
];

function getCategory(item) {
  // 1. Prefer Steam's Type tag — most accurate, covers stickers / agents / music kits.
  const tags = item?.tags || [];
  const typeTag = tags.find(t =>
    t.category === 'Type' ||
    t.category === 'CSGO_Type_Tool' ||
    t.localized_category_name === 'Type'
  );
  if (typeTag) {
    const v = typeTag.localized_tag_name || typeTag.name || typeTag.internal_name || '';
    if (TYPE_TO_CATEGORY[v]) return TYPE_TO_CATEGORY[v];
  }
  // 2. Name-based fallback for weapon types.
  const name = item?.market_hash_name || item?.name || '';
  if (!name) return 'other';
  if (/^Sticker \|/i.test(name)) return 'sticker';
  if (/^Music Kit \|/i.test(name)) return 'music_kit';
  if (/^Patch \|/i.test(name)) return 'patch';
  if (/Gloves|Hand Wraps/i.test(name)) return 'gloves';
  if (/Case$|Capsule$|Package$|Souvenir Package$/i.test(name)) return 'container';
  const knifeMatch = KNIFE_NAMES.some(k => name.includes(k + ' |') || name.endsWith(k));
  if (knifeMatch) return 'knife';
  const stripped = name.replace(/^(?:StatTrak™\s+|Souvenir\s+|★\s+)/i, '').trim();
  for (const [cat, re] of CATEGORY_PATTERNS) if (re.test(stripped)) return cat;
  return 'other';
}

// Build an absolute Steam economy image URL from the icon hash the API returns.
function buildIconUrl(iconHash) {
  if (!iconHash) return null;
  if (/^https?:\/\//.test(iconHash)) return iconHash; // already an absolute URL
  return `https://community.cloudflare.steamstatic.com/economy/image/${iconHash}/256fx192f`;
}

// Resolve Steam's %owner_steamid% / %assetid% placeholders in the inspect-link template
// so the link can be opened directly to launch CS2's in-game item viewer.
function resolveInspectLink(item, ownerSteamId) {
  const action = (item.actions || []).find(a => /^steam:\/\//i.test(a?.link || ''));
  if (!action || !action.link) return null;
  return action.link
    .replace(/%owner_steamid%/gi, ownerSteamId || '')
    .replace(/%assetid%/gi, String(item.assetid || ''));
}

// Derive the sticker's source capsule/event from its name. Tournament stickers
// are formatted "<Name> | <Event>" (e.g. "Team Dignitas | Cologne 2014"), so the
// segment after the last " | " is the event — that's enough to find the capsule.
function getStickerSource(stickerName) {
  if (!stickerName) return null;
  const stripped = stickerName.replace(/^Sticker:\s*/i, '').trim();
  const segs = stripped.split(' | ');
  if (segs.length < 2) return null;
  return segs[segs.length - 1].trim();
}

// Steam sticker/charm icon URLs come from the API as bare hashes; expand them.
function buildDecalUrl(s) {
  if (!s) return null;
  const u = s.iconUrl || s.icon_url;
  if (!u) return null;
  if (/^https?:\/\//.test(u)) return u;
  return `https://community.cloudflare.steamstatic.com/economy/image/${u}/64fx48f`;
}

// Map a /api/inventory/user error (server code or thrown apiFetch error) to a
// human-readable {title, message} pair shown inside the inventory panel.
function translateUserInventoryError(err) {
  const code = err?.code || '';
  const status = err?.status || 0;
  if (code === 'INVENTORY_PRIVATE' || status === 403) {
    return {
      title: 'Your Steam inventory is private',
      message: 'Open Steam → Profile → Privacy and set "Inventory" to Public, then refresh this page.',
    };
  }
  if (code === 'INVENTORY_RATE_LIMITED' || status === 429) {
    return {
      title: 'Steam is rate-limiting us',
      message: 'Steam has temporarily blocked inventory lookups. Try again in a minute.',
    };
  }
  if (code === 'INVENTORY_EMPTY' || status === 404) {
    return {
      title: 'No tradeable skins',
      message: 'No CS2 skins above the minimum trade value were found in your inventory.',
    };
  }
  if (code === 'NETWORK_ERROR') {
    return {
      title: 'Network error',
      message: 'Could not reach the server. Check your internet connection and refresh.',
    };
  }
  if (code === 'INVENTORY_FETCH_FAILED' || status === 502) {
    return {
      title: 'Steam is unreachable',
      message: "Steam's inventory service didn't respond. Try refreshing in a few seconds.",
    };
  }
  return {
    title: 'Could not load your inventory',
    message: err?.message || 'Unknown error. Hit refresh to retry.',
  };
}

// Categories where "WeaponName | SkinName (Wear)" → "SkinName" is the right call.
// Stickers / agents / music kits / etc. keep their full name because the prefix
// IS the identity (e.g. "Sticker | Coldzera | Boston 2018").
const WEAPON_CATEGORIES = new Set(['knife','gloves','pistol','smg','rifle','sniper','shotgun','mg']);
function getDisplayName(name, category) {
  if (!name) return '';
  if (!WEAPON_CATEGORIES.has(category)) return name;
  // Drop everything before the first " | " (the weapon name) and any trailing
  // "(Wear)" or "(Phase X)" paren group. Leaves "Case Hardened" / "Doppler Sapphire".
  const afterPipe = name.includes(' | ') ? name.split(' | ').slice(1).join(' | ') : name;
  return afterPipe.replace(/\s*\([^)]+\)\s*$/, '').trim();
}

function transformItem(item, ownerSteamId) {
  // `name` is the canonical Steam name used for price/sticker lookups.
  // `phasedName` is the server's display name with Doppler phase already inserted
  // (e.g. "★ Karambit | Doppler Phase 3"). We need both: canonical for matching,
  // phased for what the user sees.
  const name = item.market_hash_name || item.name || 'Unknown';
  const phasedName = item.name || name;
  const float = item.pattern?.floatvalue != null ? parseFloat(item.pattern.floatvalue) : 0;
  const paintSeed  = item.pattern?.paintseed  != null ? Number(item.pattern.paintseed)  : null;
  const paintIndex = item.pattern?.paintindex != null ? Number(item.pattern.paintindex) : null;
  const phase      = item.pattern?.phase || null;
  const stat = /StatTrak™/.test(name);
  const souvenir = /^Souvenir\s/i.test(name);
  // Steam tags carry the collection / capsule name. For weapon skins it's
  // `ItemSet` (e.g. "The Phoenix Collection"). For sticker items it's
  // `StickerCapsule` — and that one already distinguishes "ESL One Cologne 2014
  // Legends" vs "ESL One Cologne 2014 Challengers", so we surface it as-is.
  const itemSetTag = (item.tags || []).find(t =>
    t.category === 'ItemSet'        ||
    t.category === 'StickerCapsule' ||
    t.localized_category_name === 'Collection'
  );
  const collection = itemSetTag?.localized_tag_name || itemSetTag?.name || null;
  const collectionLogoUrl = item.collectionLogoUrl || null;
  const lockHours = item.tradeLocked && item.tradableAt
    ? Math.max(0, Math.ceil((item.tradableAt - Math.floor(Date.now() / 1000)) / 3600))
    : null;
  // Rich decal data — name + resolved icon URL — so the card can render real sticker/charm images.
  const stickerData = (item.stickers || []).map(s => ({
    name: s.name,
    iconUrl: buildDecalUrl(s),
    source: getStickerSource(s.name),
  }));
  const charmData   = (item.charms   || []).map(c => ({ name: c.name, iconUrl: buildDecalUrl(c) }));
  const category = getCategory(item);
  return {
    id: String(item.assetid),
    assetid: item.assetid,
    ownerSteamId,
    name: phasedName,                                     // canonical reference (server-applied phase) — used for tooltips
    marketHashName: name,                                 // canonical Steam name (no phase) — used for lookups
    // Display name is built from the unphased canonical name so the card shows
    // "Doppler" instead of "Doppler Phase 3" — the phase is shown separately
    // via the gradient phase chip above the name.
    displayName: getDisplayName(name, category),
    collection,                                           // e.g. "The Phoenix Collection"
    collectionLogoUrl,                                    // crest image URL (or null)
    shortName: getShortName(name),
    wear: getWear(name),
    float,
    paintSeed,
    paintIndex,
    phase,
    price: (item.priceCents || 0) / 100,
    priceCents: item.priceCents || 0,
    rarity: getRarity(item),
    stickers: stickerData.length,
    charm: charmData.length > 0,
    stickerData,
    charmData,
    stat,
    souvenir,
    lockHours,
    tradeLocked: !!item.tradeLocked,
    isHighDemand: !!item.isHighDemand,
    category,
    iconUrl: buildIconUrl(item.icon_url),
    inspectLink: resolveInspectLink(item, ownerSteamId),
    raw: item,
  };
}

// Reduce a server trade row to the shape <RecentTrades> expects.
function transformTradeRow(t) {
  const items_bot  = (t.items_bot  || []).slice().sort((a,b) => (b.priceCents||0) - (a.priceCents||0));
  const items_user = (t.items_user || []).slice().sort((a,b) => (b.priceCents||0) - (a.priceCents||0));
  const topBot  = items_bot[0];
  const topUser = items_user[0];

  // created_at is stored in seconds (legacy schema)
  const createdMs = (t.created_at || 0) * 1000;
  const ageMs = Date.now() - createdMs;
  const fmtRel = (ms) => {
    const s = Math.floor(ms / 1000);
    if (s < 60)        return `${s}s ago`;
    if (s < 3600)      return `${Math.floor(s/60)}m ago`;
    if (s < 86400)     return `${Math.floor(s/3600)}h ago`;
    if (s < 86400*30)  return `${Math.floor(s/86400)}d ago`;
    return new Date(createdMs).toLocaleDateString();
  };
  const fmtDate = (ms) => {
    const d = new Date(ms);
    return d.toLocaleDateString() + ' · ' + d.toLocaleTimeString([], { hour:'2-digit', minute:'2-digit' });
  };

  // delta = balance flow for the user. Credit positive, used negative.
  const delta = ((t.balance_credit_cents || 0) - (t.balance_used_cents || 0)) / 100;
  const deltaLabel = delta >= 0 ? 'balance credited' : 'balance used';

  // Estimated fee retained by the bot
  const PROFIT_MARGIN = 1.07;
  const fee = ((t.bot_value_cents || 0) * (1 - 1/PROFIT_MARGIN)) / 100;

  const statusLabelMap = {
    accepted:  'Completed',
    sent:      'Pending',
    pending:   'Pending',
    declined:  'Declined',
    cancelled: 'Cancelled',
    failed:    'Failed',
  };

  return {
    id: 'TXN-' + (t.offer_id || t._id || '').toString().slice(-6).toUpperCase(),
    when:    fmtRel(ageMs),
    date:    fmtDate(createdMs),
    statusLabel: statusLabelMap[t.status] || t.status || 'Pending',
    statusClass: t.status === 'accepted' ? 'ok' : (t.status === 'declined' || t.status === 'cancelled' || t.status === 'failed' ? 'bad' : 'pending'),
    got: {
      name:  topBot?.name || '—',
      price: (topBot?.priceCents || 0) / 100,
      color: '#C8A6FF',
      icon:  topBot?.icon_url ? `https://community.cloudflare.steamstatic.com/economy/image/${topBot.icon_url}/64fx48f` : null,
    },
    gave: {
      name:  topUser?.name || (t.balance_used_cents > 0 ? '(used balance)' : '—'),
      price: (topUser?.priceCents || 0) / 100,
      color: '#7ED6FF',
      icon:  topUser?.icon_url ? `https://community.cloudflare.steamstatic.com/economy/image/${topUser.icon_url}/64fx48f` : null,
    },
    delta,
    deltaLabel,
    fee: t.status === 'accepted' ? fee : null,
  };
}

// In-memory dismissed offer set (matches the legacy trade.js behaviour)
const _dismissedOfferIds = new Set();

// Lightweight preview row for the "Trade offer sent" modal — shows the actual items
// that will be exchanged so the user can verify before opening the offer in Steam.
function ItemPreviewRow({ item }) {
  return (
    <div style={{
      display:'flex', alignItems:'center', gap:10, padding:'6px 10px',
      background:'rgba(255,255,255,0.03)',
      border:'1px solid rgba(255,255,255,0.06)',
      borderRadius:8,
    }}>
      {item.iconUrl
        ? <img src={item.iconUrl} alt="" loading="lazy" style={{width:36, height:28, objectFit:'contain', flexShrink:0}} />
        : <div style={{width:36, height:28, background:'rgba(200,166,255,0.10)', borderRadius:4, flexShrink:0}}></div>}
      <div style={{flex:1, minWidth:0, fontSize:12, lineHeight:1.3, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap'}} title={item.name}>{item.displayName || item.name}</div>
      <div style={{fontSize:12, fontWeight:700, color:'var(--gold, #F2C48D)', fontVariantNumeric:'tabular-nums', flexShrink:0}}>{formatPrice(item.price)}</div>
    </div>
  );
}

// Body for the pre-send "Confirm trade" modal — lists every item with a picture
// so the user can verify before the offer is dispatched.
function ConfirmTradeBody({ giveItems, recvItems, giveTotal, recvTotal, balanceCash }) {
  const diff = recvTotal - giveTotal;
  const balanceUse = diff > 0 ? Math.min(diff, balanceCash) : 0;
  const refund = diff < 0 ? -diff : 0;
  return (
    <div style={{display:'flex', flexDirection:'column', gap:14}}>
      <p style={{margin:0, fontSize:13, color:'var(--text-secondary)'}}>
        Review the items below. The Steam offer will arrive in your inbox within ~30 s.
      </p>

      {recvItems.length > 0 && (
        <div>
          <div style={{fontSize:10, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.10em', color:'var(--accent)', marginBottom:6}}>
            You receive · {formatPrice(recvTotal)}
          </div>
          <div style={{display:'flex', flexDirection:'column', gap:5, maxHeight:180, overflowY:'auto'}}>
            {recvItems.map(it => <ItemPreviewRow key={'cr-'+it.id} item={it} />)}
          </div>
        </div>
      )}

      {giveItems.length > 0 && (
        <div>
          <div style={{fontSize:10, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.10em', color:'var(--text-muted)', marginBottom:6}}>
            You give · {formatPrice(giveTotal)}
          </div>
          <div style={{display:'flex', flexDirection:'column', gap:5, maxHeight:180, overflowY:'auto'}}>
            {giveItems.map(it => <ItemPreviewRow key={'cg-'+it.id} item={it} />)}
          </div>
        </div>
      )}

      {balanceUse > 0 && (
        <div style={{padding:'8px 12px', background:'rgba(200,166,255,0.06)', border:'1px solid rgba(200,166,255,0.20)', borderRadius:8, fontSize:12, color:'var(--text-secondary)'}}>
          Site balance used: <b>{formatPrice(balanceUse)}</b>{diff > balanceCash ? <> · short by <b>{formatPrice(diff - balanceCash)}</b></> : null}
        </div>
      )}
      {refund > 0 && (
        <div style={{padding:'8px 12px', background:'rgba(255,255,255,0.04)', border:'1px solid rgba(255,255,255,0.08)', borderRadius:8, fontSize:12, color:'var(--text-secondary)'}}>
          Overpay credited to balance: <b>{formatPrice(refund)}</b>
        </div>
      )}
    </div>
  );
}

function PendingTradeBody({ pending }) {
  const botItems  = pending.botItems  || [];
  const userItems = pending.userItems || [];
  const balanceUsed = (pending.balanceUsedCents || 0) / 100;
  const balanceOnly = userItems.length === 0 && balanceUsed > 0;

  return (
    <div style={{display:'flex', flexDirection:'column', gap:14}}>
      <p style={{margin:0, fontSize:13, color:'var(--text-secondary)'}}>
        Open the offer in Steam to accept it. Your balance is only deducted when you accept.
      </p>

      {/* What you receive (from bot) */}
      {botItems.length > 0 && (
        <div>
          <div style={{fontSize:10, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.10em', color:'var(--accent)', marginBottom:6}}>
            You receive · {formatPrice(pending.botTotal)}
          </div>
          <div style={{display:'flex', flexDirection:'column', gap:5, maxHeight:180, overflowY:'auto'}}>
            {botItems.map(it => <ItemPreviewRow key={'b-'+it.id} item={it} />)}
          </div>
        </div>
      )}

      {/* What you give — only when there are user-side items.
          Per requirement: when trading with balance, only show what was taken from the bot. */}
      {!balanceOnly && userItems.length > 0 && (
        <div>
          <div style={{fontSize:10, fontWeight:700, textTransform:'uppercase', letterSpacing:'0.10em', color:'var(--text-muted)', marginBottom:6}}>
            You give · {formatPrice(pending.userTotal)}
          </div>
          <div style={{display:'flex', flexDirection:'column', gap:5, maxHeight:180, overflowY:'auto'}}>
            {userItems.map(it => <ItemPreviewRow key={'u-'+it.id} item={it} />)}
          </div>
        </div>
      )}

      {balanceOnly && (
        <div style={{
          padding:'8px 12px',
          background:'rgba(200,166,255,0.06)',
          border:'1px solid rgba(200,166,255,0.20)',
          borderRadius:8,
          fontSize:12,
          color:'var(--text-secondary)',
        }}>
          Paying with site balance — <b>{formatPrice(balanceUsed)}</b> will be deducted on accept.
        </div>
      )}

      <a className="btn primary" href={`https://steamcommunity.com/tradeoffer/${pending.offerId}/`} target="_blank" rel="noopener noreferrer">
        Open in Steam ↗
      </a>
      <span style={{fontSize:11, color:'var(--text-muted)'}}>Offer ID: {pending.offerId}</span>
    </div>
  );
}

// ─── App ────────────────────────────────────────────────
function App() {
  useCurrencyTick(); // re-render whenever the user switches currency
  const [user,         setUser]         = useState(null);  // { steamId, balanceCents, tradeUrl, displayName, avatar }
  const [botSteamId,   setBotSteamId]   = useState('');
  const [hdBonusPct,   setHdBonusPct]   = useState(0);     // .env-driven HIGH_DEMAND_MARGIN bonus (%)
  const [botItems,     setBotItems]     = useState([]);
  const [userItems,    setUserItems]    = useState([]);
  const [giving,       setGiving]       = useState([]);    // selected user assetids
  const [receiving,    setReceiving]    = useState([]);    // selected bot assetids
  const [modal,        setModal]        = useState(null);
  const [toast,        setToastState]   = useState(null);
  const [pending,      setPending]      = useState(null);  // { offerId } when a trade is in flight
  const [botOnline,    setBotOnline]    = useState(true);
  const [recentTrades, setRecentTrades] = useState([]);
  const [userError,    setUserError]    = useState(null);  // { title, message } when /api/inventory/user fails
  const [botError,     setBotError]     = useState(null);  // same for /api/inventory/bot
  // Bot inventory filter selectors. maxLockDays: 0..8 — 0 = tradeable only,
  // 8 = include everything (Steam's longest hold is 7 days).
  // category: 'any' | knife | gloves | pistol | smg | rifle | sniper | shotgun | mg | agent | container | music_kit | patch | sticker.
  const [botFilter,    setBotFilter]    = useState({ statTrak: false, souvenir: false, maxLockDays: 8, category: 'any', exterior: 'any' });
  const [mobileTab,    setMobileTab]    = useState('bot'); // mobile-only tab: 'you' | 'center' | 'bot'
  const [errorModal,   setErrorModal]   = useState(null);  // { title, message } for serious failures
  const [, force] = useState(0);

  const tradeDraftRef = useRef('');
  const socketRef     = useRef(null);
  const showToast = useCallback((m, t = 3200) => {
    setToastState(m);
    clearTimeout(showToast._t);
    showToast._t = setTimeout(() => setToastState(null), t);
  }, []);

  // ─── Initial data fetch ───
  const fetchAuth = useCallback(async () => {
    try {
      const me = await apiFetch('/auth/me');
      if (me.botSteamId) setBotSteamId(me.botSteamId);
      if (typeof me.highDemandBonusPct === 'number') setHdBonusPct(me.highDemandBonusPct);
      if (me.loggedIn) {
        setUser({
          steamId: me.steamId,
          balanceCents: me.balanceCents || 0,
          tradeUrl: me.tradeUrl || '',
          username: me.username || '',
          displayName: me.username || '',
          avatar: me.avatar || '',
        });
      } else {
        setUser(null);
      }
    } catch {}
  }, []);

  // Pull real 30-day price history for each unique bot item and stitch the result onto the
  // matching items as `historyPoints` + `delta`. The card's sparkline draws this when present.
  const fetchPriceHistory = useCallback(async (items) => {
    // Price snapshots on the server are keyed by canonical Steam name (no Doppler
    // phase) — use marketHashName here, not the phased display name.
    const names = [...new Set(items.map(i => i.marketHashName || i.name).filter(Boolean))];
    if (names.length === 0) return;
    try {
      const r = await fetch('/api/inventory/price-history', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ names, days: 30 }),
        credentials: 'include',
      });
      if (!r.ok) return;
      const { history = {} } = await r.json();
      setBotItems(curr => curr.map(it => {
        const h = history[it.marketHashName || it.name];
        if (!h || h.length < 2) return it;
        const points = h.map(p => p.priceCents).filter(v => v > 0);
        if (points.length < 2) return it;
        const first = points[0];
        const last  = points[points.length - 1];
        const delta = first > 0 ? ((last - first) / first) * 100 : 0;
        return { ...it, historyPoints: points, delta };
      }));
    } catch {}
  }, []);

  // GC pattern data (paintindex / paintseed / floatvalue / phase) lands asynchronously
  // — populate /api/inventory/patterns and stitch each record onto the matching item
  // so the card name + 3D viewer can resolve Doppler phases.
  const fetchPatterns = useCallback(async () => {
    try {
      const data = await apiFetch('/api/inventory/patterns');
      if (!data || typeof data !== 'object') return;
      const merge = (curr) => curr.map(it => {
        const p = data[it.assetid];
        if (!p) return it;
        const float      = p.floatvalue != null ? parseFloat(p.floatvalue) : it.float;
        const paintSeed  = p.paintseed  != null ? Number(p.paintseed)      : it.paintSeed;
        const paintIndex = p.paintindex != null ? Number(p.paintindex)     : it.paintIndex;
        const phase      = p.phase || it.phase || null;
        const raw = it.raw
          ? { ...it.raw, pattern: { ...(it.raw.pattern || {}), ...p } }
          : it.raw;
        return { ...it, float, paintSeed, paintIndex, phase, raw };
      });
      setBotItems(merge);
      setUserItems(merge);
    } catch {}
  }, []);

  const fetchBotInventory = useCallback(async () => {
    try {
      const data = await apiFetch('/api/inventory/bot');
      const items = (data.items || []).map(it => transformItem(it, botSteamId));
      setBotItems(items);
      setBotError(null);
      // Re-pull price history every time we refresh, otherwise sparklines vanish
      // when the new fetch returns the same number of items (the watcher effect
      // gated on `botItems.length` wouldn't re-fire). Same logic for pattern data
      // so newly-fetched items inherit the cached paintindex right away.
      if (items.length) {
        fetchPriceHistory(items);
        fetchPatterns();
      }
    } catch (err) {
      if (err.code === 'BOT_NOT_READY') {
        setBotOnline(false);
        setBotError({
          title: 'Bot is connecting',
          message: 'The trade bot is still booting up or reconnecting to Steam. Items will appear in a moment — try the refresh button shortly.',
        });
      } else if (err.code === 'NETWORK_ERROR') {
        setBotError({
          title: 'Network error',
          message: 'Could not reach the server. Check your internet connection and refresh.',
        });
      } else {
        setBotError({
          title: 'Could not load bot inventory',
          message: err.message || 'Unknown error. Hit refresh in a moment to retry.',
        });
      }
    }
  }, [botSteamId, fetchPriceHistory, fetchPatterns]);

  const fetchUserInventory = useCallback(async () => {
    if (!user?.steamId) {
      setUserError(null);
      return;
    }
    try {
      const data = await apiFetch('/api/inventory/user');
      // Server returns 200 with `error` + `code` for soft errors so the browser
      // doesn't log a noisy console error. Surface those as inventory errors too.
      if (data?.code) {
        setUserItems([]);
        setUserError(translateUserInventoryError(data));
        return;
      }
      setUserItems((data.items || []).map(it => transformItem(it, user.steamId)));
      setUserError(null);
    } catch (err) {
      if (err.status === 401) { setUserError(null); return; } // not logged in — handled by header
      setUserItems([]);
      setUserError(translateUserInventoryError(err));
    }
  }, [user]);

  const fetchTradeHistory = useCallback(async () => {
    if (!user) { setRecentTrades([]); return; }
    try {
      const data = await apiFetch('/api/trade/history');
      // Hide declined offers from the activity feed — they clutter the view with "no, I changed my mind" noise.
      const visible = (data.trades || []).filter(t => t.status !== 'declined');
      setRecentTrades(visible.slice(0, 10).map(transformTradeRow));
    } catch {}
  }, [user]);

  useEffect(() => { fetchAuth(); }, [fetchAuth]);
  // Refetch bot inventory once we know the bot's steam ID — so inspect links resolve correctly.
  // fetchBotInventory itself triggers fetchPriceHistory after each fetch, so sparklines
  // come along on every refresh.
  useEffect(() => { fetchBotInventory(); }, [fetchBotInventory]);

  // Poll patterns (float / paint_seed) every 20s for the first 2 minutes — GC inspections
  // arrive asynchronously after a fresh inventory load.
  useEffect(() => {
    fetchPatterns();
    const t = setInterval(fetchPatterns, 20_000);
    const stop = setTimeout(() => clearInterval(t), 120_000);
    return () => { clearInterval(t); clearTimeout(stop); };
  }, [fetchPatterns, botItems.length === 0]);  // re-arm when fresh inventory arrives

  useEffect(() => {
    if (user) {
      fetchUserInventory();
      fetchTradeHistory();
    } else {
      setUserItems([]);
      setRecentTrades([]);
    }
  }, [user, fetchUserInventory, fetchTradeHistory]);

  // ─── Socket.IO ───
  useEffect(() => {
    if (typeof io === 'undefined') return;
    const s = io({ transports: ['websocket', 'polling'] });
    socketRef.current = s;

    s.on('botStatus',   ({ online }) => setBotOnline(!!online));
    s.on('botOffline',  () => setBotOnline(false));

    // Server signals when the GC finishes connecting after we've already
    // served the bot inventory once — re-fetch so trade-locked items the
    // initial response missed pop in without the user clicking refresh.
    s.on('botInventoryStale', () => { fetchBotInventory(); });

    s.on('tradePending', ({ offerId }) => {
      if (_dismissedOfferIds.has(offerId)) return;
      setPending(p => p || { offerId });
    });

    s.on('tradeUpdate', update => {
      // Always refresh balance from the server
      if (update.newBalanceCents != null) {
        setUser(u => u ? { ...u, balanceCents: update.newBalanceCents } : u);
      }
      if (update.status === 'accepted') {
        showToast(<>Trade accepted — items delivered ✓</>, 5000);
        if (update.tradedBotAssetIds?.length) {
          const removed = new Set(update.tradedBotAssetIds.map(String));
          setBotItems(items => items.filter(i => !removed.has(i.id)));
        }
        fetchUserInventory();
        fetchTradeHistory();
        setTimeout(fetchBotInventory, 30_000);
        setPending(null);
      } else if (update.status === 'declined') {
        showToast(<>Trade declined.</>, 5000);
        fetchTradeHistory();
        setPending(null);
      } else if (update.status === 'cancelled') {
        showToast(<>Trade cancelled.</>, 5000);
        fetchTradeHistory();
        setPending(null);
      }
    });

    return () => { s.disconnect(); socketRef.current = null; };
  }, [fetchUserInventory, fetchBotInventory, fetchTradeHistory, showToast]);

  // ─── Tick loop so trade-lock countdowns refresh ───
  useEffect(() => {
    const t = setInterval(() => force(x => x + 1), 60_000);
    return () => clearInterval(t);
  }, []);

  // ─── Selected items + totals ───
  const giveItems = useMemo(
    () => giving.map(id => userItems.find(i => i.id === id)).filter(Boolean),
    [giving, userItems]
  );
  const recvItems = useMemo(
    () => receiving.map(id => botItems.find(i => i.id === id)).filter(Boolean),
    [receiving, botItems]
  );
  const giveTotal = giveItems.reduce((s, i) => s + i.price, 0);
  const recvTotal = recvItems.reduce((s, i) => s + i.price, 0);

  // ─── Selection guards ───
  const onToggleBot = useCallback((id) => {
    const item = botItems.find(i => i.id === id);
    if (!item) return;
    if (item.tradeLocked) {
      const eta = item.lockHours
        ? (item.lockHours < 24 ? `${item.lockHours}h` : `${Math.floor(item.lockHours / 24)}d ${item.lockHours % 24}h`)
        : 'soon';
      showToast(<>Trade-locked — available in {eta}</>);
      return;
    }
    if (item.priceCents <= 0) {
      showToast(<>Price still loading for this item — wait a few seconds.</>);
      return;
    }
    setReceiving(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
  }, [botItems, showToast]);

  const onToggleUser = useCallback((id) => {
    const item = userItems.find(i => i.id === id);
    if (!item) return;
    if (item.tradeLocked) {
      showToast(<>This item is trade-locked.</>);
      return;
    }
    setGiving(s => s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
  }, [userItems, showToast]);

  // ─── Send Trade ───
  const submitTrade = useCallback(async () => {
    if (!user) { window.location.href = '/auth/steam'; return; }
    if (!user.tradeUrl) {
      openTradeUrlModal();
      showToast(<>Set your Steam Trade URL first.</>);
      return;
    }
    if (!recvItems.length) { showToast(<>Select at least one item from the bot.</>); return; }
    setModal(null);
    showToast(<>Sending trade offer to Steam…</>, 8000);
    try {
      const r = await apiFetch('/api/trade/propose', {
        method: 'POST',
        body: JSON.stringify({
          botAssetIds:  recvItems.map(i => i.assetid),
          userAssetIds: giveItems.map(i => i.assetid),
        }),
      });
      // Snapshot the items so the pending modal can show what's actually in the offer.
      setPending({
        offerId: r.offerId,
        botItems:  recvItems,
        userItems: giveItems,
        botTotal:  recvTotal,
        userTotal: giveTotal,
        balanceUsedCents: r.balanceUsedCents || 0,
      });
      setGiving([]);
      setReceiving([]);
      // Immediately refresh inventories — bot's reserved items disappear, user's locked items update
      fetchBotInventory();
      fetchUserInventory();
    } catch (err) {
      const msg = err.message || 'Trade failed';
      // Trade-URL prompts get the dedicated modal; everything else surfaces as a
      // blocking error popup so it can't be missed.
      if (err.code === 'NO_TRADE_URL' || err.code === 'INVALID_TRADE_URL') {
        openTradeUrlModal();
        showToast(<><b>Trade URL needed:</b> {msg}</>, 5000);
      } else {
        setErrorModal({ title: 'Trade failed', message: msg });
      }
    }
  }, [user, recvItems, giveItems, fetchBotInventory, fetchUserInventory, showToast]);

  // ─── Trade URL modal ───
  const openTradeUrlModal = useCallback(() => {
    tradeDraftRef.current = user?.tradeUrl || '';
    setModal({
      kind: 'tradeurl',
      title: 'Steam Trade URL',
      confirmLabel: 'Save',
      confirm: async () => {
        const v = (tradeDraftRef.current || '').trim();
        try {
          await apiFetch('/api/user/trade-url', { method: 'PUT', body: JSON.stringify({ tradeUrl: v }) });
          setUser(u => u ? { ...u, tradeUrl: v } : u);
          setModal(null);
          showToast(<>Trade URL <b>{v ? 'saved' : 'cleared'}</b></>);
        } catch (err) {
          showToast(<><b>Error:</b> {err.message}</>, 5000);
        }
      },
    });
  }, [user, showToast]);

  const openContactModal = useCallback(() => {
    setModal({
      kind: 'contact',
      title: 'Contact us',
      confirmLabel: 'Open Discord',
      confirm: () => {
        setModal(null);
        window.open('https://discord.gg/Y3YZxx9KkB', '_blank', 'noopener,noreferrer');
      },
    });
  }, []);

  const onSendClick = useCallback(() => {
    if (!user) { window.location.href = '/auth/steam'; return; }
    setModal({
      title: 'Confirm trade',
      body: <ConfirmTradeBody
        giveItems={giveItems}
        recvItems={recvItems}
        giveTotal={giveTotal}
        recvTotal={recvTotal}
        balanceCash={(user?.balanceCents || 0) / 100}
      />,
      confirmLabel: 'Send offer',
      confirm: submitTrade,
    });
  }, [user, giveItems, recvItems, giveTotal, recvTotal, submitTrade]);

  // ─── Render ───
  const balanceCash = (user?.balanceCents || 0) / 100;
  const yourInventoryVal = userItems.reduce((s, i) => s + i.price, 0);
  const botInventoryVal  = botItems.filter(i => !i.tradeLocked).reduce((s, i) => s + i.price, 0);

  // Apply the bot-inventory filter from CenterPanel before passing items to the bot side.
  const visibleBotItems = useMemo(() => {
    return botItems.filter(it => {
      if (botFilter.statTrak && !it.stat) return false;
      if (botFilter.souvenir && !it.souvenir) return false;
      // Trade-lock cap: convert lockHours → whole-day bucket; tradeable items count as 0.
      const lockDays = it.tradeLocked && it.lockHours ? Math.ceil(it.lockHours / 24) : 0;
      if (lockDays > botFilter.maxLockDays) return false;
      if (botFilter.category !== 'any' && it.category !== botFilter.category) return false;
      if (botFilter.exterior !== 'any' && it.wear !== botFilter.exterior) return false;
      return true;
    });
  }, [botItems, botFilter]);

  return (
    <div className="app">
      <Header
        active="Trade"
        balance={balanceCash}
        tradeUrl={user?.tradeUrl || ''}
        onSetTradeUrl={user ? openTradeUrlModal : () => { window.location.href = '/auth/steam'; }}
        user={user}
      />

      <main>
        <div className="mobile-tabs" role="tablist" aria-label={T('tradePanelsAria')}>
          <button
            type="button"
            className={'mobile-tab' + (mobileTab === 'you' ? ' active' : '')}
            onClick={() => setMobileTab('you')}
            role="tab"
            aria-selected={mobileTab === 'you'}
          >
            {T('inventoryYour')}{giving.length ? <span className="mobile-tab-badge">{giving.length}</span> : null}
          </button>
          <button
            type="button"
            className={'mobile-tab' + (mobileTab === 'center' ? ' active' : '')}
            onClick={() => setMobileTab('center')}
            role="tab"
            aria-selected={mobileTab === 'center'}
          >
            {T('tradeTab')}{(giving.length + receiving.length) ? <span className="mobile-tab-badge">{giving.length + receiving.length}</span> : null}
          </button>
          <button
            type="button"
            className={'mobile-tab' + (mobileTab === 'bot' ? ' active' : '')}
            onClick={() => setMobileTab('bot')}
            role="tab"
            aria-selected={mobileTab === 'bot'}
          >
            {T('inventoryBot')}{receiving.length ? <span className="mobile-tab-badge">{receiving.length}</span> : null}
          </button>
        </div>
        <div className={'workbench mobile-show-' + mobileTab}>
          <InventorySide
            side="give"
            label={user ? T('inventoryYour') : T('signInToLoad')}
            items={userItems}
            balance={yourInventoryVal}
            selected={giving}
            onToggle={onToggleUser}
            onRefresh={user ? fetchUserInventory : () => { window.location.href = '/auth/steam'; }}
            hdBonusPct={hdBonusPct}
            error={userError}
          />

          <CenterPanel
            giveItems={giveItems}
            recvItems={recvItems}
            giveTotal={giveTotal}
            recvTotal={recvTotal}
            balance={balanceCash}
            canSend={recvItems.length > 0 && !pending}
            onRemoveGive={(id) => setGiving(s => s.filter(x => x !== id))}
            onRemoveRecv={(id) => setReceiving(s => s.filter(x => x !== id))}
            onSend={onSendClick}
            recentTrades={recentTrades}
            botFilter={botFilter}
            onBotFilterChange={setBotFilter}
          />

          <InventorySide
            side="recv"
            label={botOnline ? T('inventoryBot') : T('botOffline')}
            items={visibleBotItems}
            balance={botInventoryVal}
            age={botOnline ? 'live' : '—'}
            selected={receiving}
            onToggle={onToggleBot}
            onRefresh={fetchBotInventory}
            error={botError}
          />
        </div>
      </main>

      {modal && <Modal
        title={modal.title}
        body={modal.kind === 'tradeurl'
          ? <TradeUrlModalBody initial={user?.tradeUrl || ''} onChange={(v) => { tradeDraftRef.current = v; }} />
          : modal.kind === 'contact'
            ? <ContactModalBody />
            : modal.body}
        onCancel={() => setModal(null)}
        onConfirm={modal.confirm}
        confirmLabel={modal.confirmLabel || 'Confirm'}
      />}

      {pending && (
        <Modal
          title={T('tradeOfferSent')}
          body={<PendingTradeBody pending={pending} />}
          onCancel={() => { _dismissedOfferIds.add(pending.offerId); setPending(null); }}
          onConfirm={() => { _dismissedOfferIds.add(pending.offerId); setPending(null); }}
          confirmLabel={T('close')}
          singleAction
        />
      )}

      {errorModal && (
        <ErrorModal
          title={errorModal.title}
          message={errorModal.message}
          onClose={() => setErrorModal(null)}
        />
      )}

      {toast && <Toast message={toast} />}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
