// SPEC: PJ2026-010403 API契约 draft-2026-06-18-r1; PJ2026-010401 Web工作台 draft-2026-06-18-r1; PJ2026-0104010803 唯一投影 draft-2026-06-18-p0-unique-projection.
// Responsibility: Session-first Workbench UI helpers for composer state, tab projection, and local list stability.

import type { ChatMessage, ProjectionDiagnostic, ProviderProfile, WorkbenchSessionRecord } from "@/types";
import { firstNonEmptyString } from "@/utils";

export interface ComposerState {
  disabled: boolean;
  disabledReason: string | null;
  submitMode: "turn" | "steer";
  route: "/v1/agent/chat" | "/v1/agent/chat/steer";
  targetTraceId: string | null;
  sessionId: string | null;
  threadId: string | null;
}

export interface SessionTab extends WorkbenchSessionRecord {
  key: string;
  label: string;
  subtitle: string;
  active: boolean;
  running: boolean;
  projectionHealth?: string | null;
  projectionDiagnosticText?: string | null;
}

export interface SessionStatusAuthority {
  sessionId: string;
  status: string | null;
  updatedAt?: string | null;
  lastTraceId?: string | null;
  projection?: ProjectionDiagnostic | null;
  loadedAt?: string | null;
}

export type SessionStatusAuthorityMap = Readonly<Record<string, SessionStatusAuthority | undefined>>;

export interface TurnStatusAuthority {
  traceId: string;
  status: string | null;
  running: boolean;
  terminal: boolean;
  sessionId?: string | null;
  threadId?: string | null;
  updatedAt?: string | null;
  projection?: ProjectionDiagnostic | null;
  loadedAt?: string | null;
}

export type TurnStatusAuthorityMap = Readonly<Record<string, TurnStatusAuthority | undefined>>;

export interface DraftEntry {
  text: string;
  ts: string;
}

export interface ProviderProfileOption {
  value: ProviderProfile;
  label: string;
  configured?: boolean;
}

export const RECENT_DRAFTS_STORAGE_KEY = "hwlab.workbench.recentDrafts.v1";
export const RECENT_DRAFTS_LIMIT = 8;
const OBJECT_OBJECT_TEXT = "[object Object]";
const GENERIC_USER_TITLE_TEXT = "用户";

const BUILTIN_PROVIDER_PROFILE_ORDER = Object.freeze(["deepseek", "dsflash-go", "codex-api", "minimax-m3"]);
const BUILTIN_PROVIDER_PROFILE_LABELS: Readonly<Record<string, string>> = Object.freeze({
  deepseek: "DeepSeek",
  "dsflash-go": "DeepSeek V4 Flash",
  "codex-api": "Codex API",
  "minimax-m3": "MiniMax-M3"
});

export function resolveComposerState(input: { messages: ChatMessage[]; sessions?: WorkbenchSessionRecord[]; activeSessionId: string | null; chatPending: boolean; currentRequest?: { traceId?: string | null; sessionId?: string | null; threadId?: string | null; status?: string | null } | null; turnStatusAuthority?: TurnStatusAuthorityMap }): ComposerState {
  const sessionId = firstNonEmptyString(input.activeSessionId);
  const latestMessage = latestSessionMessage(input.messages, sessionId);
  const currentRequest = input.currentRequest && input.currentRequest.sessionId === sessionId ? input.currentRequest : null;
  const latestTraceId = firstNonEmptyString(latestMessage?.traceId, latestMessage?.runnerTrace?.traceId);
  const activeTraceId = firstNonEmptyString(currentRequest?.traceId, latestTraceId);
  const turn = activeTraceId ? input.turnStatusAuthority?.[activeTraceId] : null;
  const activeByRequest = Boolean(input.chatPending && currentRequest && isActiveStatus(currentRequest.status ?? "running"));
  const activeByMessage = latestMessage?.role === "agent" && isActiveStatus(latestMessage.status);
  const terminalByMessage = latestMessage?.role === "agent" && isTerminalStatus(latestMessage.status);
  const activeByStatus = !terminalByMessage && (turn?.running === true || isActiveStatus(turn?.status) || activeByRequest || activeByMessage);
  const terminal = terminalByMessage || turn?.terminal === true || isTerminalStatus(turn?.status);
  const active = activeSession(input.sessions ?? [], sessionId);
  const effectiveSessionId = firstNonEmptyString(turn?.sessionId, currentRequest?.sessionId, active?.sessionId, sessionId);
  const threadId = firstNonEmptyString(turn?.threadId, currentRequest?.threadId, active?.threadId);
  const canSteer = Boolean(effectiveSessionId && activeTraceId && activeByStatus && !terminal);
  if (canSteer) return { disabled: false, disabledReason: null, submitMode: "steer", route: "/v1/agent/chat/steer", targetTraceId: activeTraceId, sessionId: effectiveSessionId, threadId };
  if (!effectiveSessionId) return { disabled: true, disabledReason: "session_required", submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, sessionId: null, threadId };
  return { disabled: false, disabledReason: null, submitMode: "turn", route: "/v1/agent/chat", targetTraceId: null, sessionId: effectiveSessionId, threadId };
}

export function resolveCancelableAgentMessage(input: { messages: ChatMessage[]; targetTraceId?: string | null; targetSessionId?: string | null; targetThreadId?: string | null; turnStatusAuthority?: TurnStatusAuthorityMap }): ChatMessage | null {
  const targetTraceId = firstNonEmptyString(input.targetTraceId);
  if (!targetTraceId) return null;
  const turn = input.turnStatusAuthority?.[targetTraceId];
  if (turn?.running !== true && !isActiveStatus(turn?.status)) return null;
  for (const message of [...input.messages].reverse()) {
    if (message.role !== "agent") continue;
    if (firstNonEmptyString(message.traceId, message.runnerTrace?.traceId) !== targetTraceId) continue;
    if (!messageBelongsToCancelTarget(message, input)) continue;
    if (isTerminalStatus(message.status)) continue;
    return message;
  }
  return null;
}

export function shouldShowSessionListLoading(input: { loading: boolean; sessionsReady: boolean }): boolean {
  return input.loading && !input.sessionsReady;
}

export function sessionToSessionTab(session: WorkbenchSessionRecord, activeSessionId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab {
  const sessionId = session.sessionId;
  const updatedAt = sessionDisplayUpdatedAt(session);
  const status = resolveSessionTabStatus(session, sessionStatusAuthority[sessionId]);
  const projection = projectionFromSession(session, sessionStatusAuthority[sessionId]);
  const projectionText = projectionDiagnosticText(projection);
  const trace = firstNonEmptyString(latestAgentMessage(session.messages)?.traceId, session.messages?.at(-1)?.traceId, session.lastTraceId);
  const userMessage = session.messages?.find((message) => message.role === "user");
  const running = isRunningIndicatorStatus(status);
  const preview = firstReadableSentence(
    session.firstUserMessagePreview,
    session.snapshot?.firstUserMessagePreview,
    session.userPreview,
    userMessage?.text,
    userMessage?.content,
    session.title,
    session.name
  );
  return {
    ...session,
    key: sessionId,
    label: preview ?? `Session ${shortToken(sessionId, 8)}`,
    subtitle: `${projectionText ? "状态更新异常" : running ? "处理中" : trace ? `trace ${shortToken(trace, 8)}` : status === "unknown" ? "状态同步中" : "未开始"} · ${session.messageCount ?? session.messages?.length ?? 0} 条`,
    lastTraceId: trace,
    status,
    active: sessionId === activeSessionId,
    running,
    projection: projection ?? null,
    projectionHealth: projection?.projectionHealth ?? null,
    projectionDiagnosticText: projectionText,
    updatedAt
  };
}

export function normalizeWorkbenchMessageTitle(role: ChatMessage["role"], title: unknown): string {
  const fallback = role === "user" ? "用户" : "Code Agent";
  const value = firstNonEmptyString(title) ?? fallback;
  const text = value.replace(/\s+/gu, " ").trim();
  if (role === "agent" && /^Code Agent\s*(?:处理中|running)$/iu.test(text)) return "Code Agent";
  return text || fallback;
}

export function normalizeChatMessageStatus(value: unknown): ChatMessage["status"] {
  const status = normalizeSessionStatus(value);
  if (status === "sent") return "sent";
  if (isActiveStatus(status)) return "running";
  if (status === "completed") return "completed";
  if (status === "blocked") return "blocked";
  if (status === "timeout") return "timeout";
  if (status === "canceled") return "canceled";
  if (["failed", "stale", "thread-resume-failed", "interrupted", "expired"].includes(status ?? "")) return "failed";
  return "pending";
}

export function sortSessionTabs(sessions: WorkbenchSessionRecord[], activeSessionId: string | null, sessionStatusAuthority: SessionStatusAuthorityMap = {}): SessionTab[] {
  return sessions.filter((session) => !isArchivedSession(session)).map((session) => sessionToSessionTab(session, activeSessionId, sessionStatusAuthority)).sort((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt));
}

export function stableSessionList(current: WorkbenchSessionRecord[], candidate: unknown, includeSessionId: string | null | undefined, selectedSession?: WorkbenchSessionRecord | null): WorkbenchSessionRecord[] {
  const selected = selectedSession && !isArchivedSession(selectedSession) ? selectedSession : null;
  if (!Array.isArray(candidate)) return selected ? mergeSessionIntoList(current, selected) : current.filter((session) => !isArchivedSession(session));
  const includeId = firstNonEmptyString(includeSessionId);
  const candidateList = (candidate as WorkbenchSessionRecord[]).filter((session) => !isArchivedSession(session));
  const stable = !includeId || candidateList.some((session) => session.sessionId === includeId)
    ? candidateList
    : current.length > 0
      ? current
      : candidateList;
  return selected ? mergeSelectedSessionDetail(stable, selected) : stable.filter((session) => !isArchivedSession(session));
}

export function appendSessionPage(current: WorkbenchSessionRecord[], candidate: unknown): WorkbenchSessionRecord[] {
  if (!Array.isArray(candidate)) return current.filter((session) => !isArchivedSession(session));
  const next = current.filter((session) => !isArchivedSession(session));
  for (const session of (candidate as WorkbenchSessionRecord[]).filter((item) => !isArchivedSession(item))) {
    const sessionId = firstNonEmptyString(session.sessionId);
    if (!sessionId) continue;
    const existingIndex = next.findIndex((item) => item.sessionId === sessionId);
    const existing = existingIndex >= 0 ? next[existingIndex] : null;
    if (existing) {
      next[existingIndex] = mergeSessionRecord(existing, session);
    } else {
      next.push(session);
    }
  }
  return next;
}

export function mergeSessionIntoList(current: WorkbenchSessionRecord[], session: WorkbenchSessionRecord | null | undefined): WorkbenchSessionRecord[] {
  const sessionId = firstNonEmptyString(session?.sessionId);
  if (!sessionId || !session) return current;
  if (isArchivedSession(session)) return current.filter((item) => item.sessionId !== sessionId);
  const existingIndex = current.findIndex((item) => item.sessionId === sessionId);
  if (existingIndex < 0) return [session, ...current];
  return current.map((item, index) => index === existingIndex ? mergeSessionRecord(item, session) : item);
}

export function isArchivedSession(session: WorkbenchSessionRecord | null | undefined): boolean {
  return normalizeSessionStatus(session?.status) === "archived";
}

export function recordRecentDraft(existing: DraftEntry[], text: string, now = new Date().toISOString()): DraftEntry[] {
  const trimmed = text.trim();
  if (!trimmed) return existing;
  return [{ text: trimmed, ts: now }, ...existing.filter((item) => item.text !== trimmed)].slice(0, RECENT_DRAFTS_LIMIT);
}

export function normalizeRecentDrafts(value: unknown): DraftEntry[] {
  if (!Array.isArray(value)) return [];
  return value.filter((item): item is DraftEntry => Boolean(item) && typeof item === "object" && typeof (item as DraftEntry).text === "string" && typeof (item as DraftEntry).ts === "string").slice(0, RECENT_DRAFTS_LIMIT);
}

export function providerProfileLabel(profile: ProviderProfile): string {
  return BUILTIN_PROVIDER_PROFILE_LABELS[String(profile)] ?? String(profile);
}

export function providerProfileOptionsFromPayload(payload: unknown, selectedProfile: ProviderProfile): ProviderProfileOption[] {
  const record = payload && typeof payload === "object" ? payload as { profiles?: unknown; items?: unknown; data?: { profiles?: unknown; items?: unknown } } : {};
  const raw = Array.isArray(record.profiles) ? record.profiles : Array.isArray(record.items) ? record.items : Array.isArray(record.data?.profiles) ? record.data.profiles : Array.isArray(record.data?.items) ? record.data.items : [];
  const parsed = raw.map((item) => profileOptionFromUnknown(item)).filter((item): item is ProviderProfileOption => Boolean(item));
  return ensureSelectedProviderProfile(parsed.length > 0 ? parsed.sort(compareProviderProfileOptions) : defaultProviderProfileOptions(selectedProfile), selectedProfile);
}

export function defaultProviderProfileOptions(selectedProfile: ProviderProfile): ProviderProfileOption[] {
  return ensureSelectedProviderProfile(BUILTIN_PROVIDER_PROFILE_ORDER.map((profile) => ({ value: profile, label: providerProfileLabel(profile) })), selectedProfile);
}

function activeSession(sessions: WorkbenchSessionRecord[], sessionId: string | null): WorkbenchSessionRecord | null {
  return sessionId ? sessions.find((item) => item.sessionId === sessionId) ?? null : null;
}

function mergeSessionRecord(existing: WorkbenchSessionRecord, incoming: WorkbenchSessionRecord): WorkbenchSessionRecord {
  const incomingMessages = Array.isArray(incoming.messages) ? incoming.messages : [];
  const existingMessages = Array.isArray(existing.messages) ? existing.messages : [];
  return {
    ...existing,
    ...incoming,
    sessionId: firstNonEmptyString(incoming.sessionId, existing.sessionId) ?? existing.sessionId,
    threadId: firstNonEmptyString(incoming.threadId, existing.threadId),
    status: firstNonEmptyString(incoming.status, existing.status),
    updatedAt: firstNonEmptyString(incoming.updatedAt, existing.updatedAt),
    lastUserMessageAt: firstNonEmptyString(incoming.lastUserMessageAt, existing.lastUserMessageAt),
    startedAt: firstNonEmptyString(incoming.startedAt, existing.startedAt),
    lastTraceId: firstNonEmptyString(incoming.lastTraceId, existing.lastTraceId),
    firstUserMessagePreview: firstNonEmptyString(incoming.firstUserMessagePreview, existing.firstUserMessagePreview),
    title: firstNonEmptyString(incoming.title, existing.title),
    name: firstNonEmptyString(incoming.name, existing.name),
    userPreview: firstNonEmptyString(incoming.userPreview, existing.userPreview),
    messageCount: incoming.messageCount ?? existing.messageCount ?? (incomingMessages.length || existingMessages.length || undefined),
    messages: incomingMessages.length > 0 ? incomingMessages : existing.messages
  };
}

function mergeSelectedSessionDetail(stable: WorkbenchSessionRecord[], selected: WorkbenchSessionRecord): WorkbenchSessionRecord[] {
  const selectedId = firstNonEmptyString(selected.sessionId);
  if (!selectedId) return stable.filter((session) => !isArchivedSession(session));
  const existingIndex = stable.findIndex((session) => session.sessionId === selectedId);
  if (existingIndex < 0) return mergeSessionIntoList(stable, selected);
  return stable.map((session, index) => index === existingIndex ? mergeSessionRecord(selected, session) : session).filter((session) => !isArchivedSession(session));
}

function latestAgentMessage(messages: ChatMessage[] | undefined): ChatMessage | null {
  return [...(messages ?? [])].reverse().find((message) => message.role === "agent") ?? null;
}

function resolveSessionTabStatus(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): string {
  void session;
  const authorityStatus = normalizeSessionStatus(authority?.status);
  return authorityStatus ?? "unknown";
}

function projectionFromSession(session: WorkbenchSessionRecord, authority: SessionStatusAuthority | null | undefined): ProjectionDiagnostic | null {
  const direct = recordValue(authority?.projection) ?? recordValue(session.projection);
  if (direct) return direct as ProjectionDiagnostic;
  const turnSummary = recordValue(session.turnSummary);
  const turnProjection = recordValue(turnSummary?.projection);
  if (turnProjection) return turnProjection as ProjectionDiagnostic;
  const blocker = recordValue(session.blocker) ?? recordValue(turnSummary?.blocker);
  const projectionHealth = firstNonEmptyString(authority?.projection?.projectionHealth, session.projectionHealth, turnSummary?.projectionHealth);
  const projectionStatus = firstNonEmptyString(authority?.projection?.projectionStatus, session.projectionStatus, turnSummary?.projectionStatus);
  if (!projectionHealth && !projectionStatus && !blocker) return null;
  return { projectionHealth, projectionStatus, blocker } as ProjectionDiagnostic;
}

function projectionDiagnosticText(projection: ProjectionDiagnostic | null): string | null {
  if (!projection || projection.projectionHealth === "caught-up") return null;
  return firstNonEmptyString(projection.blocker?.userMessage, projection.blocker?.message, projection.blocker?.summary, projection.blocker?.code ? `状态更新异常：${projection.blocker.code}` : null);
}

function recordValue(value: unknown): Record<string, unknown> | null {
  return value && typeof value === "object" ? value as Record<string, unknown> : null;
}

function messageBelongsToCancelTarget(message: ChatMessage, input: { targetSessionId?: string | null; targetThreadId?: string | null }): boolean {
  const targetSessionId = firstNonEmptyString(input.targetSessionId);
  const targetThreadId = firstNonEmptyString(input.targetThreadId);
  const messageSessionId = firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId);
  const messageThreadId = firstNonEmptyString(message.threadId, message.runnerTrace?.threadId);
  if (!targetSessionId && !targetThreadId) return true;
  return Boolean((targetSessionId && messageSessionId === targetSessionId) || (targetThreadId && messageThreadId === targetThreadId));
}

function latestSessionMessage(messages: ChatMessage[], activeSessionId: string | null): ChatMessage | null {
  for (const message of [...messages].reverse()) {
    const messageSessionId = firstNonEmptyString(message.sessionId, message.runnerTrace?.sessionId);
    if (activeSessionId && messageSessionId && messageSessionId !== activeSessionId) continue;
    return message;
  }
  return null;
}

function isActiveStatus(value: unknown): boolean {
  const status = normalizeSessionStatus(value);
  return ["active", "running", "retrying", "pending", "queued", "accepted", "dispatching", "streaming", "processing", "busy", "creating"].includes(status ?? "");
}

function isRunningIndicatorStatus(value: unknown): boolean {
  const status = normalizeSessionStatus(value);
  return ["running", "retrying", "pending", "queued", "accepted", "dispatching", "streaming", "processing", "busy"].includes(status ?? "");
}

function isTerminalStatus(value: unknown): boolean {
  return ["completed", "failed", "blocked", "timeout", "canceled", "stale", "thread-resume-failed", "interrupted", "expired"].includes(normalizeSessionStatus(value) ?? "");
}

function normalizeSessionStatus(value: unknown): string | null {
  const text = firstNonEmptyString(value);
  if (!text) return null;
  const normalized = text.trim().toLowerCase().replace(/_/gu, "-");
  if (normalized === "cancelled") return "canceled";
  if (normalized === "in-flight") return "running";
  if (normalized === "error") return "failed";
  return normalized;
}

function profileOptionFromUnknown(item: unknown): ProviderProfileOption | null {
  if (!item || typeof item !== "object") return null;
  const record = item as Record<string, unknown>;
  const value = firstNonEmptyString(record.profile, record.id, record.name, record.value);
  if (!value) return null;
  return { value, label: firstNonEmptyString(record.label, record.displayName, record.title) ?? providerProfileLabel(value), configured: typeof record.configured === "boolean" ? record.configured : undefined };
}

function ensureSelectedProviderProfile(options: ProviderProfileOption[], selectedProfile: ProviderProfile): ProviderProfileOption[] {
  const selected = firstNonEmptyString(selectedProfile);
  const unique = uniqueProviderOptions(options);
  if (!selected || unique.some((option) => option.value === selected)) return unique;
  return uniqueProviderOptions([...unique, { value: selected, label: providerProfileLabel(selected) }]);
}

function uniqueProviderOptions(options: ProviderProfileOption[]): ProviderProfileOption[] {
  const seen = new Set<string>();
  const result: ProviderProfileOption[] = [];
  for (const option of options) {
    const value = firstNonEmptyString(option.value);
    if (!value || seen.has(value)) continue;
    seen.add(value);
    result.push({ ...option, value });
  }
  return result;
}

function compareProviderProfileOptions(left: ProviderProfileOption, right: ProviderProfileOption): number {
  const leftIndex = BUILTIN_PROVIDER_PROFILE_ORDER.indexOf(left.value);
  const rightIndex = BUILTIN_PROVIDER_PROFILE_ORDER.indexOf(right.value);
  if (leftIndex >= 0 || rightIndex >= 0) {
    if (leftIndex < 0) return 1;
    if (rightIndex < 0) return -1;
    if (leftIndex !== rightIndex) return leftIndex - rightIndex;
  }
  return left.value.localeCompare(right.value);
}

function firstReadableSentence(...values: unknown[]): string | null {
  for (const value of values) {
    const text = readableSentence(value);
    if (text) return text;
  }
  return null;
}

export function sessionDisplayUpdatedAt(session: WorkbenchSessionRecord): string | null {
  return firstNonEmptyString(
    session.lastUserMessageAt,
    session.snapshot?.lastUserMessageAt,
    latestUserMessageAtFromMessages(session.messages ?? []),
    session.startedAt,
    session.updatedAt,
    session.snapshot?.updatedAt
  ) ?? null;
}

export function latestUserMessageAtFromMessages(messages: ChatMessage[] | undefined): string | null {
  const userMessageTimes = (messages ?? [])
    .filter((message) => message.role === "user")
    .flatMap((message) => [message.createdAt, message.updatedAt]);
  return latestTimestamp(...userMessageTimes);
}

function latestTimestamp(...values: unknown[]): string | null {
  let latest: { value: string; ms: number } | null = null;
  for (const value of values) {
    const text = firstNonEmptyString(value);
    if (!text) continue;
    const ms = timestampMs(text);
    if (!ms) continue;
    if (!latest || ms > latest.ms) latest = { value: text, ms };
  }
  return latest?.value ?? firstNonEmptyString(...values) ?? null;
}

function readableSentence(value: unknown): string | null {
  if (typeof value === "string") return firstSentenceFromText(value);
  if (typeof value === "number" || typeof value === "boolean") return String(value);
  if (Array.isArray(value)) return firstReadableSentence(...value);
  if (!value || typeof value !== "object") return null;
  const record = value as Record<string, unknown>;
  return firstReadableSentence(
    record.text,
    record.content,
    record.message,
    record.summary,
    record.preview,
    record.prompt,
    record.title,
    record.name,
    record.markdown
  );
}

function firstSentenceFromText(value: string): string | null {
  const normalized = value.replace(/\u0000/gu, " ").replace(/\r\n|\r/gu, "\n").replace(/[\t\f\v ]+/gu, " ").trim();
  if (!normalized || normalized === OBJECT_OBJECT_TEXT || normalized === GENERIC_USER_TITLE_TEXT) return null;
  const candidates = [
    sentenceBoundary(normalized, /[。！？!?]/u, 1),
    sentenceBoundary(normalized, /\.(?=\s|$)/u, 1),
    sentenceBoundary(normalized, /\n+/u, 0)
  ].filter((index): index is number => typeof index === "number");
  const end = candidates.length > 0 ? Math.min(...candidates) : normalized.length;
  const text = normalized.slice(0, end).replace(/\s+/gu, " ").trim();
  return text && text !== OBJECT_OBJECT_TEXT && text !== GENERIC_USER_TITLE_TEXT ? text : null;
}

function sentenceBoundary(value: string, pattern: RegExp, offset: number): number | null {
  const match = pattern.exec(value);
  return match?.index === undefined ? null : match.index + offset;
}

function shortToken(value: string | null | undefined, size: number): string {
  const text = String(value ?? "");
  if (text.length <= size) return text;
  return `${text.slice(0, size)}...`;
}

function timestampMs(value: unknown): number {
  const text = firstNonEmptyString(value);
  if (!text) return 0;
  const parsed = Date.parse(text);
  return Number.isFinite(parsed) ? parsed : 0;
}
