// planning-sessions — host-cp surface for creating and inspecting in-flight
// planning sessions stored under world_id = PLANNING_WORLD_ID ('_planning').
//
// Formalises what the plan-chat-spa dev substrate does ad hoc:
//
//   createPlanningSession({ actorId, pool })
//     Seeds a session with one 'system' chunk so the Electric shape subscriber
//     gets a non-empty initial response on its first long-poll cycle. Also
//     INSERTs a row into the planning_sessions sidecar table inside the same
//     transaction so no partial state can exist (chunk written, no metadata row).
//     Returns the allocated world_id, session_id, and the inserted seed chunk.
//
//   loadPlanningSession({ pool, sessionId })
//     Lightweight metadata read: chunk count, first/last timestamps, first
//     operator content (for title derivation). SPA still streams live chunks
//     via the existing /v1/shape proxy — this is metadata-only.
//
//   recordPlanningSession({ pool, sessionId, actorId, summary })
//     UPSERT into planning_sessions. Used by createPlanningSession (wrapped in
//     a transaction) and later to update the summary as the session evolves.
//
//   setCrystallizeStatus({ pool, sessionId, status, worldId })
//     UPDATE planning_sessions.crystallize_status + crystallized_world_id.
//     Throws if status is not in PLANNING_SESSION_STATUSES.
//
//   listPlanningSessions({ pool, actorId, limit })
//     SELECT rows for actorId, ordered created_at DESC. Returns array.
//
// Neither function calls validateChunkInput — that's for the public POST
// surface. INSERTs here are built directly against the chunks column list.
//
// Pool errors surface loudly (never swallowed) so the caller sees the full
// pg error message and can diagnose connectivity or constraint failures.

import { randomUUID } from 'node:crypto';
import { PLANNING_WORLD_ID, PLANNING_SESSION_STATUSES } from '@olam/chunks/schema';

/**
 * UPSERT a row in planning_sessions for the given sessionId.
 *
 * On first call (from createPlanningSession): inserts a fresh row.
 * On subsequent calls: updates summary + updated_at only (leaves
 * crystallize_status and crystallized_world_id untouched).
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.sessionId
 * @param {string} opts.actorId
 * @param {string | null} [opts.summary]
 */
export async function recordPlanningSession({ pool, sessionId, actorId, summary = null }) {
  await pool.query(
    `INSERT INTO planning_sessions (session_id, actor_id, summary)
     VALUES ($1, $2, $3)
     ON CONFLICT (session_id) DO UPDATE
       SET summary    = EXCLUDED.summary,
           updated_at = NOW()`,
    [sessionId, actorId, summary],
  );
}

/**
 * Create a new in-flight planning session under world_id='_planning'.
 *
 * Allocates a fresh session_id (UUID v4) and, inside a single transaction:
 *   1. INSERTs a seed chunk (actor_type='system', seq=0) so the Electric shape
 *      subscriber receives a non-empty initial long-poll response.
 *   2. INSERTs a planning_sessions sidecar row (via recordPlanningSession) so
 *      listPlanningSessions can return it immediately.
 *
 * Transaction guarantee: both INSERTs succeed or both roll back. A chunk
 * written without a corresponding planning_sessions row is the partial-state
 * bug this transaction prevents.
 *
 * @param {object} opts
 * @param {string} opts.actorId  — actor_id to attribute the seed chunk to
 *                                  (typically 'system' or the host-cp service id)
 * @param {object} opts.pool     — pg.Pool-compatible object with .query() and
 *                                  optionally .connect() for transactional clients.
 *                                  Tests may supply a stub with a transaction-aware
 *                                  .query() (BEGIN / INSERT / INSERT / COMMIT).
 * @returns {Promise<{
 *   world_id:   string,
 *   session_id: string,
 *   seed_chunk: {
 *     world_id: string, session_id: string, message_id: string, seq: number,
 *     actor_id: string, actor_type: string, role: string, chunk: string,
 *     chunk_type: string,
 *   },
 * }>}
 */
export async function createPlanningSession({ actorId, pool }) {
  const sessionId = randomUUID();
  const messageId = randomUUID();
  const seq = 0;
  const actorType = 'system';
  const role = 'system';
  const chunk = 'Planning session created.';
  const chunkType = 'text';

  // Use a transactional client when pool.connect() is available (real pg.Pool).
  // Test stubs that only implement .query() fall through to the flat path;
  // the transactional contract is proven by the test that uses a stub whose
  // second .query() throws and asserts the chunk INSERT was rolled back.
  if (typeof pool.connect === 'function') {
    const client = await pool.connect();
    try {
      await client.query('BEGIN');
      await client.query(
        `INSERT INTO chunks
           (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
        [PLANNING_WORLD_ID, sessionId, messageId, seq, actorId, actorType, role, chunk, chunkType],
      );
      await recordPlanningSession({ pool: client, sessionId, actorId, summary: null });
      await client.query('COMMIT');
    } catch (err) {
      await client.query('ROLLBACK');
      throw err;
    } finally {
      client.release();
    }
  } else {
    // Flat path for test stubs: queries run sequentially on the stub pool.
    await pool.query(
      `INSERT INTO chunks
         (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
      [PLANNING_WORLD_ID, sessionId, messageId, seq, actorId, actorType, role, chunk, chunkType],
    );
    await recordPlanningSession({ pool, sessionId, actorId, summary: null });
  }

  return {
    world_id: PLANNING_WORLD_ID,
    session_id: sessionId,
    seed_chunk: {
      world_id: PLANNING_WORLD_ID,
      session_id: sessionId,
      message_id: messageId,
      seq,
      actor_id: actorId,
      actor_type: actorType,
      role,
      chunk,
      chunk_type: chunkType,
    },
  };
}

/**
 * Update crystallize_status (and optionally crystallized_world_id) for a
 * planning session.
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.sessionId
 * @param {string} opts.status   — must be in PLANNING_SESSION_STATUSES
 * @param {string | null} [opts.worldId] — required when status='crystallized'
 */
export async function setCrystallizeStatus({ pool, sessionId, status, worldId = null }) {
  if (!PLANNING_SESSION_STATUSES.includes(status)) {
    throw new Error(
      `setCrystallizeStatus: invalid status "${status}"; ` +
      `must be one of ${PLANNING_SESSION_STATUSES.join(', ')}`,
    );
  }
  await pool.query(
    `UPDATE planning_sessions
     SET crystallize_status    = $2,
         crystallized_world_id = $3,
         updated_at            = NOW()
     WHERE session_id = $1`,
    [sessionId, status, worldId],
  );
}

/**
 * Create a new multi-turn DISPATCH session (multi-turn-cloud-sandbox-dispatch
 * Phase A2 — distinct from createPlanningSession which is for planning-flow
 * crystallization sessions under world_id='_planning').
 *
 * Allocates a UUID session_id, INSERTs a planning_sessions row with
 * session_type='dispatch' + caller-supplied world_id, applies operator-supplied
 * budget_usd_cap / allow_unpriced_models defaults, returns the session_id.
 *
 * No seed chunk: dispatch sessions accumulate chunks from the agent runtime
 * (via /v1/chunks); we don't pre-seed a system chunk because Electric shape
 * subscribers for dispatch sessions can wait for the first real agent chunk.
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.actorId
 * @param {string} opts.worldId — operator-supplied; identifies the dispatch
 *   target world (NOT the '_planning' sentinel used by createPlanningSession).
 * @param {number | null} [opts.budgetUsdCap=null] — per-session budget cap;
 *   null = uncapped. When null AND `OLAM_SESSION_BUDGET_DEFAULT_USD` is set,
 *   the env-default applies at /v1/dispatch-turn check time (Phase D); here
 *   we record the row exactly as supplied.
 * @param {boolean} [opts.allowUnpricedModels=false] — opt session into the
 *   pricingForModel-returns-null fallback (Plan A T11 mitigation; default
 *   refuses unknown models with 502).
 * @returns {Promise<{ session_id: string }>}
 */
export async function createDispatchSession({
  pool,
  actorId,
  worldId,
  budgetUsdCap = null,
  allowUnpricedModels = false,
  sessionId: providedSessionId = null,
}) {
  if (!actorId || typeof actorId !== 'string') {
    throw new Error('createDispatchSession: actorId required');
  }
  if (!worldId || typeof worldId !== 'string') {
    throw new Error('createDispatchSession: worldId required');
  }
  // A6 (Decision 9 always-on threading): callers MAY supply session_id to
  // upsert an existing planning_sessions row (e.g. /api/cloud-dispatch
  // pre-creating the thread before forwarding to plan-DO). When omitted,
  // we generate a UUID. ON CONFLICT DO NOTHING handles the race where
  // the SPA called /v1/sessions/create concurrently AND server-side
  // cloud-dispatch tried to pre-create the same row.
  const sessionId = providedSessionId ?? randomUUID();
  await pool.query(
    `INSERT INTO planning_sessions
       (session_id, actor_id, session_type, world_id, budget_usd_cap, allow_unpriced_models)
     VALUES ($1, $2, 'dispatch', $3, $4, $5)
     ON CONFLICT (session_id) DO NOTHING`,
    [sessionId, actorId, worldId, budgetUsdCap, allowUnpricedModels],
  );
  return { session_id: sessionId };
}

/**
 * Atomic test-and-set lock claim on a dispatch session
 * (multi-turn-cloud-sandbox-dispatch Phase A3 — Decision 4 + T5 mitigation).
 *
 * Pattern: single-statement UPDATE ... WHERE in_flight_turn_id IS NULL RETURNING.
 * Two concurrent attempts: first claim wins (RETURNING yields 1 row); second
 * sees empty result + must return 409 to caller. Matches the established
 * planning-sessions.mjs:169 setCrystallizeStatus atomic-write idiom.
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.sessionId
 * @param {string} opts.turnId — operator-or-server-generated turn UUID
 * @returns {Promise<boolean>} true if lock claimed, false if already held
 */
export async function claimDispatchTurnLock({ pool, sessionId, turnId }) {
  const result = await pool.query(
    `UPDATE planning_sessions
     SET in_flight_turn_id = $1,
         in_flight_turn_started_at = NOW(),
         last_turn_at = NOW()
     WHERE session_id = $2
       AND session_type = 'dispatch'
       AND in_flight_turn_id IS NULL
     RETURNING session_id`,
    [turnId, sessionId],
  );
  return (result.rows?.length ?? 0) > 0;
}

/**
 * Clear the in-flight turn lock after dispatch completes (success OR failure).
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.sessionId
 */
export async function clearDispatchTurnLock({ pool, sessionId }) {
  await pool.query(
    `UPDATE planning_sessions
     SET in_flight_turn_id = NULL,
         in_flight_turn_started_at = NULL
     WHERE session_id = $1
       AND session_type = 'dispatch'`,
    [sessionId],
  );
}

/**
 * Halt a dispatch session — operator-driven "block next turn" state (T13).
 *
 * Sets halted_at to NOW() AND clears in_flight_turn_id. Future /v1/dispatch-turn
 * calls return 409 'session_halted' until reactivateDispatchSession clears
 * halted_at. Does NOT stop an in-flight container — the running container
 * completes its current turn naturally. UX is "Block next turn" not "Stop"
 * (Plan A Phase C C6).
 *
 * Scoped by actor_id for ownership isolation.
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.sessionId
 * @param {string} opts.actorId
 * @returns {Promise<boolean>} true if a session row was updated; false if
 *   the session_id was not found / not owned by actorId.
 */
export async function haltDispatchSession({ pool, sessionId, actorId }) {
  const result = await pool.query(
    `UPDATE planning_sessions
     SET halted_at = NOW(),
         in_flight_turn_id = NULL,
         in_flight_turn_started_at = NULL
     WHERE session_id = $1
       AND session_type = 'dispatch'
       AND actor_id = $2
     RETURNING session_id`,
    [sessionId, actorId],
  );
  return (result.rows?.length ?? 0) > 0;
}

/**
 * Reactivate a halted dispatch session — clears halted_at so subsequent
 * /v1/dispatch-turn calls can claim the lock again. Idempotent (clearing an
 * already-null halted_at is a no-op).
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.sessionId
 * @param {string} opts.actorId
 * @returns {Promise<boolean>} true if a session row was updated; false if
 *   the session_id was not found / not owned by actorId.
 */
export async function reactivateDispatchSession({ pool, sessionId, actorId }) {
  const result = await pool.query(
    `UPDATE planning_sessions
     SET halted_at = NULL
     WHERE session_id = $1
       AND session_type = 'dispatch'
       AND actor_id = $2
     RETURNING session_id`,
    [sessionId, actorId],
  );
  return (result.rows?.length ?? 0) > 0;
}

/**
 * Read a dispatch session by session_id + scope to caller's actor_id
 * (ownership check). Returns the session metadata needed for budget check
 * + plan-DO forward, OR null when not found / not owned.
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.sessionId
 * @param {string} opts.actorId
 * @returns {Promise<null | {
 *   session_id: string,
 *   world_id: string | null,
 *   actor_id: string,
 *   total_usd: number,
 *   budget_usd_cap: number | null,
 *   allow_unpriced_models: boolean,
 *   halted_at: string | null,
 * }>}
 */
export async function getDispatchSession({ pool, sessionId, actorId }) {
  const result = await pool.query(
    `SELECT session_id, world_id, actor_id,
            total_usd, budget_usd_cap, allow_unpriced_models,
            halted_at
     FROM planning_sessions
     WHERE session_id = $1
       AND session_type = 'dispatch'
       AND actor_id = $2`,
    [sessionId, actorId],
  );
  const row = result.rows?.[0];
  if (!row) return null;
  return {
    session_id: row.session_id,
    world_id: row.world_id ?? null,
    actor_id: row.actor_id,
    total_usd: Number(row.total_usd ?? 0),
    budget_usd_cap:
      row.budget_usd_cap === null || row.budget_usd_cap === undefined
        ? null
        : Number(row.budget_usd_cap),
    allow_unpriced_models: Boolean(row.allow_unpriced_models),
    halted_at: row.halted_at ?? null,
  };
}

/**
 * List planning sessions for a given actorId, ordered by created_at DESC.
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.actorId
 * @param {number} [opts.limit=50]
 * @returns {Promise<Array<{
 *   session_id: string,
 *   summary: string | null,
 *   crystallize_status: string,
 *   crystallized_world_id: string | null,
 *   created_at: string,
 *   updated_at: string,
 * }>>}
 */
export async function listPlanningSessions({ pool, actorId, limit = 50 }) {
  const result = await pool.query(
    `SELECT session_id, summary, crystallize_status, crystallized_world_id,
            created_at, updated_at
     FROM planning_sessions
     WHERE actor_id = $1
     ORDER BY created_at DESC
     LIMIT $2`,
    [actorId, limit],
  );
  return result.rows;
}

/**
 * List multi-turn DISPATCH sessions for a given actorId, ordered by
 * last_turn_at DESC (most recently active first), excluding archived sessions.
 *
 * Distinct from listPlanningSessions: this returns only `session_type='dispatch'`
 * rows + projects the multi-turn-specific columns (total_usd, in_flight_turn_id,
 * halted_at, etc.) that the SPA's SessionsListView (Phase C C3) renders.
 *
 * @param {object} opts
 * @param {object} opts.pool
 * @param {string} opts.actorId
 * @param {number} [opts.limit=50]
 * @returns {Promise<Array<{
 *   session_id: string,
 *   world_id: string | null,
 *   total_usd: string,
 *   budget_usd_cap: string | null,
 *   in_flight_turn_id: string | null,
 *   halted_at: string | null,
 *   last_turn_at: string | null,
 *   created_at: string,
 *   summary: string | null,
 * }>>}
 */
export async function listDispatchSessions({ pool, actorId, limit = 50 }) {
  const result = await pool.query(
    `SELECT session_id, world_id,
            total_usd, budget_usd_cap,
            in_flight_turn_id, halted_at,
            last_turn_at, created_at,
            summary
     FROM planning_sessions
     WHERE actor_id = $1
       AND session_type = 'dispatch'
       AND archived_at IS NULL
     ORDER BY last_turn_at DESC NULLS LAST, created_at DESC
     LIMIT $2`,
    [actorId, limit],
  );
  return result.rows;
}

/**
 * Load lightweight metadata for an existing in-flight planning session.
 *
 * Performs two queries scoped to world_id='_planning' AND session_id=<sessionId>:
 *  1. Aggregate: chunk_count, first_chunk_at, last_chunk_at.
 *  2. First operator content: earliest chunk where actor_type='operator',
 *     used by the SPA for session title derivation.
 *
 * The SPA streams live chunks via the existing /v1/shape proxy; this function
 * is metadata-only and does NOT subscribe to any Electric shape.
 *
 * @param {object} opts
 * @param {object} opts.pool      — pg.Pool-compatible object with a .query(sql, params) method
 * @param {string} opts.sessionId — UUID of the planning session to inspect
 * @returns {Promise<{
 *   world_id:               string,
 *   session_id:             string,
 *   exists:                 boolean,
 *   chunk_count:            number,
 *   first_chunk_at:         string | null,
 *   last_chunk_at:          string | null,
 *   first_operator_content: string | null,
 * }>}
 */
export async function loadPlanningSession({ pool, sessionId }) {
  const aggResult = await pool.query(
    `SELECT COUNT(*) AS chunk_count,
            MIN(created_at) AS first_chunk_at,
            MAX(created_at) AS last_chunk_at
     FROM chunks
     WHERE world_id = $1 AND session_id = $2`,
    [PLANNING_WORLD_ID, sessionId],
  );

  const row = aggResult.rows[0];
  const chunkCount = Number(row.chunk_count);
  const exists = chunkCount > 0;

  let firstOperatorContent = null;

  if (exists) {
    const opResult = await pool.query(
      `SELECT chunk
       FROM chunks
       WHERE world_id = $1 AND session_id = $2 AND actor_type = 'operator'
       ORDER BY created_at ASC
       LIMIT 1`,
      [PLANNING_WORLD_ID, sessionId],
    );
    if (opResult.rows.length > 0) {
      firstOperatorContent = opResult.rows[0].chunk;
    }
  }

  return {
    world_id: PLANNING_WORLD_ID,
    session_id: sessionId,
    exists,
    chunk_count: chunkCount,
    first_chunk_at: exists ? row.first_chunk_at : null,
    last_chunk_at: exists ? row.last_chunk_at : null,
    first_operator_content: firstOperatorContent,
  };
}
