const fs = require("fs");
const path = require("path");

const { loadEloData } = require("./elo");
const { loadMeals, repoRoot } = require("./meals");

const STATE_VERSION = 1;
const defaultStatePath = path.join(repoRoot, ".runtime", "rankings-state.json");

function isValidMealId(id) {
  return typeof id === "string" && /^\d+$/.test(id);
}

function createDefaultEntry(id, defaultRating) {
  return {
    id,
    rating: defaultRating,
    wins: 0,
    losses: 0,
  };
}

function cloneEntry(entry) {
  return {
    id: entry.id,
    rating: entry.rating,
    wins: entry.wins,
    losses: entry.losses,
  };
}

function roundRating(rating) {
  return Math.round(rating * 1000) / 1000;
}

function expectedScore(rating, opponentRating) {
  return 1 / (1 + Math.pow(10, (opponentRating - rating) / 400));
}

function isValidStoredEntry(entry) {
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
    return false;
  }

  if (typeof entry.id !== "string" || !/^\d+$/.test(entry.id)) {
    return false;
  }

  if (typeof entry.rating !== "number" || !Number.isFinite(entry.rating) || entry.rating <= 0) {
    return false;
  }

  return ["wins", "losses"].every(
    (field) => Number.isInteger(entry[field]) && entry[field] >= 0
  );
}

function createSeedState(seedData) {
  return {
    version: STATE_VERSION,
    voteCount: 0,
    lastPairKey: null,
    updatedAt: null,
    undo: null,
    elo: {
      defaultRating: seedData.elo.defaultRating,
      kFactor: seedData.elo.kFactor,
      entries: seedData.meals.map((meal) => {
        const seedEntry =
          seedData.elo.entries.find((entry) => entry.id === meal.id) ||
          createDefaultEntry(meal.id, seedData.elo.defaultRating);

        return cloneEntry(seedEntry);
      }),
    },
  };
}

function syncStateCore(seedData, storedState) {
  if (!storedState || typeof storedState !== "object" || Array.isArray(storedState)) {
    return createSeedState(seedData);
  }

  const storedEntries = Array.isArray(storedState.elo?.entries)
    ? storedState.elo.entries.filter(isValidStoredEntry)
    : [];
  const storedEntryById = new Map(storedEntries.map((entry) => [entry.id, entry]));
  const seedEntryById = new Map(seedData.elo.entries.map((entry) => [entry.id, entry]));
  const entries = seedData.meals.map((meal) => {
    const storedEntry = storedEntryById.get(meal.id);

    if (storedEntry) {
      return cloneEntry(storedEntry);
    }

    const seedEntry =
      seedEntryById.get(meal.id) || createDefaultEntry(meal.id, seedData.elo.defaultRating);

    return cloneEntry(seedEntry);
  });
  const derivedVoteCount = entries.reduce((sum, entry) => sum + entry.wins, 0);

  return {
    version: STATE_VERSION,
    voteCount:
      Number.isInteger(storedState.voteCount) && storedState.voteCount >= derivedVoteCount
        ? storedState.voteCount
        : derivedVoteCount,
    lastPairKey: typeof storedState.lastPairKey === "string" ? storedState.lastPairKey : null,
    updatedAt: typeof storedState.updatedAt === "string" ? storedState.updatedAt : null,
    undo: null,
    elo: {
      defaultRating: seedData.elo.defaultRating,
      kFactor: seedData.elo.kFactor,
      entries,
    },
  };
}

function createPairKey(leftId, rightId) {
  return [leftId, rightId].sort().join(":");
}

function createUndoSnapshot(state) {
  return {
    voteCount: state.voteCount,
    lastPairKey: state.lastPairKey,
    updatedAt: state.updatedAt,
    elo: {
      defaultRating: state.elo.defaultRating,
      kFactor: state.elo.kFactor,
      entries: state.elo.entries.map(cloneEntry),
    },
  };
}

function resolveUndoPairIds(winnerId, loserId, leftId, rightId) {
  const validPair =
    isValidMealId(leftId) &&
    isValidMealId(rightId) &&
    leftId !== rightId &&
    [leftId, rightId].includes(winnerId) &&
    [leftId, rightId].includes(loserId);

  if (validPair) {
    return { leftId, rightId };
  }

  return { leftId: winnerId, rightId: loserId };
}

function syncUndo(seedData, storedUndo) {
  if (!storedUndo || typeof storedUndo !== "object" || Array.isArray(storedUndo)) {
    return null;
  }

  const { leftId, rightId, winnerId, loserId, snapshot } = storedUndo;

  if (
    !isValidMealId(leftId) ||
    !isValidMealId(rightId) ||
    !isValidMealId(winnerId) ||
    !isValidMealId(loserId) ||
    leftId === rightId ||
    winnerId === loserId
  ) {
    return null;
  }

  const pairIds = new Set([leftId, rightId]);
  const mealIds = new Set(seedData.meals.map((meal) => meal.id));

  if (
    !pairIds.has(winnerId) ||
    !pairIds.has(loserId) ||
    ![leftId, rightId, winnerId, loserId].every((id) => mealIds.has(id))
  ) {
    return null;
  }

  return {
    pairKey: createPairKey(leftId, rightId),
    leftId,
    rightId,
    winnerId,
    loserId,
    snapshot: createUndoSnapshot(syncStateCore(seedData, snapshot)),
  };
}

function syncStoredState(seedData, storedState) {
  const nextState = syncStateCore(seedData, storedState);

  return {
    ...nextState,
    undo: syncUndo(seedData, storedState?.undo),
  };
}

function restoreUndoSnapshot(seedData, snapshot) {
  return {
    ...syncStateCore(seedData, snapshot),
    undo: null,
  };
}

function applyVote(seedData, state, vote) {
  const { winnerId, loserId, pairKey, leftId, rightId } = vote;
  const entryById = new Map(state.elo.entries.map((entry) => [entry.id, cloneEntry(entry)]));
  const winner = entryById.get(winnerId);
  const loser = entryById.get(loserId);
  const undoPair = resolveUndoPairIds(winnerId, loserId, leftId, rightId);
  const resolvedPairKey =
    typeof pairKey === "string" && pairKey.length > 0
      ? pairKey
      : createPairKey(undoPair.leftId, undoPair.rightId);

  if (!winner || !loser) {
    throw new Error("Vote referenced an unknown meal id");
  }

  const winnerExpected = expectedScore(winner.rating, loser.rating);
  const loserExpected = expectedScore(loser.rating, winner.rating);

  winner.rating = roundRating(winner.rating + state.elo.kFactor * (1 - winnerExpected));
  loser.rating = roundRating(loser.rating + state.elo.kFactor * (0 - loserExpected));
  winner.wins += 1;
  loser.losses += 1;

  return {
    ...state,
    voteCount: state.voteCount + 1,
    lastPairKey: resolvedPairKey,
    updatedAt: new Date().toISOString(),
    undo: {
      pairKey: createPairKey(undoPair.leftId, undoPair.rightId),
      leftId: undoPair.leftId,
      rightId: undoPair.rightId,
      winnerId,
      loserId,
      snapshot: createUndoSnapshot(state),
    },
    elo: {
      ...state.elo,
      entries: seedData.meals.map((meal) => entryById.get(meal.id)),
    },
  };
}

function undoVote(seedData, state) {
  if (!state.undo) {
    return state;
  }

  return restoreUndoSnapshot(seedData, state.undo.snapshot);
}

function resolveStatePath(statePath) {
  return statePath ? path.resolve(statePath) : defaultStatePath;
}

function loadSeedData() {
  return {
    meals: loadMeals(),
    elo: loadEloData(),
  };
}

function readPersistedState(statePath) {
  if (!fs.existsSync(statePath)) {
    return null;
  }

  return JSON.parse(fs.readFileSync(statePath, "utf8"));
}

function writePersistedState(statePath, state) {
  fs.mkdirSync(path.dirname(statePath), { recursive: true });

  const nextState = `${JSON.stringify(state, null, 2)}\n`;
  const tempPath = `${statePath}.tmp`;

  fs.writeFileSync(tempPath, nextState);
  fs.renameSync(tempPath, statePath);
}

function loadRankingsState(options = {}) {
  const statePath = resolveStatePath(options.statePath);
  const seedData = loadSeedData();
  const storedState = readPersistedState(statePath);

  return syncStoredState(seedData, storedState);
}

function saveRankingsState(state, options = {}) {
  const statePath = resolveStatePath(options.statePath);
  const seedData = loadSeedData();
  const nextState = syncStoredState(seedData, state);

  writePersistedState(statePath, nextState);

  return nextState;
}

function recordVote(vote, options = {}) {
  if (!vote || typeof vote !== "object" || Array.isArray(vote)) {
    throw new Error("Vote payload must be an object");
  }

  const { winnerId, loserId, pairKey, leftId, rightId } = vote;

  if (!isValidMealId(winnerId)) {
    throw new Error("Vote payload is missing a valid winnerId");
  }

  if (!isValidMealId(loserId)) {
    throw new Error("Vote payload is missing a valid loserId");
  }

  if (winnerId === loserId) {
    throw new Error("winnerId and loserId must be different");
  }

  if (leftId !== undefined || rightId !== undefined) {
    if (!isValidMealId(leftId) || !isValidMealId(rightId) || leftId === rightId) {
      throw new Error("Vote payload is missing a valid left/right pair");
    }

    if (![leftId, rightId].includes(winnerId) || ![leftId, rightId].includes(loserId)) {
      throw new Error("Vote payload leftId/rightId must match winnerId/loserId");
    }
  }

  const statePath = resolveStatePath(options.statePath);
  const seedData = loadSeedData();
  const storedState = syncStoredState(seedData, readPersistedState(statePath));
  const nextState = applyVote(seedData, storedState, {
    winnerId,
    loserId,
    pairKey,
    leftId,
    rightId,
  });

  writePersistedState(statePath, nextState);

  return nextState;
}

function undoLastRankingsVote(options = {}) {
  const statePath = resolveStatePath(options.statePath);
  const seedData = loadSeedData();
  const storedState = syncStoredState(seedData, readPersistedState(statePath));
  const nextState = undoVote(seedData, storedState);

  writePersistedState(statePath, nextState);

  return nextState;
}

function resetRankingsState(options = {}) {
  const statePath = resolveStatePath(options.statePath);
  const seedData = loadSeedData();
  const nextState = createSeedState(seedData);

  writePersistedState(statePath, nextState);

  return nextState;
}

module.exports = {
  createSeedState,
  defaultStatePath,
  loadRankingsState,
  recordVote,
  resetRankingsState,
  saveRankingsState,
  syncStoredState,
  undoLastRankingsVote,
};
