import WebSocket from 'ws';
import fs from 'fs';
import calculateLongestRoad from './longest-road';
import { getValidRoads, getValidCorners } from '../util/validLocations';
import { layout, staticData } from '../util/layout';
require("../console-line")
const version = '0.0.1';

if (process.argv.length < 5) {
  console.error(`
usage: npm start SERVER GAME-ID USER

For example:

  npm start https://nuc.ketrenos.com:3000/ketr.ketran robot-wars ai-1

`);
  process.exit(-1);
}

const server: string = process.argv[2] as string;
const gameId: string = process.argv[3] as string;
const name: string = process.argv[4] as string;
// Optional flag: --start
const startOnFull: boolean = process.argv.includes('--start');

const game: any = {};
const anyValue: any = undefined;

process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = '0';

/* Do not use arrow function as this is rebound to have 
 * this as the WebSocket */
let wsRef: WebSocket | undefined;
let send = function (data: any) {
  if (data.type === 'get') {
    console.log(`ws - send: get`, data.fields);
  } else {
    console.log(`ws - send: ${data.type}`);
  }
  if (wsRef && wsRef.readyState === WebSocket.OPEN) {
    wsRef.send(JSON.stringify(data));
  } else {
    console.warn('ws - send called but socket not open');
  }
};

const error = (e: any) => {
  console.log(`ws - error`, e);
};

const connect = async (): Promise<WebSocket> => {
  const loc = new URL(server as string);
  let player: string | undefined;
  try {
    const data = JSON.parse(await fs.promises.readFile(`${name}.json`, 'utf-8')) as any;
    player = data.player;
  } catch (_err) {
    // Use a minimal fetch options object to avoid RequestInit mismatches
    const res = await fetch(`${server}/api/v1/games`, ({ headers: { 'Content-Type': 'application/json' } } as any));
    if (!res) {
      throw new Error(`Unable to connect to ${server}`);
    }

    const text = await res.text();
    player = JSON.parse(text).player;
    await fs.promises.writeFile(`${name}.json`, JSON.stringify({ name, player }));
  }

  console.log(`Connecting to ${server} as ${player}`);

  const proto = loc.protocol === 'https:' ? 'wss' : 'ws';
  const new_uri = `${proto}://${loc.host}/ketr.ketran/api/v1/games/ws/${gameId}`;
  const ws = new WebSocket(new_uri, [], { headers: { Cookie: `player=${player}` } });

  return new Promise<WebSocket>((resolve) => {
    const headers = (_e?: any): void => { console.log(`ws - headers`); };
    const open = (): void => { console.log(`ws - open`); resolve(ws); };
    const close = (_e?: any): void => { console.log(`ws - close`); };

    ws.on('open', open);
    ws.on('headers', headers);
    ws.on('close', close);
    ws.on('error', error);
    ws.on('message', async (data: WebSocket.Data) => { await message(data); });
    // store reference for send()
    wsRef = ws;
  });
};

// createPlayer intentionally removed (unused)

const types = [ 'wheat', 'brick', 'stone', 'sheep', 'wood' ];

const tryBuild = (_received?: any): any => {
  let waitingFor: any = undefined;

  if (!waitingFor
  && game.private.settlements
  && game.private.wood 
  && game.private.brick
  && game.private.sheep
  && game.private.wheat) {
    const corners = getValidCorners(game, game.color);
    if (corners.length) {
      send({
        type: 'buy-settlement'
      });
      waitingFor = { 
        turn: {
          actions: anyValue
        }
      };
    }
  }
  
  if (!waitingFor
  && game.private.cities
  && game.private.stone >= 3
  && game.private.wheat >= 2) {
    const corners = getValidCorners(game, game.color, 'settlement');
    if (corners.length) {
      send({
        type: 'buy-city'
      });
      waitingFor = {
        turn: {
          actions: anyValue
        }
      };
    }
  }  

  if (!waitingFor
  && game.private.roads
  && game.private.wood
  && game.private.brick) {
    const roads = getValidRoads(game, game.color);
    if (roads.length) {
      send({
        type: 'buy-road'
      });
      waitingFor = {
        turn: {
          actions: anyValue
        }
      };
    }
  }

  if (!waitingFor
    && game.private.wheat
    && game.private.stone
    && game.private.sheep) {
    send({
      type: 'buy-development'
    });
    waitingFor = {
      private: {
        development: anyValue
      }
    };
  }

  return waitingFor;
};


const tryProgress = (_received?: any): any => {
  if (!game.private || !game.private.development) {
    return undefined;
  }
  let vps = 0;
  let firstPlayableProgress: any = undefined;
  // Count VP cards and remember the first non-VP progress card we can play
  for (let i = 0; i < game.private.development.length; i++) {
    const card = game.private.development[i];
    // card.turn >= game.turns => newly drawn this turn and can't be played
    if (card.turn >= game.turns || card.played) {
      if (card.type === 'vp') vps++;
      continue;
    }
    if (card.type === 'vp') {
      vps++;
      continue;
    }
    // remember the first playable non-VP card
    if (!firstPlayableProgress) firstPlayableProgress = card;
  }

  // If we have a non-VP progress card to play, play it (existing behavior)
  if (firstPlayableProgress) {
    console.log(`${name} - Playing -`, firstPlayableProgress);
    send({ type: 'play-card', card: firstPlayableProgress });
    return { turn: { actions: anyValue } };
  }

  // If we have VP cards, consider playing them to win. Determine victory threshold.
  if (vps > 0) {
    const currentPoints = (game.players && game.players[game.color] && game.players[game.color].points) || 0;
    let victoryThreshold = 10;
    try {
      if (game.rules && game.rules['victory-points'] && ('points' in game.rules['victory-points'])) {
        victoryThreshold = Number(game.rules['victory-points'].points) || victoryThreshold;
      }
    } catch (e) {
      // ignore and use default
    }

    // If playing our VP cards would reach or exceed the threshold, play one now
    if (currentPoints + vps >= victoryThreshold) {
      // find first unplayed VP card object
      for (let i = 0; i < game.private.development.length; i++) {
        const card = game.private.development[i];
        if (card.type === 'vp' && !(card.played) && !(card.turn >= game.turns)) {
          console.log(`${name} - Playing VP to attempt win -`, card);
          send({ type: 'play-card', card });
          return { turn: { actions: anyValue } };
        }
      }
    }

    // Otherwise just announce how many VP cards we have
    send({ type: 'chat', message: `I have ${vps} VP cards!` });
  }

  return undefined;
};

let sleeping = false;
let paused = false;
let winnerAnnounced = false;

const sleep = async (delay: number): Promise<void> => {
  if (sleeping) {
    return;
  }
  sleeping = true;
  return new Promise<void>((resolve: () => void) => {
    setTimeout(() => {
      sleeping = false;
      resolve();
    }, delay);
  });
};


const bestSettlementPlacement = (game: any): number => {
  const best = {
    index: -1,
    pips: 0
  };

  /* For each corner that is valid, find out which
   * tiles are on that corner, and for each of those
   * tiles, find the pip placement for that tile. */    
  (game.turn && game.turn.limits && Array.isArray(game.turn.limits.corners) ? game.turn.limits.corners : []).forEach((cornerIndex: any) => {
    const tiles: Array<{ tile: any; index: number; }> = [];
    (layout.tiles || []).forEach((tile: any, index: number) => {
      if ((tile.corners || []).indexOf(cornerIndex) !== -1 && tiles.findIndex(t => t.index === index) === -1) {
        tiles.push({ tile, index });
      }
    });

    let cornerScore = 0;

    /* Find the tileOrder holding this tile */
    tiles.forEach((tile: { tile: any; index: number }) => {
      const index = tile.index;
      const pipIndex = game.pipOrder && game.pipOrder[index];
      const score = (staticData.pips && staticData.pips[pipIndex] && staticData.pips[pipIndex].pips) || 0;
      cornerScore += score;
    });

    if (cornerScore > best.pips) {
      best.index = cornerIndex;
      best.pips = cornerScore;
    }
  });

  console.log(`${name} - Corner ${best.index} gives ${best.pips} pips.`);
  return best.index;
}

const bestRoadPlacement = (game: any): number => {
  const road: any = calculateLongestRoad(game) || { index: -1, segments: 0 };
  console.log(`${name} - could make road ${road.segments + 1} long on ${road.index}`);

  let attempt = -1;
  const layoutAny: any = layout;
  if (road && typeof road.index === 'number' && road.index !== -1 && layoutAny && layoutAny.roads && layoutAny.roads[road.index] && layoutAny.roads[road.index].corners) {
    const roadCorners = layoutAny.roads[road.index].corners || [];
    roadCorners.forEach((cornerIndex: any) => {
      if (attempt !== -1) {
        return;
      }
  const cornerRoads = (layoutAny.corners && layoutAny.corners[cornerIndex] && layoutAny.corners[cornerIndex].roads) || [];
      cornerRoads.forEach((roadIndex: any) => {
        if (attempt !== -1) {
          return;
        }
        const placedRoad = game.placements.roads[roadIndex];
        if (!placedRoad || placedRoad.color) {
          return;
        }
        attempt = roadIndex;
      });
    });
  }

  if (game && game.turn && game.turn.limits && Array.isArray(game.turn.limits.roads) && game.turn.limits.roads.indexOf(attempt) !== -1) {
    console.log(`${name} - attempting to place on end of longest road`);
    return attempt;
  } else {
    console.log(`${name} - selecting a random road location`);
    const roads = (game && game.turn && game.turn.limits && game.turn.limits.roads) || [];
    if (!roads.length) return -1;
    return roads[Math.floor(Math.random() * roads.length)];
  }
}

const isMatch = (input: any, received: any): boolean => {
  for (let key in input) {
    /* received update didn't contain this field */
    if (!(key in received)) {
      return false;
    }
    /* Received object had a value we were waiting to have set */
    if (input[key] === anyValue && (key in received)) {
      continue;
    }
    /* waitingFor field is an object, so recurse */
    if (typeof input[key] === 'object') {
      if (!isMatch(input[key], received[key])) {
        return false
      }
      /* object matched; go to next field */
      continue;
    }
    /* No match in requested key... */
    if (input[key] !== received[key]) {
      return false;
    }
    /* Value matches */
  }
  /* All fields set or matched */
  return true;
};

// Helper: deep clone/compare simple JSON-able values
const deepClone = (v: any) => {
  try { return JSON.parse(JSON.stringify(v)); } catch (e) { return v; }
};
// Stable stringify: recursively sort object keys so output is deterministic
const stableStringify = (v: any): string => {
  if (v === null) return 'null';
  const t = typeof v;
  if (t === 'number' || t === 'boolean' || t === 'string') return JSON.stringify(v);
  if (Array.isArray(v)) {
    return '[' + v.map((el) => stableStringify(el)).join(',') + ']';
  }
  if (t === 'object') {
    const keys = Object.keys(v).sort();
    return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify((v as any)[k])).join(',') + '}';
  }
  // fallback
  try { return JSON.stringify(v); } catch (e) { return String(v); }
};

const deepEqual = (a: any, b: any) => {
  try { return stableStringify(a) === stableStringify(b); } catch (e) { return a === b; }
};

// Helper: get value at dot-separated path (path may be array or string)
const getAtPath = (obj: any, path: string): any => {
  if (!path) return undefined;
  const parts = path.split('.');
  let cur: any = obj;
  for (let i = 0; i < parts.length; i++) {
    if (cur === undefined || cur === null) return undefined;
    const p = parts[i];
    // @ts-ignore - dynamic index; we've checked cur for null/undefined
    cur = (cur as any)[p];
  }
  return cur;
};

// Helper: collect all leaf paths from waitingFor object (returns dot-separated)
const collectLeafPaths = (obj: any, prefix = ''): string[] => {
  const paths: string[] = [];
  if (obj === anyValue || obj === undefined) {
    // leaf
    if (prefix) paths.push(prefix);
    return paths;
  }
  if (typeof obj !== 'object' || obj === null) {
    if (prefix) paths.push(prefix);
    return paths;
  }
  for (const k of Object.keys(obj)) {
    const next = prefix ? `${prefix}.${k}` : k;
    paths.push(...collectLeafPaths(obj[k], next));
  }
  return paths;
};

// Helper: check if msg.update explicitly contains the full path (leaf present in the incremental update)
const msgUpdateContainsPath = (update: any, path: string): boolean => {
  if (!update) return false;
  const parts = path.split('.');
  let cur: any = update;
  for (let i = 0; i < parts.length; i++) {
    if (cur === undefined || cur === null || typeof cur !== 'object') return false;
    const key: PropertyKey = parts[i] as PropertyKey;
    if (!Object.prototype.hasOwnProperty.call(cur, key)) return false;
    // @ts-ignore - dynamic index guarded by previous checks
    cur = (cur as any)[key];
  }
  return true;
};

const processLobby = (received: any): any => {
  /*
   * Lobby flow notes:
   * - `game` is the local snapshot built from previous updates (Object.assign(game, update)).
   * - `received` is the most recent partial update we got from the server which we use
   *   to check for fields the server just sent in this update cycle.
   * - `waitingFor` describes which fields we want the server to include in a future
   *   update. A value of `anyValue` means "I don't care what value, but the field must
   *   be present in the update"; a concrete value means "the field must equal this".
   *
   * Reconnection edge-case: on reconnect the server may provide `name` only in the
   * per-player response and/or the `game` snapshot may not yet have `game.name` set
   * (it might be undefined rather than an empty string). Be tolerant of both cases.
   */

  console.log(`${name} - processLobby start: game.name=${typeof game.name === 'undefined' ? 'undefined' : JSON.stringify(game.name)}, received.name=${typeof received === 'object' && 'name' in received ? JSON.stringify(received.name) : 'undefined'}`);

  // Prefer whatever the server just sent in `received` (if present), otherwise fall
  // back to the `game` snapshot. This avoids a strict === '' check which failed for
  // undefined on reconnect.
  const effectiveName = (received && typeof received.name !== 'undefined') ? received.name : game.name;

  if ((!effectiveName || effectiveName === '') && !(received && received.name)) {
    // If neither the current snapshot nor the latest update provide a game.name,
    // announce our player-name to the server and wait for confirmation plus
    // the players/unselected lists.
    send({ type: 'player-name', name });
    /* Wait for the game.name to be set to 'name' and for unselected */
    return { name, players: anyValue, unselected: anyValue };
  }

  // If the server included `name` in this update but our `game` snapshot is still
  // missing it, copy it across so later logic that reads `game.name` will see it.
  if (received && typeof received.name !== 'undefined' && typeof game.name === 'undefined') {
    game.name = received.name;
  }

  if (!received.unselected) {
    return {
      unselected: anyValue
    };
  }

  /* AI selected a Player. Wait for game-order.
   * Only treat the bot as already selected if its color is not the 'unassigned' sentinel.
   * Server may return an empty 'unselected' array even while the player's color
   * remains 'unassigned', so guard that case explicitly. */
  if (received.unselected.indexOf(name) === -1 && game.color !== 'unassigned') {
    send({
      type: 'chat',
      message: `Woohoo! Robot AI ${version} is alive and playing as ${game.color}!`
    });
    // We are already selected. If there are enough active participants in the
    // lobby, actively request to start the game by setting the state to
    // 'game-order'. Otherwise return a waitingFor that asks for the state so
    // we'll proceed once the server flips it.
    const participants = (game && game.participants) || [];
    const activeCount = participants.filter((p: any) => p && p.color && p.color !== 'unassigned').length;
    if (startOnFull) {
      if (activeCount >= 2) {
        console.log(`${name} - ${activeCount} active players detected; requesting start (startOnFull enabled)`);
        send({ type: 'set', field: 'state', value: 'game-order' });
      } else {
        console.log(`${name} - startOnFull enabled but only ${activeCount} active players; not starting yet`);
      }
    } else {
      console.log(`${name} - auto-start disabled (startOnFull not set); not requesting start even with ${activeCount} active players`);
    }

    // Still wait for the server to report state=game-order (either because we
    // requested it or because another player started it).
    return { state: 'game-order' };
  }

  const slots: any[] = [];
  for (let color in game.players) {
    if (game.players[color].status === 'Not active') {
      slots.push(color);
    }
  }

  if (slots.length === 0) {
    send({
      chat: `There are no slots for me to play :(. Waiting for one to open up.`
    });
    return { unselected: anyValue };
  }

  const index = Math.floor(Math.random() * slots.length);
  console.log(`${name} - requesting to play as ${slots[index]}.`);
  game.unselected = (game.unselected || []).filter((color: any) => color === slots[index]);
  send({
    type: 'set',
    field: 'color',
    value: slots[index]
  });

  // If requested, attempt to start the game immediately after claiming a slot
  // (only when startOnFull is set). This mirrors the reconnect flow and gives
  // the server a short moment to update player lists before we check counts.
  if (startOnFull) {
    setTimeout(() => {
      try {
        const participants = (game && game.participants) || [];
        const activeCount = participants.filter((p: any) => p && p.color && p.color !== 'unassigned').length;
        console.log(`${name} - (post-claim) startOnFull check: ${activeCount} active players, ${slots.length} unselected slots`);
        if (activeCount >= 2) {
          console.log(`${name} - attempting to start game (post-claim)`);
          send({ type: 'set', field: 'state', value: 'game-order' });
        } else {
          send({ type: 'get', fields: [ 'players', 'participants', 'unselected', 'state' ] });
        }
      } catch (err) {
        console.warn('post-claim start attempt failed', err);
      }
    }, 500);
  }
  return { color: slots[index], state: 'game-order' };
};

const processGameOrder = async (_received?: any): Promise<any> => {
  // The server uses the string 'unassigned' to indicate an open slot.
  // Previously the code used a falsy-check (!game.color), which fails
  // because 'unassigned' is truthy. Check explicitly for the sentinel.
  if (game.color === 'unassigned') {
    console.log(`game-order - player not active`);
    return { color: game.color };
  }
  console.log(`game-order - `, {
    color: game.color,
    players: game.players
  });
  if (!game.players[game.color].orderRoll || game.players[game.color].tied) {
    console.log(`Time to roll as ${game.color}`);
    send({ type: 'roll' });
  }

  return { turn: { color: game.color }};
};

const processInitialPlacement = async (_received?: any): Promise<any> => {
  /* Fetch the various game order elements so we can make
   * educated location selections */
  if (!game.pipOrder
    || !game.tileOrder
    || !game.borderOrder) {
    return {
      pipOrder: anyValue,
      tileOrder: anyValue,
      borderOrder: anyValue
    }
  }

  if (!game.turn || game.turn.color !== game.color) {
    return {
      turn: {
        color: game.color,
      }
    }
  };

  if (!game.placements) {
    return {
      turn: {
        color: game.color,
      },
      placements: anyValue
    };
  }

  if (!game.turn.actions) {
    return {
      turn: {
        color: game.color,
        actions: anyValue
      },
      placements: anyValue
    };
  }

  let index;
  const type = game.turn.actions[0];
  if (type === 'place-road') {
    index = bestRoadPlacement(game);
  } else if (type === 'place-settlement') {
    index = bestSettlementPlacement(game);
  }
  console.log(`Selecting ${type} at ${index}`);
  send({
    type, index
  });
  /* Wait for this player's turn again */
  return { turn: { color: game.color } };
}

/* Start watching for a name entry */
let waitingFor: any = { name: anyValue };
let received: any = {};

// Ignore TS6133 (declared but never used) 
// @ts-ignore
const reducedGame = (game: any): Record<string, any> => {
  const filters = [ 'chat', 'activities', 'placements', 'players', 'private', 'dice' ];
  const value: Record<string, any> = {};
  for (let key in game) {
    if (filters.indexOf(key) === -1) {
      value[key] = game[key];
    } else {
      if (Array.isArray((game as any)[key])) {
        value[key] = `length(${(game as any)[key].length})`;
      } else {
        value[key] = `...filtered`;
      }
    }
  }
  return value;
}

const processWaitingFor = (waitingFor: any): void => {
  const value: { type: string; fields: string[] } = { type: 'get', fields: [] };
  console.log(`${name} - waiting for: `, waitingFor);
  for (let key in waitingFor) {
    value.fields.push(key);
  }
  send(value);
  received = {};
}


const selectResources = async (_received: any): Promise<any> => {
  if (!game.turn) return { turn: anyValue };
  if (!game.turn.actions || game.turn.actions.indexOf('select-resources') === -1) return undefined;
  return undefined;
}

const processDiscard = async (_received?: any): Promise<any> => {
  if (!game.players) {
    waitingFor = {
      players: {}
    };
    waitingFor.players[game.color] = undefined;
    return waitingFor;
  }

  // The server may send the per-player `private` update with mustDiscard
  // before the aggregated `players` snapshot is delivered (sendUpdateToPlayer
  // is issued prior to sendUpdateToPlayers). Prefer the value found in
  // `game.players[...]` when present, otherwise fall back to `game.private`.
  let mustDiscard: any = undefined;
  if (game.players && game.players[game.color] && typeof game.players[game.color].mustDiscard !== 'undefined') {
    mustDiscard = game.players[game.color].mustDiscard;
  } else if (game.private && typeof game.private.mustDiscard !== 'undefined') {
    mustDiscard = game.private.mustDiscard;
  }

  // No discards required or information not present yet
  if (!mustDiscard) {
    return;
  }

  // Intelligent discard heuristic:
  // - Compute counts for each resource type from our private hand.
  // - Repeatedly discard from the type we have the most of (most-abundant first).
  // - Tie-breaker: prefer to discard sheep first, then brick/wood, then wheat/stone
  //   (sheep is typically least critical; wheat/stone useful for cities/devs).
  const discards: Record<string, number> = {};
  const counts: Record<string, number> = {};
  types.forEach(type => { counts[type] = Math.max(0, Number(game.private[type]) || 0); });

  let totalCards = types.reduce((s, t) => s + (counts[t] || 0), 0);
  if (totalCards === 0) {
    // nothing to discard (defensive)
    return;
  }

  const tieOrder = [ 'sheep', 'brick', 'wood', 'wheat', 'stone' ];
  while (mustDiscard-- > 0 && totalCards > 0) {
    // pick the resource with the highest count; tie-break using tieOrder
    let bestType: string | null = null;
    let bestCount = -1;
    for (const t of types) {
      const c = counts[t] || 0;
      if (c > bestCount) {
        bestCount = c;
        bestType = t;
      } else if (c === bestCount && c > 0 && bestType) {
        // tie-breaker by tieOrder index (lower index == prefer to discard)
        const curTie = tieOrder.indexOf(t);
        const bestTie = tieOrder.indexOf(bestType);
        if (curTie !== -1 && bestTie !== -1 && curTie < bestTie) {
          bestType = t;
        }
      }
    }

    if (!bestType || (counts[bestType] || 0) <= 0) {
      // no selectable resource left
      break;
    }

    discards[bestType] = (discards[bestType] || 0) + 1;
    counts[bestType] = (counts[bestType] || 0) - 1;
    totalCards--;
  }
  console.log(`discarding - `, discards);
  send({
    type: 'discard',
    discards
  });
  waitingFor = {
    turn: anyValue,
    players: {}
  }
  waitingFor.players[game.color] = anyValue;
  return waitingFor;
};

const processTrade = async (received?: any): Promise<any> => {
  const enough: string[] = [];
  let shouldTrade = true;

  /* Check and see which resources we have enough of */
  types.forEach(type => {
    if (game.private[type] >= 4) {
      enough.push(type);
    }
  });
  shouldTrade = enough.length > 0;

  let least: { type?: string | undefined; count: number } = { type: undefined, count: 0 };

  if (shouldTrade) {
    /* Find out which resource we have the least amount of */
    types.forEach(type => {
      if (game.private[type] <= least.count) {
        least.type = type;
        least.count = game.private[type];
      }
    });
    if (least.count >= 4) {
      shouldTrade = false;
    }
  }

  /* If trade not active, see if it should be... */
  if (shouldTrade 
    && (!received.turn.actions 
      || received.turn.actions.indexOf('trade') === -1)) {
    /* Request trade mode, and wait for it... */
    console.log(`${name} - starting trade negotiations`);
    send({
      type: 'trade'
    });
    return {
      turn: { actions: anyValue }
    }
  }
  
  /* If we do not have enough resources, and trade is active, cancel */
  if (!shouldTrade
    && received.turn.actions
    && received.turn.actions.indexOf('trade') !== -1) {
    console.log(`${name} - cancelling trade negotiations`);
    send({
      type: 'trade',
      action: 'cancel'
    });
    return {
      turn: anyValue
    };
  }

  if (!shouldTrade) {
    return;
  }

  const give = {
    type: enough[Math.floor(Math.random() * enough.length)],
    count: 4
  }, get = {
    type: least.type,
    count: 1
  };
  const offer = {
    gives: [give],
    gets: [get]
  };
  // Helper: can our private resources satisfy a list of required give items?
  const privateCanGive = (items: Array<any>): boolean => {
    if (!game.private) return false;
    for (let i = 0; i < (items || []).length; i++) {
      const it = items[i];
      if (!it) continue;
      if (it.type === 'bank') {
        // bank entries refer to a give type in our own player.gives; treat as not satisfiable here
        return false;
      }
      const have = Number(game.private[it.type] || 0);
      if (have < (it.count || 0)) return false;
    }
    return true;
  };

  // Helper: do two offers represent matching terms (p.gives == myGets && p.gets == myGives)
  const offersMatch = (p: any, myOffer: any): boolean => {
    if (!p || !myOffer) return false;
    // compare lengths first
    if ((p.gives || []).length !== (myOffer.gets || []).length) return false;
    if ((p.gets || []).length !== (myOffer.gives || []).length) return false;
    // each give in p must match a get in myOffer
    for (let i = 0; i < (p.gives || []).length; i++) {
      const g = p.gives[i];
      let found = false;
      for (let j = 0; j < (myOffer.gets || []).length; j++) {
        const mg = myOffer.gets[j];
        if (g.type === mg.type && g.count === mg.count) {
          found = true;
          break;
        }
      }
      if (!found) return false;
    }
    for (let i = 0; i < (p.gets || []).length; i++) {
      const g = p.gets[i];
      let found = false;
      for (let j = 0; j < (myOffer.gives || []).length; j++) {
        const mg = myOffer.gives[j];
        if (g.type === mg.type && g.count === mg.count) {
          found = true;
          break;
        }
      }
      if (!found) return false;
    }
    return true;
  };

  // If there's a current turn-level offer, decide how to respond.
  if (received && received.turn && received.turn.offer) {
    const turnOffer = received.turn.offer;

    // If the offer originates from another player (not the bank), then we (as the active player)
    // should accept if we can satisfy the terms, otherwise explicitly reject.
    if (turnOffer.name && turnOffer.name !== 'The bank') {
      // Active player accepting other player's offer must be able to give what that player requests (offer.gets)
      if (privateCanGive(turnOffer.gets || [])) {
        send({ type: 'trade', action: 'accept', offer: turnOffer });
        return { turn: { actions: anyValue } };
      } else {
        send({ type: 'trade', action: 'reject', offer: turnOffer });
        return { turn: anyValue };
      }
    }

    // If the current offer is a bank offer, wait to allow other players to respond
    // and prefer player trades over the bank.
    const TRADE_RESPONSE_DELAY_MS = 15000;
    await new Promise(r => setTimeout(r, TRADE_RESPONSE_DELAY_MS));

    // Find our own player entry in the received players (our proposed offer)
    const myEntry = (received.players && received.players[game.color]) || null;
    const myOffer = myEntry && (myEntry.gives || myEntry.gets) ? { gives: myEntry.gives, gets: myEntry.gets, name: myEntry.name, color: myEntry.color } : null;

    if (myOffer && myOffer.gives && myOffer.gives.length && myOffer.gets && myOffer.gets.length) {
      // Scan other players to see if any submitted a matching counter-offer. If found and they can meet it,
      // accept that player trade. If they can't meet it, explicitly reject their offer.
      for (const color in (received.players || {})) {
        if (!Object.prototype.hasOwnProperty.call(received.players, color)) continue;
        const p = received.players[color];
        if (!p || p.color === game.color) continue;
        if (p.status !== 'Active') continue;
        if (!p.gives || !p.gets) continue;
        if (!offersMatch(p, myOffer)) continue;

        // If the candidate player requests resources that we can provide, accept their offer.
        if (privateCanGive(p.gets || [])) {
          send({ type: 'trade', action: 'accept', offer: { name: p.name, color: p.color, gives: p.gives, gets: p.gets } });
          return { turn: { actions: anyValue } };
        } else {
          // Cannot meet their terms — explicitly reject so the server will know.
          send({ type: 'trade', action: 'reject', offer: { name: p.name, color: p.color, gives: p.gives, gets: p.gets } });
        }
      }
    }

    // If no player trades were accepted, fall back to the bank trade (if we can still give the required resources).
    if (privateCanGive([{ type: give.type, count: give.count }])) {
      send({
        type: 'trade',
        action: 'accept',
        offer: {
          name: 'The bank',
          gets: [{ type: get.type, count: 1 }],
          gives: [{ type: give.type, count: give.count }]
        }
      });
      return { turn: { actions: anyValue } };
    }

    // If we cannot satisfy the bank trade, do nothing this cycle.
    return { turn: anyValue };
  }

  /* Initiate offer... */

  if (!received || !received.turn || !received.turn.offer) {
    console.log(`trade - `, offer);
    send({
      type: 'trade',
      action: 'offer',
      offer
    });

    // Wait not only for our private.offerRejected flag but also for any players
    // updates (other players posting counter-offers or accept/rejects). This
    // reduces a race where the proposing AI proceeds before opponents have
    // had a chance to respond.
    return {
      private: { offerRejected: anyValue },
      players: anyValue,
      turn: { offer: anyValue }
    };
  }

  return {
    turn: anyValue
  };
}

const processVolcano = async (_received?: any): Promise<any> => {
  if (!game.turn || !game.private) {
    return {
      turn: anyValue,
      private: anyValue
    }
  };
  
  if (game.turn.actions 
    && game.turn.actions.indexOf('select-resources') !== -1) {
    console.log(`${name} - TODO - select resources -`, game.turn.select);
    return;
  }

  send({
    type: 'roll'
  });

  return {
    turn: anyValue
  };
};

const processNormal = async (received?: any): Promise<any> => {
  let waitingFor: any = undefined;
  let index: number | undefined;

  if (!game.turn || !game.private) {
    return {
      turn: anyValue,
      private: anyValue
    }
  };

  if (!game.pipOrder) {
    return {
      pipOrder: anyValue
    };
  }
  
  /* Process things that happen on everyone's turn */
  waitingFor = await processDiscard(received);
  if (waitingFor) {
    return waitingFor;
  }

  waitingFor = await selectResources(received);
  if (waitingFor) {
    return waitingFor;
  }

  /* From here on it is only actions that occur on the player's turn */
  if (!received.turn || received.turn.color !== game.color) {
    console.log(`${name} - waiting for turn... ${game.players && game.players[game.turn && game.turn.color] ? game.players[game.turn.color].name : 'unknown'} is active.`);
    console.log({
      wheat: game.private.wheat,
      sheep: game.private.sheep,
      stone: game.private.stone,
      brick: game.private.brick,
      wood: game.private.wood,
    });
    return {
      turn: { color: game.color },
      dice: anyValue
    };
  }

  // If the robber is active on our turn, ensure we have player information
  // only when a discard might be required.
  if (game.turn && game.turn.robberInAction) {
    if (!game.players) {
      console.log(`${name} - robber in action and players missing; requesting players`);
      return { players: anyValue };
    }

    const playerObj = game.players[game.color];
    const playerResources = (playerObj && typeof playerObj.resources !== 'undefined') ? Number(playerObj.resources) : undefined;
    const privateTotal = types.reduce((s, t) => s + (Number(game.private && game.private[t]) || 0), 0);
    const totalResources = (typeof playerResources !== 'undefined') ? playerResources : privateTotal;

    const playerMustDiscard = game.players[game.color] && typeof game.players[game.color].mustDiscard !== 'undefined' ? game.players[game.color].mustDiscard : undefined;
    if (typeof playerMustDiscard === 'undefined') {
      // If our total resources indicate a discard should be required (>7) but the
      // server hasn't provided the `mustDiscard` field yet, explicitly request
      // the players payload so we get the information. Otherwise, if total
      // resources are <=7, no discard is needed and we can proceed.
      if (totalResources > 7) {
        console.log(`${name} - robber in action but mustDiscard missing (totalResources=${totalResources}); requesting players`);
        return { players: anyValue };
      } else {
        console.log(`${name} - robber in action and no discard required (totalResources=${totalResources}); proceeding`);
      }
    }
  }

  console.log(`${name}'s turn. Processing...`);

  if (!game.dice) {
    console.log(`${name} - rolling...`);
    send({
      type: 'roll'
    });
    return {
      turn: { 
        color: game.color
      }, 
      dice: anyValue
    };
  }

  if (received.turn && received.turn.actions && received.turn.actions.indexOf('place-road') !== -1) {
    index = bestRoadPlacement(game);
    send({ type: 'place-road', index });
    return {
      turn: { color: game.color }
    };
  }

  if (game.turn.actions && game.turn.actions.indexOf('place-city') !== -1) {
    index = game.turn.limits.corners[Math.floor(Math.random() * game.turn.limits.corners.length)];
    send({ type: 'place-city', index });
    return {
      turn: { color: game.color }
    };
  }

  if (game.turn.actions && game.turn.actions.indexOf('place-settlement') !== -1) {
    const index = bestSettlementPlacement(game);
    send({
      type: 'place-settlement', index
    });
    return {
      turn: { color: game.color }
    };
  }

  if (game.turn.actions
    && game.turn.actions.indexOf('place-robber') !== -1) {
    console.log({ pips: game.turn.limits.pips });
    const index = game.turn.limits.pips[Math.floor(Math.random() * game.turn.limits.pips.length)];
    console.log(`placing robber - ${index}`)
    send({
      type: 'place-robber',
      index
    });
    return {
      turn: { color: game.color }
    };
  }

  if (game.turn.actions && game.turn.actions.indexOf('steal-resource') !== -1) {
    if (!game.turn.limits.players) {
      console.warn(`No players in limits with steal-resource`);
      return;
    }
    const { color } = game.turn.limits.players[Math.floor(Math.random() * game.turn.limits.players.length)];
    console.log(`stealing resouce from ${game.players[color].name}`);
    send({
      type: 'steal-resource',
      color
    });
    return;
  }

  if (game.turn.robberInAction) {
    console.log({ "turn.name": game.turn.name });
    return;
  } 

  console.log({
    wheat: game.private.wheat,
    sheep: game.private.sheep,
    stone: game.private.stone,
    brick: game.private.brick,
    wood: game.private.wood,
  });

  waitingFor = await tryBuild(received);
  if (waitingFor) {
    return waitingFor;
  }

  waitingFor = await tryProgress(received);
  if (waitingFor) {
    return waitingFor;
  }
  
  waitingFor = await processTrade(received);
  if (waitingFor) {
    return waitingFor;
  }

  console.log(`${name} - passing`);
  send({
    type: 'pass'
  });

  return { turn: anyValue };
};

const message = async (data: WebSocket.Data): Promise<void> => {
  let parsed: any = data;
  try {
    const text = typeof data === 'string' ? data : data.toString();
    parsed = JSON.parse(text);
  } catch (error) {
    console.error(error);
    console.log(data);
    return;
  }

  const msg: any = parsed;
  switch (msg.type) {
  case 'warning':
    if (game.turn && game.turn.color === game.color && game.state !== 'lobby') {
      console.log(`WARNING: ${msg.warning}.`);
      // If robber is in action or we may need to discard, do not blindly pass.
      if (game.turn.robberInAction) {
        console.log(`${name} - WARNING received while robber in action; ensuring players data`);
        // Request players so we learn mustDiscard fields and can act accordingly.
        waitingFor = { players: anyValue };
        processWaitingFor(waitingFor);
      } else {
        console.log(`${name} - not in robber action; passing.`);
        send({ type: 'pass' });
        waitingFor = { turn: { color: game.color } };
        processWaitingFor(waitingFor);
      }
    }
    break;

  case 'game-update':
    // Preserve previous snapshot to detect whether waited-for fields actually changed
    const oldGameSnapshot = deepClone(game);
    Object.assign(game, msg.update || {});
    // If the server just announced a winner, send a congratulatory chat
    // and then dismiss the winner dialog by navigating back to the lobby.
    if (msg.update && (msg.update.winner || msg.update.state === 'winner')) {
      // If we got the full winner payload, use it. Otherwise request it from the server.
      if (msg.update.winner) {
        const w = msg.update.winner as any;
        if (!winnerAnnounced) {
          let message = '';
          try {
            // If we won, be witty. Use turns/points if available.
            if (w.name === name) {
              const pts = w.points !== undefined ? w.points : 'many';
              const turns = w.turns !== undefined ? w.turns : 'a few';
              message = `Victory! I won with ${pts} points after ${turns} turns. Beep boop — humility.exe not found.`;
            } else {
              const pts = w.points !== undefined ? ` with ${w.points} points` : '';
              message = `Congratulations ${w.name}${pts}! Well played.`;
            }
          } catch (e) {
            message = `Congratulations ${w.name || 'winner'}!`;
          }
          send({ type: 'chat', message });
          // After a short pause show the lobby (dismiss winner dialog)
          setTimeout(() => {
            try {
              send({ type: 'goto-lobby' });
              // Immediately request lobby information so we can decide whether to
              // auto-start (startOnFull) as soon as the server transitions back
              // to the lobby. This avoids waiting for an unrelated update cycle.
              setTimeout(() => {
                try {
                  send({ type: 'get', fields: ['players', 'participants', 'unselected', 'state'] });
                } catch (err) {
                  console.warn('Failed to request lobby fields', err);
                }
              }, 250);
            } catch (err) {
              console.warn('Failed to send goto-lobby', err);
            }
          }, 1200);
          winnerAnnounced = true;
        }
      } else {
        // We were notified the state is 'winner' but didn't receive the winner object.
        // Request the winner field from the server. If it arrives shortly, the
        // handler above will process it. Otherwise fall back to a generic message
        // and return to the lobby.
        try {
          send({ type: 'get', fields: ['winner'] });
        } catch (err) {
          console.warn('Failed to request winner field', err);
        }

        // Fallback after a short wait if full winner info doesn't arrive.
        setTimeout(() => {
          if (winnerAnnounced) return;
          let msgText = 'Congratulations to the winner!';
          try {
            // If we have some partial info in game, try to include it
            if (game && game.turn && game.turn.name) {
              msgText = `Congratulations ${game.turn.name}!`;
            }
          } catch (e) {
            // ignore
          }
          send({ type: 'chat', message: msgText });
          try {
            send({ type: 'goto-lobby' });
            // Ask for lobby state so we can react quickly (claim slot / auto-start)
            setTimeout(() => {
              try {
                send({ type: 'get', fields: ['players', 'participants', 'unselected', 'state'] });
              } catch (err) {
                console.warn('Failed to request lobby fields (fallback)', err);
              }
            }, 250);
          } catch (err) {
            console.warn('Failed to send goto-lobby (fallback)', err);
          }
          winnerAnnounced = true;
        }, 2000);
      }
    }
      // If the server clears the winner/state, reset our announced flag so we can handle future games
      if (msg.update && typeof msg.update.state !== 'undefined' && msg.update.state !== 'winner') {
        winnerAnnounced = false;
      }
    if (msg.update && msg.update.chat) {
      let newState = paused;
      const rePause = new RegExp(`${name}: pause`, 'i');
      const reUnpause = new RegExp(`${name}: unpause`, 'i');
      for (let i = 0; i < msg.update.chat.length; i++) {
        if (msg.update.chat[i].message.match(rePause)) { newState = true; continue; }
        if (msg.update.chat[i].message.match(reUnpause)) { newState = false; continue; }
      }
      if (newState !== paused) { paused = newState; send({ type: 'chat', message: `Robot AI is now ${paused ? '' : 'un'}paused.` }); }
    }
    if (paused) { if (waitingFor) Object.assign(received, msg.update); return; }
    if (sleeping) { if (waitingFor) Object.assign(received, msg.update); console.log(`${name} - sleeping`); return; }

    if (waitingFor) {
      // Accumulate recent update fields for debugging
      Object.assign(received, msg.update);

      // First ensure presence in the merged snapshot
      if (!isMatch(waitingFor, game)) {
        if (game.turn && game.turn.robberInAction) { console.log(`${name} - robber in action! Must check discards...`); } else { return; }
      }

      // Now require that at least one of the waited-for leaf paths actually
      // changed in this update (or was explicitly provided in msg.update).
      // For leaves equal to `anyValue` we accept any change; for concrete
      // values we accept only if the new value equals the concrete value.
      const paths = collectLeafPaths(waitingFor);
      let changeDetected = false;
      for (const p of paths) {
        const oldVal = getAtPath(oldGameSnapshot, p);
        const newVal = getAtPath(game, p);

        // If the incremental update explicitly contained the path, treat that as a change.
        if (msg.update && msgUpdateContainsPath(msg.update, p)) {
          changeDetected = true;
          break;
        }

        // If old and new differ, that's a change
        if (!deepEqual(oldVal, newVal)) {
          changeDetected = true;
          break;
        }

        // If the waiting leaf expects a concrete value (not anyValue) and the
        // value already equals that concrete value, consider that satisfied
        // only if it changed to that value in this update (above checks cover it).
      }

      if (!changeDetected) {
        // No relevant change occurred in this update; continue waiting.
        return;
      }

      // We found a change and presence is satisfied
      const delay = 1000 + Math.random() * 500;
      console.log(`${name} - received match - received: `, msg.update);
      console.log(`${name} - going to sleep for ${Math.round(delay)}ms`);
      await sleep(delay);
      console.log(`${name} - waking up to wait for new socket messages...`);
      waitingFor = undefined;
    }

    switch (game.state) {
    case undefined:
    case 'lobby':
      waitingFor = await processLobby(received);
      if (waitingFor) processWaitingFor(waitingFor);
      return;
    case 'game-order':
      waitingFor = await processGameOrder(received);
      if (waitingFor) processWaitingFor(waitingFor);
      return;
    case 'initial-placement':
      waitingFor = await processInitialPlacement(received);
      if (waitingFor) processWaitingFor(waitingFor);
      return;
    case 'volcano':
      waitingFor = await processVolcano(received);
      if (waitingFor) processWaitingFor(waitingFor);
      return;
    case 'normal':
      waitingFor = await processNormal(received);
      if (waitingFor) processWaitingFor(waitingFor);
      return;
    }
    break;

  case 'ping':
    if (!game.state && !received.state) { console.log(`ping received with no game. Sending update request`); send({ type: 'game-update' }); }
    return;

  default:
    console.log(`Received: ${msg.type} while game state = ${game.state}`);
    break;
  }
}

const ai = async () => {
  send({
    type: 'get',
    fields: [ 'dice', 'name', 'color', 'state', 'placements' ]
  });
}

connect().then(() => {
  ai()
  .catch((error) => {
    console.error(error);
    if (wsRef) {
      try { wsRef.close(); } catch (_) { }
    }
  });
})
.catch((error) => {
  console.error(error);
});
