// Phase F-2-D follow-up: persistent world-name store.
//
// Background: world.id is the docker container suffix (e.g. `gold-arc-1454`)
// and is immutable. Operators want a separate human-friendly `name`
// (e.g. "Refactor the auth module") so the worlds list reads like a
// task board instead of a string of CSS-color-words.
//
// Storage: a single JSON file at /data/world-names.json (mounted from
// ~/.olam/world-names.json on the host). Atomic write via tmp+rename so
// concurrent PATCHes can't half-write the file. Read-on-demand with a
// tiny in-process cache keyed off mtime so steady-state GET /api/worlds
// doesn't reread the file every poll.
//
// Schema:
//   { "<worldId>": "<name>", ... }
//
// Names are arbitrary UTF-8 strings, capped at NAME_MAX_LEN to keep
// the file small + the UI sane.

import fs from 'node:fs';
import path from 'node:path';

const NAME_MAX_LEN = 120;

/**
 * @typedef {object} WorldNamesStore
 * @property {() => Record<string, string>} all
 * @property {(id: string) => string | null} get
 * @property {(id: string, name: string) => string} set
 * @property {(id: string) => void} remove
 */

/**
 * Create a JSON-backed world-names store rooted at `filePath`.
 * Resilient to a missing file (treats as empty); resilient to a
 * malformed file (logs + treats as empty).
 *
 * @param {string} filePath
 * @returns {WorldNamesStore}
 */
export function createWorldNamesStore(filePath) {
  /** @type {Record<string, string>} */
  let cache = {};
  let cacheMtimeMs = -1;

  function readFromDisk() {
    if (!fs.existsSync(filePath)) {
      cache = {};
      cacheMtimeMs = 0;
      return;
    }
    try {
      const stat = fs.statSync(filePath);
      if (stat.mtimeMs === cacheMtimeMs) return; // cache hit
      const raw = fs.readFileSync(filePath, 'utf-8');
      const parsed = JSON.parse(raw);
      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
        const next = {};
        for (const [k, v] of Object.entries(parsed)) {
          if (typeof v === 'string') next[k] = v;
        }
        cache = next;
      } else {
        cache = {};
      }
      cacheMtimeMs = stat.mtimeMs;
    } catch (err) {
      console.error(`world-names-store: failed to read ${filePath}: ${err.message}`);
      cache = {};
      cacheMtimeMs = 0;
    }
  }

  function writeToDisk() {
    const dir = path.dirname(filePath);
    fs.mkdirSync(dir, { recursive: true });
    const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
    fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), 'utf-8');
    fs.renameSync(tmp, filePath);
    try {
      const stat = fs.statSync(filePath);
      cacheMtimeMs = stat.mtimeMs;
    } catch {
      cacheMtimeMs = 0;
    }
  }

  /** @returns {Record<string, string>} */
  function all() {
    readFromDisk();
    return { ...cache };
  }

  /**
   * @param {string} id
   * @returns {string | null}
   */
  function get(id) {
    readFromDisk();
    return cache[id] ?? null;
  }

  /**
   * @param {string} id
   * @param {string} name
   * @returns {string} the normalized name actually stored
   */
  function set(id, name) {
    if (typeof id !== 'string' || id.length === 0) {
      throw new Error('worldId must be a non-empty string');
    }
    const normalized = normalizeName(name);
    if (normalized === null) {
      throw new Error('name must be a non-empty string (after trim)');
    }
    readFromDisk();
    cache = { ...cache, [id]: normalized };
    writeToDisk();
    return normalized;
  }

  /**
   * @param {string} id
   */
  function remove(id) {
    readFromDisk();
    if (!(id in cache)) return;
    const next = { ...cache };
    delete next[id];
    cache = next;
    writeToDisk();
  }

  return { all, get, set, remove };
}

/**
 * Normalize a name input. Trims, collapses internal whitespace, caps
 * length. Returns null for empty/whitespace-only input.
 *
 * @param {unknown} input
 * @returns {string | null}
 */
export function normalizeName(input) {
  if (typeof input !== 'string') return null;
  const trimmed = input.replace(/\s+/g, ' ').trim();
  if (trimmed.length === 0) return null;
  return trimmed.length > NAME_MAX_LEN
    ? trimmed.slice(0, NAME_MAX_LEN).trimEnd()
    : trimmed;
}

/**
 * Derive a human-friendly name from an initial task / dispatch text.
 * Takes the first sentence (split on `.`/`?`/`!`/newline), trims, caps
 * at ~60 chars at a word boundary so the UI doesn't truncate mid-word.
 * Returns null for empty input — caller falls back to id.
 *
 * @param {unknown} taskText
 * @returns {string | null}
 */
export function inferNameFromTask(taskText) {
  if (typeof taskText !== 'string') return null;
  const cleaned = taskText.replace(/\s+/g, ' ').trim();
  if (cleaned.length === 0) return null;
  // First sentence terminator wins; otherwise the whole string.
  const firstSentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? cleaned;
  const SOFT_CAP = 60;
  if (firstSentence.length <= SOFT_CAP) return firstSentence || null;
  // Cap at a word boundary close to SOFT_CAP so we don't dangle
  // half a word + an ellipsis.
  const head = firstSentence.slice(0, SOFT_CAP);
  const lastSpace = head.lastIndexOf(' ');
  const truncated = lastSpace > 30 ? head.slice(0, lastSpace) : head;
  return truncated.replace(/[\s,;:—–-]+$/u, '');
}
