// Guest-first player profiles (spec Раздел II + Task 9 accounts).
// Keyed by a client-generated device token; email+password can be linked later
// so the same profile loads on another device. Persisted through an injected
// `io` backend with debounced writes — plenty for a party game, no DB to
// babysit. No node imports here: the portal build runs this same economy in the
// browser (see public/local/host.js), so store.js holds the fs/crypto half.

import { isFlagPattern } from '../public/flags.js';

// thresholds ×3 (2026-07 operator feedback: league progress felt too fast)
export const LEAGUES = [
  { name: 'Bronze Grid', min: 0 },
  { name: 'Neon Cobalt', min: 1500, reward: { neobits: 100 } },
  { name: 'Cyber Gold', min: 4500, reward: { neobits: 250, item: 'gold' } },
  { name: 'Overclock Platinum', min: 9000, reward: { item: 'overclock' } },
  { name: 'NEON DEMON', min: 15000, reward: { item: 'neon-demon-title' } },
];

export function leagueFor(cp) {
  let idx = 0;
  for (let i = 0; i < LEAGUES.length; i++) if (cp >= LEAGUES[i].min) idx = i;
  return idx;
}

// placement CP for an 8-rider round; smaller rounds map onto the same curve
const CP_TABLE = [50, 30, 15, 0, 0, -20, -20, -20];
export function cpForPlacement(place, riders) {
  if (riders <= 1) return 0;
  const row = Math.round(((place - 1) * 7) / (riders - 1));
  return CP_TABLE[Math.max(0, Math.min(7, row))];
}

// ×15 across the board (2026-07 operator feedback: cosmetics should be earned)
export const PRICES = {
  neobits: { pulse: 9000, chevron: 9000 },
  shards: { rainbow: 450, lightning: 450, nyan: 750 },
};
export const FLAG_PRICE = 15000; // neobits, any national flag trail
export const FREE_PATTERNS = ['solid', 'dashed'];
export const FREE_SHAPES = ['blade', 'heavy', 'wing'];

const AD_REWARD = 5;
const AD_DAILY_CAP = 5;

// Daily challenges: 3 per UTC day, same set for everyone, rewards auto-granted
// the moment a challenge completes. Metrics come from applyRound deltas.
// The pool is ~100 generated entries: every metric the round summary can
// measure × an escalating target ladder. Labels live client-side as
// `chm_<metric>` templates, so wording stays plain in every language.
const CH_TARGETS = {
  matches: [2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30],
  wins: [1, 2, 3, 4, 5, 6, 8, 10],
  kills: [2, 3, 4, 5, 7, 10, 12, 15, 20, 25, 30],
  top3: [1, 2, 3, 4, 5, 6, 8, 10],
  pickups: [2, 3, 4, 5, 6, 8, 10, 12, 15, 20],
  uses: [2, 3, 4, 5, 6, 8, 10, 12, 15],
  survive: [60, 90, 120, 180, 240, 300, 420, 600, 900],
  boost: [15, 30, 45, 60, 90, 120, 180, 240],
  padhits: [2, 4, 6, 10],
  royale: [1, 2, 3, 5],
  royalewins: [1, 2],
  city: [1, 2, 3, 5],
  ridge: [1, 2, 3, 5],
  race: [1, 2, 3, 5],
  racewins: [1, 2],
};
const CH_REWARD = {
  matches: (n) => ({ neobits: 60 + n * 40 }),
  wins: (n) => (n >= 5 ? { shards: n * 2 } : { neobits: 250 * n }),
  kills: (n) => ({ neobits: 70 * n }),
  top3: (n) => ({ neobits: 120 * n }),
  pickups: (n) => ({ neobits: 40 * n }),
  uses: (n) => ({ neobits: 45 * n }),
  survive: (n) => ({ neobits: n }),
  boost: (n) => ({ neobits: 3 * n }),
  padhits: (n) => ({ neobits: 50 * n }),
  royale: (n) => ({ neobits: 150 * n }),
  royalewins: (n) => ({ shards: 10 * n }),
  city: (n) => ({ neobits: 120 * n }),
  ridge: (n) => ({ neobits: 120 * n }),
  race: (n) => ({ neobits: 150 * n }),
  racewins: (n) => ({ shards: 10 * n }),
};
export const CHALLENGE_POOL = Object.entries(CH_TARGETS).flatMap(([metric, targets]) =>
  targets.map((target) => ({
    id: metric + target, metric, target, reward: CH_REWARD[metric](target),
  })));

export function challengesFor(date) {
  // deterministic 3-of-pool pick, rotating daily
  let seed = 0;
  for (const ch of date) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;
  const idx = CHALLENGE_POOL.map((_, i) => i);
  for (let i = idx.length - 1; i > 0; i--) {
    seed = (seed * 1664525 + 1013904223) >>> 0;
    const j = seed % (i + 1);
    [idx[i], idx[j]] = [idx[j], idx[i]];
  }
  return idx.slice(0, 3).map((i) => CHALLENGE_POOL[i]);
}

const utcDay = () => new Date().toISOString().slice(0, 10);

const NEOBITS_PLAY = 10;
const NEOBITS_WIN = 50;
const NEOBITS_KILL = 15;

// Persistence seam. `read` returns the serialized blob (or null on first boot),
// `write` stores it. `passwords` is optional — without it email accounts are
// simply unavailable, which is what the browser build wants.
export const memoryIO = () => {
  let blob = null;
  return { read: () => blob, write: (data) => { blob = data; } };
};

export class Store {
  constructor(io = memoryIO(), passwords = null) {
    this.io = io;
    this.passwords = passwords;
    this.profiles = new Map();
    this.tokens = new Map();
    this.emails = new Map();
    this.charges = new Set(); // settled Telegram Stars payments, for idempotency
    this.saveTimer = null;
    this.load();
  }

  load() {
    try {
      const raw = JSON.parse(this.io.read());
      for (const p of raw.profiles || []) this.profiles.set(p.id, p);
      for (const [t, id] of Object.entries(raw.tokens || {})) this.tokens.set(t, id);
      for (const [e, id] of Object.entries(raw.emails || {})) this.emails.set(e, id);
      for (const c of raw.charges || []) this.charges.add(c);
    } catch { /* first boot */ }
  }

  serialize() {
    return JSON.stringify({
      profiles: [...this.profiles.values()],
      tokens: Object.fromEntries(this.tokens),
      emails: Object.fromEntries(this.emails),
      charges: [...this.charges],
    });
  }

  save() {
    if (this.saveTimer) return;
    this.saveTimer = setTimeout(() => {
      this.saveTimer = null;
      try {
        this.io.write(this.serialize());
      } catch (err) {
        console.error('profile save failed:', err.message);
      }
    }, 1500);
    this.saveTimer.unref?.();
  }

  // resolve-or-create by device token
  auth(token, name) {
    let id = this.tokens.get(token);
    let p = id && this.profiles.get(id);
    if (!p) {
      p = {
        id: token, createdAt: Date.now(), name: name || 'RIDER',
        email: null, passHash: null, salt: null,
        cp: 0, neobits: 0, shards: 0,
        stats: { matches: 0, wins: 0, kills: 0 },
        inventory: [],
        adsToday: { date: '', count: 0 },
      };
      this.profiles.set(p.id, p);
      this.tokens.set(token, p.id);
      this.save();
    }
    return p;
  }

  linkEmail(p, email, pass) {
    if (!this.passwords) return { error: 'accounts unavailable' };
    email = String(email || '').trim().toLowerCase();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return { error: 'bad email' };
    if (String(pass || '').length < 6) return { error: 'password too short (6+)' };
    if (this.emails.has(email)) return { error: 'email already linked' };
    p.salt = this.passwords.newSalt();
    p.passHash = this.passwords.hash(String(pass), p.salt);
    p.email = email;
    this.emails.set(email, p.id);
    this.save();
    return { ok: true };
  }

  login(token, email, pass) {
    if (!this.passwords) return null;
    email = String(email || '').trim().toLowerCase();
    const id = this.emails.get(email);
    const p = id && this.profiles.get(id);
    if (!p || !p.passHash) return null;
    if (!this.passwords.verify(String(pass || ''), p.salt, p.passHash)) return null;
    this.tokens.set(token, p.id); // this device now opens the account profile
    this.save();
    return p;
  }

  owned(p) {
    const patterns = new Set([...FREE_PATTERNS, ...p.inventory.filter(i => i !== 'overclock' && !i.endsWith('-title'))]);
    const shapes = new Set([...FREE_SHAPES, ...(p.inventory.includes('overclock') ? ['overclock'] : [])]);
    return { patterns, shapes };
  }

  buy(p, item) {
    if (p.inventory.includes(item)) return { error: 'already owned' };
    const neobitsPrice = isFlagPattern(item) ? FLAG_PRICE : PRICES.neobits[item];
    if (neobitsPrice !== undefined) {
      if (p.neobits < neobitsPrice) return { error: 'not enough neobits' };
      p.neobits -= neobitsPrice;
    } else if (PRICES.shards[item] !== undefined) {
      if (p.shards < PRICES.shards[item]) return { error: 'not enough data shards' };
      p.shards -= PRICES.shards[item];
    } else {
      return { error: 'unknown item' };
    }
    p.inventory.push(item);
    this.save();
    return { ok: true };
  }

  byId(id) {
    return this.profiles.get(id) || null;
  }

  // Telegram Stars purchase settled by the payment webhook. chargeId makes this
  // safe to call twice — Telegram retries an update it thinks we missed.
  grantPack(p, pack, chargeId) {
    if (chargeId && this.charges.has(chargeId)) return { duplicate: true };
    if (chargeId) this.charges.add(chargeId);
    p.shards += pack.shards || 0;
    for (const item of pack.items || []) {
      if (!p.inventory.includes(item)) p.inventory.push(item);
    }
    p.starsSpent = (p.starsSpent || 0) + (pack.stars || 0);
    this.save();
    return { ok: true, shards: pack.shards || 0, items: pack.items || [] };
  }

  adReward(p) {
    const today = utcDay();
    if (p.adsToday.date !== today) p.adsToday = { date: today, count: 0 };
    if (p.adsToday.count >= AD_DAILY_CAP) return { error: 'daily ad limit reached' };
    p.adsToday.count++;
    p.shards += AD_REWARD;
    this.save();
    return { ok: true, shards: AD_REWARD, left: AD_DAILY_CAP - p.adsToday.count };
  }

  ensureDaily(p, today = utcDay()) {
    if (!p.daily || p.daily.date !== today) {
      p.daily = { date: today, progress: [0, 0, 0], done: [false, false, false] };
    }
    return p.daily;
  }

  challengeState(p, today = utcDay()) {
    const daily = this.ensureDaily(p, today);
    return challengesFor(today).map((c, i) => ({
      id: c.id, metric: c.metric, target: c.target, reward: c.reward,
      progress: daily.progress[i], done: daily.done[i],
    }));
  }

  // round results -> currencies, CP, league rewards, stats
  applyRound(p, {
    placement, riders, kills, win, pickups = 0, uses = 0,
    survived = 0, boosted = 0, padHits = 0, mode = 'classic', map = 'empty',
  }, today = utcDay()) {
    const beforeLeague = leagueFor(p.cp);
    const cpDelta = cpForPlacement(placement, riders);
    p.cp = Math.max(0, p.cp + cpDelta);
    const neobitsDelta = NEOBITS_PLAY + (win ? NEOBITS_WIN : 0) + kills * NEOBITS_KILL;
    p.neobits += neobitsDelta;
    p.stats.matches++;
    if (win) p.stats.wins++;
    p.stats.kills += kills;

    // daily challenge progress + instant rewards
    const daily = this.ensureDaily(p, today);
    const delta = {
      matches: 1, wins: win ? 1 : 0, kills,
      top3: placement <= 3 ? 1 : 0, pickups, uses,
      survive: Math.round(survived), boost: Math.round(boosted), padhits: padHits,
      royale: mode === 'royale' ? 1 : 0,
      royalewins: mode === 'royale' && win ? 1 : 0,
      city: map === 'city' ? 1 : 0,
      ridge: map === 'ridge' ? 1 : 0,
      race: mode === 'race' ? 1 : 0,
      racewins: mode === 'race' && win ? 1 : 0,
    };
    const challengesDone = [];
    challengesFor(today).forEach((c, i) => {
      if (daily.done[i]) return;
      daily.progress[i] = Math.min(c.target, daily.progress[i] + (delta[c.metric] || 0));
      if (daily.progress[i] >= c.target) {
        daily.done[i] = true;
        if (c.reward.neobits) p.neobits += c.reward.neobits;
        if (c.reward.shards) p.shards += c.reward.shards;
        challengesDone.push({ id: c.id, reward: c.reward });
      }
    });
    let leagueUp = null;
    const afterLeague = leagueFor(p.cp);
    if (afterLeague > beforeLeague) {
      // grant every crossed league's reward (skipping several at once is rare)
      for (let i = beforeLeague + 1; i <= afterLeague; i++) {
        const r = LEAGUES[i].reward || {};
        if (r.neobits) p.neobits += r.neobits;
        if (r.item && !p.inventory.includes(r.item)) p.inventory.push(r.item);
      }
      leagueUp = LEAGUES[afterLeague].name;
    }
    this.save();
    return { cpDelta, neobitsDelta, leagueUp, challengesDone };
  }

  publicProfile(p) {
    const league = leagueFor(p.cp);
    return {
      name: p.name, email: p.email,
      cp: p.cp, neobits: p.neobits, shards: p.shards,
      league: LEAGUES[league].name, leagueIdx: league,
      nextLeagueAt: LEAGUES[league + 1] ? LEAGUES[league + 1].min : null,
      stats: p.stats,
      inventory: p.inventory,
      supporter: (p.starsSpent || 0) > 0,
      challenges: this.challengeState(p),
      adsLeft: p.adsToday.date === utcDay()
        ? Math.max(0, AD_DAILY_CAP - p.adsToday.count) : AD_DAILY_CAP,
    };
  }
}
