// 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],
  );
}

/**
 * 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;
}

/**
 * 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,
  };
}
