const SUMMARY_PATTERN = /^botStatus slots=(\d{1,2}) entities=(\d{1,2}) active=(\d{1,2})$/;
const NAVIGATION_PATTERN = /^botNavigation client=(\d{1,2}) health=(-?\d{1,6}) spectating=([01]) respawning=([01]) teleporting=([01]) spawnAt=(-?\d{1,10}) minRespawnTime=(-?\d{1,10}) area=(\d{1,10}) origin=(-?\d+\.\d{3}),(-?\d+\.\d{3}),(-?\d+\.\d{3}) forward=(-?\d{1,3}) right=(-?\d{1,3})$/;

const MAX_BLOCK_BYTES = 256 * 1024;
const MAX_LINE_BYTES = 4096;
const MAX_WORLD_COORDINATE = 10_000_000;

export const BOT_MOTION_MIN_XY = 64;
export const BOT_MOTION_TELEPORT_XY = 512;
export const BOT_MOTION_INPUT_MIN = 16;

function parseInteger(value, label, minimum, maximum) {
  const parsed = Number(value);
  if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) {
    throw new Error(`invalid ${label}: ${value}`);
  }
  return parsed;
}

function parseCoordinate(value, label) {
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || Math.abs(parsed) > MAX_WORLD_COORDINATE) {
    throw new Error(`invalid ${label}: ${value}`);
  }
  return parsed;
}

export function parseBotStatusBlock(block) {
  if (typeof block !== 'string' || Buffer.byteLength(block, 'utf8') > MAX_BLOCK_BYTES) {
    throw new Error('bot status block is missing or too large');
  }

  let summary = null;
  const bots = new Map();
  for (const rawLine of block.replaceAll('\r', '').split('\n')) {
    if (Buffer.byteLength(rawLine, 'utf8') > MAX_LINE_BYTES) {
      throw new Error('bot status line is too large');
    }
    const line = rawLine.trim();
    if (line.startsWith('botStatus ')) {
      if (summary) throw new Error('duplicate botStatus summary');
      const match = SUMMARY_PATTERN.exec(line);
      if (!match) throw new Error(`malformed botStatus summary: ${line}`);
      summary = Object.freeze({
        slots: parseInteger(match[1], 'bot slots', 0, 32),
        entities: parseInteger(match[2], 'bot entities', 0, 32),
        active: parseInteger(match[3], 'active clients', 0, 32),
      });
      continue;
    }
    if (!line.startsWith('botNavigation ')) continue;

    const match = NAVIGATION_PATTERN.exec(line);
    if (!match) throw new Error(`malformed botNavigation line: ${line}`);
    const client = parseInteger(match[1], 'bot client', 0, 31);
    if (bots.has(client)) throw new Error(`duplicate botNavigation client: ${client}`);
    bots.set(client, Object.freeze({
      client,
      health: parseInteger(match[2], 'bot health', -100_000, 100_000),
      spectating: match[3] === '1',
      respawning: match[4] === '1',
      teleporting: match[5] === '1',
      spawnAt: parseInteger(match[6], 'bot spawnAt', -2_147_483_648, 2_147_483_647),
      minRespawnTime: parseInteger(match[7], 'bot minRespawnTime', -2_147_483_648, 2_147_483_647),
      area: parseInteger(match[8], 'bot area', 0, 2_147_483_647),
      origin: Object.freeze([
        parseCoordinate(match[9], 'bot origin x'),
        parseCoordinate(match[10], 'bot origin y'),
        parseCoordinate(match[11], 'bot origin z'),
      ]),
      forward: parseInteger(match[12], 'bot forward input', -127, 127),
      right: parseInteger(match[13], 'bot right input', -127, 127),
    }));
  }

  if (!summary) throw new Error('botStatus summary is missing');
  if (bots.size !== summary.entities) {
    throw new Error(`botNavigation count ${bots.size} differs from entity count ${summary.entities}`);
  }
  return Object.freeze({ summary, bots });
}

function distanceXY(left, right) {
  return Math.hypot(left[0] - right[0], left[1] - right[1]);
}

export class BotMotionGate {
  constructor({
    minimumDistanceXY = BOT_MOTION_MIN_XY,
    teleportDistanceXY = BOT_MOTION_TELEPORT_XY,
    minimumInput = BOT_MOTION_INPUT_MIN,
  } = {}) {
    if (!(minimumDistanceXY > 0) ||
        !(teleportDistanceXY > minimumDistanceXY) ||
        !(minimumInput >= 0 && minimumInput <= 254)) {
      throw new Error('invalid bot motion gate thresholds');
    }
    this.minimumDistanceXY = minimumDistanceXY;
    this.teleportDistanceXY = teleportDistanceXY;
    this.minimumInput = minimumInput;
    this.state = null;
    this.resets = 0;
    this.lastResetReason = 'not-started';
  }

  #clear(reason) {
    this.state = null;
    this.resets += 1;
    this.lastResetReason = reason;
  }

  #start(sample) {
    this.state = {
      client: sample.client,
      spawnAt: sample.spawnAt,
      minRespawnTime: sample.minRespawnTime,
      anchor: sample.origin,
      last: sample.origin,
      maxDistanceXY: 0,
      commandSeen: Math.abs(sample.forward) + Math.abs(sample.right) >= this.minimumInput,
      samples: 1,
    };
  }

  observe(sample) {
    if (!sample || !Number.isInteger(sample.client) || !Array.isArray(sample.origin)) {
      throw new Error('invalid bot motion sample');
    }
    if (sample.health <= 0 || sample.spectating || sample.respawning || sample.area <= 0) {
      this.#clear('inactive-or-respawning');
      return null;
    }
    if (sample.teleporting) {
      this.#clear('teleporting');
      return null;
    }

    if (!this.state) {
      this.#start(sample);
      return null;
    }
    if (sample.client !== this.state.client ||
        sample.spawnAt !== this.state.spawnAt ||
        sample.minRespawnTime !== this.state.minRespawnTime) {
      this.#clear('spawn-generation-changed');
      this.#start(sample);
      return null;
    }

    const stepDistanceXY = distanceXY(sample.origin, this.state.last);
    if (stepDistanceXY > this.teleportDistanceXY) {
      this.#clear('teleport-discontinuity');
      this.#start(sample);
      return null;
    }

    this.state.last = sample.origin;
    this.state.samples += 1;
    this.state.commandSeen ||= Math.abs(sample.forward) + Math.abs(sample.right) >= this.minimumInput;
    this.state.maxDistanceXY = Math.max(
      this.state.maxDistanceXY,
      distanceXY(sample.origin, this.state.anchor),
    );
    if (!this.state.commandSeen || this.state.maxDistanceXY < this.minimumDistanceXY) return null;

    return Object.freeze({
      client: sample.client,
      area: sample.area,
      distanceXY: this.state.maxDistanceXY,
      samples: this.state.samples,
      resets: this.resets,
    });
  }

  describe() {
    return Object.freeze({
      minimumDistanceXY: this.minimumDistanceXY,
      resets: this.resets,
      lastResetReason: this.lastResetReason,
      samples: this.state?.samples ?? 0,
      maxDistanceXY: this.state?.maxDistanceXY ?? 0,
      commandSeen: this.state?.commandSeen ?? false,
    });
  }
}
