// Copyright (c) 2026 Hellmai Ltd
// SPDX-License-Identifier: AGPL-3.0-only

/**
 * Session Schema (WU-2754, ADR-014 extension 2)
 *
 * Canonical schema for agent session records persisted under
 * `.lumenflow/sessions/<WU>.json` and the `current.json` compatibility pointer.
 *
 * ADR-014 extension 2 adds three optional axes:
 *   - `lifecycle_role`     — what phase of delivery the session owns
 *                             (opaque string at the kernel layer; pack owns vocabulary)
 *   - `specialty_profile`  — which skill bundle the agent self-identifies as carrying
 *   - `delegation_id`      — reference back to the parent delegation registry record
 *                             (dlg-XXXX) so `delegation:list` can cross-display session
 *                             axes alongside requested_role / actual_role.
 *
 * Schema-version discipline:
 *   - v1 legacy records have NO `schemaVersion` field and may omit the three
 *     new axes entirely. Readers MUST accept them without error.
 *   - v2 records stamp `schemaVersion: 'v2'`. New writes default to v2 and
 *     populate `lifecycle_role` + `specialty_profile` (sensible pack-layer
 *     defaults supplied by callers). Kernel-agnostic: the schema itself
 *     carries opaque strings; the pack (@lumenflow/packs/software-delivery)
 *     owns the canonical vocabulary.
 *
 * Reference: docs/09-architecture-decisions/ADR-014-typed-agent-role-contract.md §181
 */

import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { parse as parseYAML } from 'yaml';
import { z } from 'zod';

/**
 * Session schema version discriminator.
 *
 * v1 = legacy (no stamp). v2 = ADR-014 extension 2 (may carry role axes).
 */
export const SESSION_SCHEMA_VERSION_V2 = 'v2' as const;

/** Known session schema versions (extensible by additive pack updates). */
export const SESSION_SCHEMA_VERSIONS = [SESSION_SCHEMA_VERSION_V2] as const;

/** Type union for session schema version values. */
export type SessionSchemaVersion = (typeof SESSION_SCHEMA_VERSIONS)[number];

/**
 * Default ADR-014 axes applied when a v2 session is written without explicit
 * caller-supplied values. Kernel-agnostic defaults — the pack may override via
 * `resolveDefaultRoleContract` (CLI delegation-role-resolver.ts).
 */
export const DEFAULT_SESSION_LIFECYCLE_ROLE = 'executor';
export const DEFAULT_SESSION_SPECIALTY_PROFILE = 'general';
export const DEFAULT_DISPLAY_NAME_POOL = [
  'Planck',
  'Hegel',
  'Curie',
  'Noether',
  'Turing',
  'Ada',
  'Gauss',
  'Euler',
  'Raman',
  'Hopper',
  'Lovelace',
  'Shannon',
] as const;
export const DISPLAY_NAME_POOL_RELATIVE_PATH = '.lumenflow/agents/display-name-pool.yaml';
export const DISPLAY_NAME_RESERVATIONS_RELATIVE_PATH =
  '.lumenflow/state/display-name-reservations.jsonl';
export const DISPLAY_NAME_RESERVATION_TTL_HOURS = 12;

/** Regex for delegation_id references back into delegation registry. */
export const DELEGATION_ID_PATTERN = /^dlg-[0-9a-f]{4}$/;

/**
 * Session role axes — the three ADR-014 extension 2 fields that bind a live
 * session to its delegation parent.
 */
export const SessionRoleAxesSchema = z.object({
  lifecycle_role: z.string().min(1, { message: 'lifecycle_role must be non-empty' }).optional(),
  specialty_profile: z
    .string()
    .min(1, { message: 'specialty_profile must be non-empty' })
    .optional(),
  display_name: z.string().min(1, { message: 'display_name must be non-empty' }).optional(),
  delegation_id: z
    .string()
    .regex(DELEGATION_ID_PATTERN, {
      message: 'delegation_id must match dlg-XXXX (lowercase hex)',
    })
    .optional(),
});

/**
 * Base session schema — shared fields across v1 and v2.
 *
 * Intentionally permissive: existing on-disk records have grown organically
 * (lane, incidents, tiers, etc.). This schema focuses on the ADR-014 axes
 * and only asserts the minimum invariants required for cross-linkage.
 */
const SessionRecordBaseSchema = z.object({
  session_id: z.string().min(1),
  wu_id: z.string().min(1),
  started: z.string().min(1),
});

/**
 * v2 session record — ADR-014 axes are optional at the schema level because
 * existing callers may not populate them yet. However, the auto-integration
 * layer defaults them on every new v2 write (see auto-session-integration.ts).
 */
const SessionRecordV2Schema = SessionRecordBaseSchema.extend({
  schemaVersion: z.literal(SESSION_SCHEMA_VERSION_V2),
  lifecycle_role: z.string().min(1).optional(),
  specialty_profile: z.string().min(1).optional(),
  display_name: z.string().min(1).optional(),
  delegation_id: z
    .string()
    .regex(DELEGATION_ID_PATTERN, {
      message: 'delegation_id must match dlg-XXXX (lowercase hex)',
    })
    .optional(),
});

/**
 * v1-compat session record — no `schemaVersion` stamp. ADR-014 axes may be
 * present (lenient) but are not required.
 */
const SessionRecordV1Schema = SessionRecordBaseSchema.extend({
  schemaVersion: z.undefined().optional(),
  lifecycle_role: z.string().min(1).optional(),
  specialty_profile: z.string().min(1).optional(),
  display_name: z.string().min(1).optional(),
  delegation_id: z.string().optional(),
});

/**
 * Discriminated union — both v1 and v2 records are accepted on read.
 */
export const SessionRecordSchema = z.union([SessionRecordV2Schema, SessionRecordV1Schema]);

export type SessionRecordV1 = z.infer<typeof SessionRecordV1Schema>;
export type SessionRecordV2 = z.infer<typeof SessionRecordV2Schema>;
export type SessionRecord = z.infer<typeof SessionRecordSchema>;
export type SessionRoleAxes = z.infer<typeof SessionRoleAxesSchema>;

/**
 * Validates a session record against the v1/v2 discriminated union.
 * Accepts both legacy and v2 records; callers that want strict v2 validation
 * should check `isV2SessionRecord` after validation.
 */
export function validateSessionRecord(data: unknown) {
  return SessionRecordSchema.safeParse(data);
}

/**
 * Type guard: returns true if the session record carries the v2 stamp.
 */
export function isV2SessionRecord(record: SessionRecord): record is SessionRecordV2 {
  return record.schemaVersion === SESSION_SCHEMA_VERSION_V2;
}

/**
 * Apply ADR-014 v2 defaults to a partial role-axes input. Kernel-agnostic:
 * string values passed through as-is; the pack vocabulary (lifecycle_role +
 * specialty_profile) is owned by CLI callers.
 */
export function withV2RoleDefaults(input: {
  lifecycle_role?: string | null;
  specialty_profile?: string | null;
  display_name?: string | null;
  delegation_id?: string | null;
}): {
  lifecycle_role: string;
  specialty_profile: string;
  display_name?: string;
  delegation_id?: string;
} {
  const lifecycle_role =
    typeof input.lifecycle_role === 'string' && input.lifecycle_role.trim().length > 0
      ? input.lifecycle_role.trim()
      : DEFAULT_SESSION_LIFECYCLE_ROLE;
  const specialty_profile =
    typeof input.specialty_profile === 'string' && input.specialty_profile.trim().length > 0
      ? input.specialty_profile.trim()
      : DEFAULT_SESSION_SPECIALTY_PROFILE;
  const delegation_id =
    typeof input.delegation_id === 'string' && DELEGATION_ID_PATTERN.test(input.delegation_id)
      ? input.delegation_id
      : undefined;
  const display_name =
    typeof input.display_name === 'string' && input.display_name.trim().length > 0
      ? input.display_name.trim()
      : undefined;
  const out: {
    lifecycle_role: string;
    specialty_profile: string;
    display_name?: string;
    delegation_id?: string;
  } = {
    lifecycle_role,
    specialty_profile,
  };
  if (display_name) {
    out.display_name = display_name;
  }
  if (delegation_id) {
    out.delegation_id = delegation_id;
  }
  return out;
}

interface DisplayNameReservation {
  display_name: string;
  session_id: string;
  acquired_at: string;
}

interface DisplayNamePoolDocument {
  names?: string[];
  reservations?: DisplayNameReservation[];
  ttl_hours?: number;
}

function getDisplayNamePoolPath(workspaceRoot: string): string {
  return path.join(workspaceRoot, DISPLAY_NAME_POOL_RELATIVE_PATH);
}

function getDisplayNameReservationsPath(workspaceRoot: string): string {
  return path.join(workspaceRoot, DISPLAY_NAME_RESERVATIONS_RELATIVE_PATH);
}

function getDisplayNameReservationTtlMs(ttlHours: number | undefined): number {
  const hours =
    typeof ttlHours === 'number' && Number.isFinite(ttlHours) && ttlHours > 0
      ? ttlHours
      : DISPLAY_NAME_RESERVATION_TTL_HOURS;
  return hours * 60 * 60 * 1000;
}

function normalizeDisplayNamePoolDocument(raw: unknown): DisplayNamePoolDocument {
  if (typeof raw !== 'object' || raw === null) {
    return {
      names: [...DEFAULT_DISPLAY_NAME_POOL],
      reservations: [],
      ttl_hours: DISPLAY_NAME_RESERVATION_TTL_HOURS,
    };
  }

  const candidate = raw as DisplayNamePoolDocument;
  return {
    names: Array.isArray(candidate.names)
      ? candidate.names.filter(
          (entry): entry is string => typeof entry === 'string' && entry.length > 0,
        )
      : [...DEFAULT_DISPLAY_NAME_POOL],
    reservations: Array.isArray(candidate.reservations)
      ? candidate.reservations.filter(
          (entry): entry is DisplayNameReservation =>
            typeof entry?.display_name === 'string' &&
            entry.display_name.length > 0 &&
            typeof entry?.session_id === 'string' &&
            entry.session_id.length > 0 &&
            typeof entry?.acquired_at === 'string' &&
            entry.acquired_at.length > 0,
        )
      : [],
    ttl_hours:
      typeof candidate.ttl_hours === 'number' && Number.isFinite(candidate.ttl_hours)
        ? candidate.ttl_hours
        : DISPLAY_NAME_RESERVATION_TTL_HOURS,
  };
}

function readDisplayNamePoolDocument(workspaceRoot: string): DisplayNamePoolDocument {
  const poolPath = getDisplayNamePoolPath(workspaceRoot);
  if (!existsSync(poolPath)) {
    return {
      names: [...DEFAULT_DISPLAY_NAME_POOL],
    };
  }

  try {
    return normalizeDisplayNamePoolDocument(
      parseYAML(readFileSync(poolPath, { encoding: 'utf-8' })),
    );
  } catch {
    return {
      names: [...DEFAULT_DISPLAY_NAME_POOL],
    };
  }
}

function readDisplayNameReservations(workspaceRoot: string): DisplayNameReservation[] {
  const reservationsPath = getDisplayNameReservationsPath(workspaceRoot);
  if (!existsSync(reservationsPath)) {
    return [];
  }

  const raw = readFileSync(reservationsPath, { encoding: 'utf-8' }).trim();
  if (raw.length === 0) {
    return [];
  }

  return raw
    .split('\n')
    .map((line) => {
      try {
        return JSON.parse(line) as DisplayNameReservation;
      } catch {
        return null;
      }
    })
    .filter(
      (entry): entry is DisplayNameReservation =>
        entry !== null &&
        typeof entry.display_name === 'string' &&
        entry.display_name.length > 0 &&
        typeof entry.session_id === 'string' &&
        entry.session_id.length > 0 &&
        typeof entry.acquired_at === 'string' &&
        entry.acquired_at.length > 0,
    );
}

function writeDisplayNameReservations(
  workspaceRoot: string,
  reservations: DisplayNameReservation[],
): void {
  const reservationsPath = getDisplayNameReservationsPath(workspaceRoot);
  if (reservations.length === 0) {
    rmSync(reservationsPath, { force: true });
    return;
  }

  mkdirSync(path.dirname(reservationsPath), { recursive: true });
  writeFileSync(
    reservationsPath,
    `${reservations.map((reservation) => JSON.stringify(reservation)).join('\n')}\n`,
    { encoding: 'utf-8' },
  );
}

function loadDisplayNameState(
  workspaceRoot: string,
  now: Date,
): {
  names: string[];
  reservations: DisplayNameReservation[];
} {
  const document = readDisplayNamePoolDocument(workspaceRoot);
  const names =
    document.names && document.names.length > 0 ? document.names : [...DEFAULT_DISPLAY_NAME_POOL];
  const ttlMs = getDisplayNameReservationTtlMs(document.ttl_hours);
  const reservations = pruneExpiredReservations(
    [...readDisplayNameReservations(workspaceRoot), ...(document.reservations ?? [])],
    now,
    ttlMs,
  );

  const uniqueReservations = new Map<string, DisplayNameReservation>();
  for (const reservation of reservations) {
    uniqueReservations.set(reservation.session_id, reservation);
  }

  writeDisplayNameReservations(workspaceRoot, [...uniqueReservations.values()]);

  return {
    names,
    reservations: [...uniqueReservations.values()],
  };
}

function pruneExpiredReservations(
  reservations: DisplayNameReservation[],
  now: Date,
  ttlMs: number,
): DisplayNameReservation[] {
  return reservations.filter((reservation) => {
    const acquiredAt = Date.parse(reservation.acquired_at);
    if (Number.isNaN(acquiredAt)) {
      return false;
    }
    return now.getTime() - acquiredAt < ttlMs;
  });
}

export function acquireDisplayName(
  workspaceRoot: string,
  sessionId: string,
  now: Date = new Date(),
): string {
  const state = loadDisplayNameState(workspaceRoot, now);
  const reservations = state.reservations.filter(
    (reservation) => reservation.session_id !== sessionId,
  );
  const reservedNames = new Set(reservations.map((reservation) => reservation.display_name));
  const selectedName = state.names.find((name) => !reservedNames.has(name)) ?? sessionId;

  reservations.push({
    display_name: selectedName,
    session_id: sessionId,
    acquired_at: now.toISOString(),
  });

  writeDisplayNameReservations(workspaceRoot, reservations);

  return selectedName;
}

export function releaseDisplayName(
  workspaceRoot: string,
  sessionId: string,
  now: Date = new Date(),
): void {
  const state = loadDisplayNameState(workspaceRoot, now);
  const reservations = state.reservations.filter(
    (reservation) => reservation.session_id !== sessionId,
  );
  writeDisplayNameReservations(workspaceRoot, reservations);
}
