import { randomUUID, createHash } from "node:crypto";
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import type {
  ChatMessage,
  ProjectConfirmationPolicy,
  ProjectPackageConfig,
  ProjectPackageEntry,
  ProjectPackageEntryType,
  ProjectRole,
  ProjectSessionState,
  ProjectSessionSummary,
  ProjectSummary,
  WorkspaceSkillSummary
} from "@qjclaw/shared-types";
import type { AppConfigService } from "./app-config.js";
import {
  CURATED_GENERIC_SKILL_IDS,
  getCuratedGenericSkillDefinition
} from "./curated-skills.js";
import type { SkillExecutionTarget } from "./skill-store.js";

interface StoredProjectRecord {
  id: string;
  name: string;
  description?: string;
  version?: string;
  ready?: boolean;
  boundSkillIds?: string[];
  updatedAt: string;
  [key: string]: unknown;
}

interface StoredSessionRecord {
  id: string;
  title: string;
  createdAt?: string;
  updatedAt: string;
  lastActiveAt?: string;
  contextSnapshotId?: string | null;
  contextLoadedAt?: string | null;
  selectedSkillId?: string | null;
  draft?: string;
}

interface ActiveProjectRecord {
  projectId: string;
}

interface ProjectSeed {
  id: string;
  name: string;
  description?: string;
  version?: string;
  boundSkillIds?: string[];
  ready?: boolean;
  projectType?: string;
  platform?: string;
  defaultEntry?: unknown;
  entries?: unknown;
  orchestrators?: unknown;
  capabilities?: unknown;
  requires?: unknown;
  confirmationPolicy?: unknown;
  [key: string]: unknown;
}

interface ProjectStoreServiceOptions {
  qSkillsRoot?: string;
}

interface ResolvedSkillFile {
  skillDir: string;
  fileName: string;
}

const PROJECTS_DIR = "projects";
const SKILLS_DIR = "skills";
const CRON_DIR = "cron";
const MANIFESTS_DIR = "manifests";
const ACTIVE_PROJECT_FILE = "active-project.json";
const PROJECT_FILE = "project.json";
const SESSIONS_FILE = "sessions.json";
const SESSION_MESSAGES_DIR = "session-messages";
const BUILTIN_HOME_PROJECT_ID = "home-chat";
const BUILTIN_HOME_PROJECT_NAME = "home-chat";

function nowIso(): string {
  return new Date().toISOString();
}

function isBuiltinHomeProjectId(projectId: string): boolean {
  return projectId === BUILTIN_HOME_PROJECT_ID;
}

function slugify(value: string): string {
  const ascii = value
    .normalize("NFKD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 48);
  if (ascii) {
    return ascii;
  }
  const digest = createHash("sha1").update(value).digest("hex").slice(0, 10);
  return `project-${digest}`;
}

function sanitizeSkillId(value: string): string {
  const trimmed = value.trim();
  return trimmed || `skill-${createHash("sha1").update(value).digest("hex").slice(0, 8)}`;
}

const PROJECT_NAME_NOISE_TOKENS = [
  "openclaw",
  "qjclaw",
  "skills",
  "skill",
  "delivery",
  "project",
  "projects",
  "workspace",
  "workflow"
] as const;

const PROJECT_PLATFORM_DISPLAY_NAMES: Array<{ pattern: RegExp; name: string }> = [
  { pattern: /xiaohongshu|xhs|rednote|小红书/i, name: "小红书专家" },
  { pattern: /douyin|tiktok|抖音/i, name: "抖音专家" },
  { pattern: /kuaishou|快手/i, name: "快手专家" },
  { pattern: /wechat|weixin|微信|视频号/i, name: "微信专家" },
  { pattern: /taobao|淘宝/i, name: "淘宝专家" }
];

function isInvalidDisplayName(value: string | undefined): boolean {
  if (!value) {
    return true;
  }
  const normalized = value.normalize("NFKC").trim();
  if (!normalized) {
    return true;
  }
  if (/^[?？\s._-]+$/u.test(normalized)) {
    return true;
  }
  if (normalized.includes("\uFFFD")) {
    return true;
  }
  return false;
}

function cleanupProjectName(value: string | undefined): string {
  if (!value) {
    return "";
  }

  let next = value.normalize("NFKC").trim();
  if (!next) {
    return "";
  }

  next = next.replace(/\.zip$/i, "");
  next = next.replace(/[_./\\]+/g, " ");
  next = next.replace(/-+/g, " ");
  next = next.replace(/\((skills?|delivery|project)\)/gi, " ");
  next = next.replace(/\b(?:open[\s-]?claw|qjclaw)\b/gi, " ");
  for (const token of PROJECT_NAME_NOISE_TOKENS) {
    next = next.replace(new RegExp(`\\b${token}\\b`, "gi"), " ");
  }
  next = next.replace(/\s+/g, " ").trim();
  next = next.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "").trim();
  return next;
}

function toExpertDisplayName(rawValue: string | undefined): string | undefined {
  if (isInvalidDisplayName(rawValue)) {
    return undefined;
  }

  const cleaned = cleanupProjectName(rawValue);
  if (isInvalidDisplayName(cleaned)) {
    return undefined;
  }

  for (const candidate of PROJECT_PLATFORM_DISPLAY_NAMES) {
    if (candidate.pattern.test(cleaned)) {
      return candidate.name;
    }
  }

  if (/专家$/u.test(cleaned)) {
    return cleaned;
  }
  return `${cleaned}专家`;
}

function deriveProjectDisplayName(record: Pick<StoredProjectRecord, "id" | "name" | "description"> & {
  platform?: unknown;
  projectType?: unknown;
}): string {
  const directMatch = [record.name, record.platform, record.projectType, record.id]
    .map((value) => typeof value === "string" ? toExpertDisplayName(value) : undefined)
    .find((value): value is string => Boolean(value));
  if (directMatch) {
    return directMatch;
  }

  const cleanedDescription = cleanupProjectName(record.description);
  if (!isInvalidDisplayName(cleanedDescription)) {
    return `${cleanedDescription}专家`;
  }

  return "自动化专家";
}

function sortProjects(items: ProjectSummary[]): ProjectSummary[] {
  return [...items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || (left.displayName ?? left.name).localeCompare(right.displayName ?? right.name, "zh-CN"));
}

function sortSessionStates(items: ProjectSessionState[]): ProjectSessionState[] {
  return [...items].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}

function compareSkills(left: WorkspaceSkillSummary, right: WorkspaceSkillSummary): number {
  return (left.description || left.name).localeCompare(right.description || right.name, "zh-CN");
}

async function pathExists(targetPath: string): Promise<boolean> {
  try {
    await stat(targetPath);
    return true;
  } catch {
    return false;
  }
}

async function hasDirectProjectChildren(rootPath: string): Promise<boolean> {
  const entries = await readdir(rootPath, { withFileTypes: true }).catch(() => []);
  for (const entry of entries) {
    if (!entry.isDirectory()) {
      continue;
    }
    if (await pathExists(path.join(rootPath, entry.name, PROJECT_FILE))) {
      return true;
    }
  }
  return false;
}

async function readJsonFile<T>(filePath: string): Promise<T | null> {
  try {
    const raw = await readFile(filePath, "utf8");
    return JSON.parse(raw) as T;
  } catch {
    return null;
  }
}

async function writeJsonFile(filePath: string, payload: unknown): Promise<void> {
  await mkdir(path.dirname(filePath), { recursive: true });
  await writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
}

function normalizeStringArray(value: unknown): string[] {
  if (Array.isArray(value)) {
    return [...new Set(value
      .map((entry) => typeof entry === "string" ? entry.trim() : "")
      .filter(Boolean))];
  }
  if (typeof value === "string") {
    const trimmed = value.trim();
    return trimmed ? [trimmed] : [];
  }
  return [];
}

function buildSkillSummary(input: {
  skillId: string;
  name: string;
  description: string;
  category: string;
  projectUpdatedAt: string;
  resolvedFile?: ResolvedSkillFile | null;
}): WorkspaceSkillSummary {
  return {
    id: input.skillId,
    name: input.name,
    description: input.description,
    category: input.category,
    enabled: true,
    ready: Boolean(input.resolvedFile),
    downloadState: input.resolvedFile ? "ready" : "failed",
    fileName: input.resolvedFile?.fileName,
    lastSyncedAt: input.projectUpdatedAt,
    lastError: input.resolvedFile ? undefined : "Skill directory does not contain a runnable description file."
  };
}

function normalizeEntryType(value: unknown): ProjectPackageEntryType | undefined {
  if (value === "workspace-entry" || value === "workspace_entry" || value === "workspace") {
    return "workspace-entry";
  }
  if (value === "skill") {
    return "skill";
  }
  return undefined;
}

function normalizeProjectRoles(value: unknown): ProjectRole[] | undefined {
  if (!Array.isArray(value)) return undefined
  const valid: ProjectRole[] = []
  for (const item of value) {
    if (!item || typeof item !== "object") continue
    const obj = item as Record<string, unknown>
    const id = typeof obj.id === "string" ? obj.id.trim() : ""
    const name = typeof obj.name === "string" ? obj.name.trim() : ""
    const icon = typeof obj.icon === "string" ? obj.icon.trim() : ""
    const description = typeof obj.description === "string" ? obj.description.trim() : ""
    const skillIds = Array.isArray(obj.skillIds)
      ? (obj.skillIds as unknown[]).filter((s): s is string => typeof s === "string" && s.length > 0)
      : []
    if (id && name && icon && description) {
      valid.push({ id, name, icon, skillIds, description } as ProjectRole)
    }
  }
  return valid.length > 0 ? valid : undefined
}

function normalizeConfirmationPolicy(value: unknown): ProjectConfirmationPolicy | undefined {
  if (value === "always" || value === "never" || value === "publish-only") {
    return value;
  }
  return undefined;
}

function normalizePackageEntry(raw: unknown, fallbackConfirmationPolicy?: ProjectConfirmationPolicy): ProjectPackageEntry | null {
  if (typeof raw === "string") {
    const trimmed = sanitizeSkillId(raw);
    return trimmed
      ? {
          id: trimmed,
          type: "skill",
          requiresSkills: [],
          capabilities: [],
          intentAliases: [],
          confirmationPolicy: fallbackConfirmationPolicy
        }
      : null;
  }

  if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
    return null;
  }

  const record = raw as Record<string, unknown>;
  const type = normalizeEntryType(record.type ?? record.entryType ?? record.kind)
    ?? (typeof record.workspaceEntry === "string" ? "workspace-entry" : undefined)
    ?? (typeof record.skillId === "string" || typeof record.id === "string" ? "skill" : undefined);
  const idCandidate = record.id
    ?? record.skillId
    ?? record.entryId
    ?? record.targetId
    ?? record.workspaceEntry
    ?? record.name;
  const entryId = typeof idCandidate === "string" && idCandidate.trim()
    ? (type === "workspace-entry" ? idCandidate.trim() : sanitizeSkillId(idCandidate))
    : (type === "workspace-entry" ? "workspace-entry" : "");
  if (!entryId) {
    return null;
  }

  return {
    id: entryId,
    type: type ?? "skill",
    requiresSkills: normalizeStringArray(record.requiresSkills ?? record.dependsOn ?? record.dependencies ?? record.dependencySkillIds)
      .map((skillId) => sanitizeSkillId(skillId)),
    capabilities: normalizeStringArray(record.capabilities ?? record.intents),
    intentAliases: normalizeStringArray(record.intentAliases ?? record.aliases ?? record.matchers),
    confirmationPolicy: normalizeConfirmationPolicy(record.confirmationPolicy) ?? fallbackConfirmationPolicy
  };
}

function normalizeProjectPackageConfig(record: StoredProjectRecord | null): ProjectPackageConfig | null {
  if (!record) {
    return null;
  }

  const confirmationPolicy = normalizeConfirmationPolicy(record.confirmationPolicy);
  const topLevelCapabilities = normalizeStringArray(record.capabilities);
  const topLevelRequires = normalizeStringArray(record.requires ?? record.automationRequirements);
  const topLevelIntentAliases = normalizeStringArray(record.intentAliases);
  const topLevelDefaultDependencies = normalizeStringArray(record.defaultEntryDependencies ?? record.orchestratorDependencies ?? record.dependencySkillIds)
    .map((skillId) => sanitizeSkillId(skillId));

  const fallbackDefaultEntry = normalizePackageEntry(
    record.defaultEntry
      ?? record.executionEntry
      ?? record.orchestratorEntry
      ?? record.orchestratorSkillId
      ?? record.defaultSkillId,
    confirmationPolicy
  );

  const defaultEntry = fallbackDefaultEntry
    ? {
        ...fallbackDefaultEntry,
        capabilities: fallbackDefaultEntry.capabilities.length > 0 ? fallbackDefaultEntry.capabilities : topLevelCapabilities,
        intentAliases: fallbackDefaultEntry.intentAliases.length > 0 ? fallbackDefaultEntry.intentAliases : topLevelIntentAliases,
        requiresSkills: fallbackDefaultEntry.requiresSkills.length > 0 ? fallbackDefaultEntry.requiresSkills : topLevelDefaultDependencies
      }
    : null;

  const entryCandidates = Array.isArray(record.orchestrators)
    ? record.orchestrators
    : Array.isArray(record.entries)
      ? record.entries
      : [];
  const entries: ProjectPackageEntry[] = [];
  if (defaultEntry) {
    entries.push(defaultEntry);
  }
  for (const candidate of entryCandidates) {
    const normalized = normalizePackageEntry(candidate, confirmationPolicy);
    if (!normalized) {
      continue;
    }
    const mergedEntry: ProjectPackageEntry = {
      ...normalized,
      capabilities: normalized.capabilities.length > 0 ? normalized.capabilities : topLevelCapabilities,
      intentAliases: normalized.intentAliases.length > 0 ? normalized.intentAliases : topLevelIntentAliases
    };
    if (!entries.some((entry) => entry.type === mergedEntry.type && entry.id === mergedEntry.id)) {
      entries.push(mergedEntry);
    }
  }

  return {
    projectType: typeof record.projectType === "string" && record.projectType.trim() ? record.projectType.trim() : undefined,
    platform: typeof record.platform === "string" && record.platform.trim() ? record.platform.trim() : undefined,
    capabilities: topLevelCapabilities,
    requires: topLevelRequires,
    confirmationPolicy,
    defaultEntry: defaultEntry ?? undefined,
    entries
  };
}

function hasDirectWorkspaceEntry(record: StoredProjectRecord | null): boolean {
  if (!record) {
    return false;
  }

  const packageConfig = normalizeProjectPackageConfig(record);
  return Boolean(
    packageConfig?.defaultEntry?.type === "workspace-entry"
    || packageConfig?.entries.some((entry) => entry.type === "workspace-entry")
    || record.workspaceEntryEnabled === true
    || record.executionEntryEnabled === true
    || typeof record.workspaceEntry === "string"
    || typeof record.executionEntry === "string"
  );
}

export class ProjectStoreService {
  private readonly configService: AppConfigService;
  private readonly qSkillsRoot?: string;

  constructor(configService: AppConfigService, options?: ProjectStoreServiceOptions) {
    this.configService = configService;
    this.qSkillsRoot = options?.qSkillsRoot?.trim() || undefined;
  }

  async initialize(): Promise<void> {
    const workspaceRoot = await this.getWorkspaceRoot();
    await Promise.all([
      mkdir(path.join(workspaceRoot, PROJECTS_DIR), { recursive: true }),
      mkdir(path.join(workspaceRoot, SKILLS_DIR), { recursive: true }),
      mkdir(path.join(workspaceRoot, CRON_DIR), { recursive: true }),
      mkdir(path.join(workspaceRoot, MANIFESTS_DIR), { recursive: true })
    ]);

    await this.ensureBuiltinHomeProject();

    const projects = await this.readProjects();
    if (projects.length === 0) {
      await writeJsonFile(await this.getActiveProjectFilePath(), {});
      return;
    }

    const activeRecord = await readJsonFile<ActiveProjectRecord>(await this.getActiveProjectFilePath());
    if (!activeRecord?.projectId || !projects.some((project) => project.id === activeRecord.projectId)) {
      await writeJsonFile(await this.getActiveProjectFilePath(), { projectId: projects[0].id });
    }
  }

  async getWorkspaceRoot(): Promise<string> {
    const config = await this.configService.load();
    const configured = config.workspacePath.trim();
    const fallbackRoot = this.configService.getDataPath("workspace");
    const configuredRoot = configured || fallbackRoot;
    const nestedWorkspaceRoot = configured ? path.join(configuredRoot, "workspace") : "";
    const workspaceRoot = configured
      && !await pathExists(path.join(configuredRoot, PROJECTS_DIR))
      && !await hasDirectProjectChildren(configuredRoot)
      && await hasDirectProjectChildren(nestedWorkspaceRoot)
      ? nestedWorkspaceRoot
      : configuredRoot;
    await mkdir(workspaceRoot, { recursive: true });
    return workspaceRoot;
  }

  async listProjects(): Promise<ProjectSummary[]> {
    await this.initialize();
    return sortProjects(await this.readProjects());
  }

  async getActiveProject(): Promise<ProjectSummary> {
    await this.initialize();
    const projects = await this.readProjects();
    const activeRecord = await readJsonFile<ActiveProjectRecord>(await this.getActiveProjectFilePath());
    const activeProject = projects.find((item) => item.id === activeRecord?.projectId) ?? projects[0];
    if (!activeProject) {
      throw new Error("No available projects.");
    }
    if (activeRecord?.projectId !== activeProject.id) {
      await this.setActiveProject(activeProject.id);
    }
    return activeProject;
  }

  async setActiveProject(projectId: string): Promise<ProjectSummary> {
    await this.initialize();
    const project = await this.getProjectById(projectId);
    await writeJsonFile(await this.getActiveProjectFilePath(), { projectId });
    return project;
  }

  async getProjectRoot(projectId: string): Promise<string> {
    const project = await this.getProjectById(projectId);
    return this.getProjectDir(project.id);
  }

  async getProjectSummary(projectId: string): Promise<ProjectSummary> {
    return this.getProjectById(projectId);
  }

  async getProjectPackageConfig(projectId: string): Promise<ProjectPackageConfig | null> {
    return normalizeProjectPackageConfig(await this.readProjectRecord(projectId));
  }

  async getProjectDefaultEntry(projectId: string): Promise<ProjectPackageEntry | null> {
    return (await this.getProjectPackageConfig(projectId))?.defaultEntry ?? null;
  }

  async isProjectOrchestratorSkill(projectId: string, skillId: string): Promise<boolean> {
    const normalizedSkillId = sanitizeSkillId(skillId);
    const projectConfig = await this.getProjectPackageConfig(projectId);
    if (!projectConfig) {
      return false;
    }

    return projectConfig.entries.some((entry) => entry.type === "skill" && sanitizeSkillId(entry.id) === normalizedSkillId);
  }

  async getProjectSkillDependencyIds(projectId: string, skillId: string): Promise<string[]> {
    const normalizedSkillId = sanitizeSkillId(skillId);
    const projectConfig = await this.getProjectPackageConfig(projectId);
    if (!projectConfig) {
      return [];
    }

    const matchedEntry = projectConfig.entries.find((entry) => entry.type === "skill" && sanitizeSkillId(entry.id) === normalizedSkillId);
    return matchedEntry ? uniqueSkillIds(matchedEntry.requiresSkills) : [];
  }

  async getProjectSkillDependencyTargets(projectId: string, skillId: string): Promise<SkillExecutionTarget[]> {
    const dependencyIds = await this.getProjectSkillDependencyIds(projectId, skillId);
    const targets = await Promise.all(dependencyIds.map(async (dependencyId) => this.getProjectSkillTarget(projectId, dependencyId)));
    return targets.filter((target): target is SkillExecutionTarget => Boolean(target));
  }

  async upsertProject(seed: ProjectSeed): Promise<ProjectSummary> {
    await this.initialize();
    const projectDir = await this.getProjectDir(seed.id);
    await mkdir(projectDir, { recursive: true });
    const existing = await readJsonFile<StoredProjectRecord>(path.join(projectDir, PROJECT_FILE));
    const record: StoredProjectRecord = {
      ...(existing ?? {}),
      ...seed,
      id: seed.id,
      name: seed.name,
      description: seed.description ?? existing?.description,
      version: seed.version ?? existing?.version,
      ready: seed.ready ?? existing?.ready ?? true,
      boundSkillIds: uniqueSkillIds(seed.boundSkillIds ?? existing?.boundSkillIds ?? []),
      updatedAt: nowIso()
    };
    await writeJsonFile(path.join(projectDir, PROJECT_FILE), record);
    await mkdir(path.join(projectDir, "memory"), { recursive: true });
    await this.ensureProjectSessionStates(seed.id);
    return this.toProjectSummary(record);
  }

  async updateProjectSkills(projectId: string, skillIds: string[]): Promise<ProjectSummary> {
    const projectDir = await this.getProjectDir(projectId);
    const existing = await readJsonFile<StoredProjectRecord>(path.join(projectDir, PROJECT_FILE));
    if (!existing) {
      throw new Error(`Project ${projectId} was not found.`);
    }
    return this.upsertProject({
      ...existing,
      id: existing.id,
      name: existing.name,
      description: existing.description,
      version: existing.version,
      ready: existing.ready,
      boundSkillIds: skillIds
    });
  }

  async listSessions(projectId?: string): Promise<ProjectSessionSummary[]> {
    const targetProjectId = projectId ?? (await this.getActiveProject()).id;
    const sessions = await this.ensureProjectSessionStates(targetProjectId);
    return sortSessionStates(sessions).map((session) => this.toProjectSessionSummary(session));
  }

  async createSession(title?: string, projectId?: string): Promise<ProjectSessionSummary> {
    const activeProject = projectId ? (await this.getProjectById(projectId)) : await this.getActiveProject();
    const projectRoot = await this.getProjectDir(activeProject.id);
    const sessions = await this.ensureProjectSessionStates(activeProject.id);
    const timestamp = nowIso();
    const session = this.toProjectSessionState(activeProject.id, projectRoot, {
      id: `project:${activeProject.id}:${randomUUID()}`,
      title: title?.trim() || `New Session ${sessions.length + 1}`,
      createdAt: timestamp,
      updatedAt: timestamp,
      lastActiveAt: timestamp,
      contextSnapshotId: null,
      contextLoadedAt: null,
      selectedSkillId: null,
      draft: ""
    });
    const nextSessions = sortSessionStates([session, ...sessions]);
    await this.writeSessions(activeProject.id, nextSessions);
    return this.toProjectSessionSummary(session);
  }

  async closeSession(sessionId: string): Promise<ProjectSessionSummary[]> {
    const projectId = await this.resolveProjectIdForSession(sessionId);
    const sessions = await this.ensureProjectSessionStates(projectId);
    const remaining = sessions.filter((session) => session.sessionId !== sessionId);
    if (remaining.length > 0) {
      await this.writeSessions(projectId, remaining);
      return sortSessionStates(remaining).map((session) => this.toProjectSessionSummary(session));
    }

    const created = await this.createSession(undefined, projectId);
    return [created];
  }

  async touchSession(sessionId: string, title?: string): Promise<void> {
    await this.updateSessionState(sessionId, (session) => ({
      ...session,
      title: title?.trim() || session.title,
      updatedAt: nowIso(),
      lastActiveAt: nowIso()
    }));
  }

  async updateSessionLastActive(sessionId: string): Promise<void> {
    await this.updateSessionState(sessionId, (session) => ({
      ...session,
      updatedAt: nowIso(),
      lastActiveAt: nowIso()
    }));
  }

  async renameSession(sessionId: string, title: string): Promise<ProjectSessionSummary[]> {
    const nextTitle = title.trim();
    if (!nextTitle) {
      throw new Error("Session title is required.");
    }
    const projectId = await this.resolveProjectIdForSession(sessionId);
    await this.updateSessionState(sessionId, (session) => ({
      ...session,
      title: nextTitle,
      updatedAt: nowIso(),
      lastActiveAt: nowIso()
    }));
    return this.listSessions(projectId);
  }

  async persistDraft(sessionId: string, draft: string): Promise<void> {
    await this.updateSessionState(sessionId, (session) => ({
      ...session,
      draft,
      updatedAt: nowIso()
    }));
  }

  async bindSessionContextSnapshot(sessionId: string, snapshotId: string): Promise<void> {
    const timestamp = nowIso();
    await this.updateSessionState(sessionId, (session) => ({
      ...session,
      contextSnapshotId: snapshotId,
      contextLoadedAt: timestamp,
      updatedAt: timestamp
    }));
  }

  async setSessionSelectedSkill(sessionId: string, skillId: string | null): Promise<void> {
    await this.updateSessionState(sessionId, (session) => ({
      ...session,
      selectedSkillId: skillId,
      updatedAt: nowIso()
    }));
  }

  async getSessionState(sessionId: string): Promise<ProjectSessionState> {
    const projectId = await this.resolveProjectIdForSession(sessionId);
    const sessions = await this.ensureProjectSessionStates(projectId);
    const session = sessions.find((item) => item.sessionId === sessionId);
    if (!session) {
      throw new Error(`Session ${sessionId} was not found.`);
    }
    return session;
  }

  async listSessionMessages(sessionId: string): Promise<ChatMessage[]> {
    const messagesPath = await this.getSessionMessagesPath(sessionId);
    const existing = await readJsonFile<ChatMessage[]>(messagesPath);
    return Array.isArray(existing) ? existing : [];
  }

  async seedSessionMessages(sessionId: string, messages: ChatMessage[]): Promise<void> {
    if (messages.length === 0) {
      return;
    }
    await writeJsonFile(await this.getSessionMessagesPath(sessionId), messages);
  }

  async appendSessionMessage(sessionId: string, message: ChatMessage): Promise<void> {
    const messages = await this.listSessionMessages(sessionId);
    messages.push(message);
    await writeJsonFile(await this.getSessionMessagesPath(sessionId), messages);
  }

  async upsertSessionMessage(sessionId: string, message: ChatMessage): Promise<void> {
    const messages = await this.listSessionMessages(sessionId);
    const existingIndex = messages.findIndex((entry) => entry.id === message.id);
    if (existingIndex >= 0) {
      messages[existingIndex] = message;
    } else {
      messages.push(message);
    }
    await writeJsonFile(await this.getSessionMessagesPath(sessionId), messages);
  }

  async listCurrentProjectSkills(): Promise<WorkspaceSkillSummary[]> {
    const project = await this.getActiveProject();
    return this.listProjectSkills(project.id);
  }

  async listProjectSkills(projectId: string): Promise<WorkspaceSkillSummary[]> {
    const project = await this.getProjectById(projectId);
    const projectRecord = await this.readProjectRecord(project.id);
    const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
    if (project.isBuiltinHome) {
      return (await this.listCuratedGenericSkills(project.updatedAt)).sort(compareSkills);
    }

    return (await this.listWorkspaceSkills(project.name, project.updatedAt, boundSkillIds)).sort(compareSkills);
  }

  async getCurrentProjectSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
    const activeProject = await this.getActiveProject();
    return this.getProjectSkillTarget(activeProject.id, skillId);
  }

  async getProjectSkillTarget(projectId: string, skillId: string): Promise<SkillExecutionTarget | undefined> {
    const normalizedSkillId = sanitizeSkillId(skillId);
    const projectRecord = await this.readProjectRecord(projectId);
    const boundSkillIds = new Set(projectRecord?.boundSkillIds ?? []);
    if (CURATED_GENERIC_SKILL_IDS.has(normalizedSkillId)) {
      if (!isBuiltinHomeProjectId(projectId)) {
        return undefined;
      }
      return this.resolveCuratedGenericSkillTarget(normalizedSkillId);
    }

    if (boundSkillIds.size > 0 && !boundSkillIds.has(normalizedSkillId)) {
      return undefined;
    }

    const workspaceRoot = await this.getWorkspaceRoot();
    const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
    const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
    for (const entry of dirEntries) {
      if (!entry.isDirectory()) {
        continue;
      }
      if (sanitizeSkillId(entry.name) !== normalizedSkillId) {
        continue;
      }
      const skillDir = path.join(skillsRoot, entry.name);
      const resolvedFile = await this.resolveSkillFile(skillDir);
      if (!resolvedFile) {
        return undefined;
      }
      return {
        skillId: normalizedSkillId,
        name: entry.name,
        fileName: resolvedFile.fileName,
        localPath: path.join(skillDir, resolvedFile.fileName)
      };
    }
    return undefined;
  }

  async syncBundleProject(input: {
    projectId?: string;
    projectName: string;
    version?: string;
    boundSkillIds?: string[];
    declaredBoundSkillIds?: string[];
    description?: string;
    ready?: boolean;
  }): Promise<ProjectSummary> {
    const projectId = input.projectId?.trim() || slugify(input.projectName);
    const bundleProjectRecord = await this.readProjectRecord(projectId);
    const directWorkspaceEntry = hasDirectWorkspaceEntry(bundleProjectRecord);
    const availableWorkspaceSkillIds = await this.readWorkspaceSkillIds();
    const materializedBoundSkillIds = uniqueSkillIds(input.boundSkillIds ?? []);
    const declaredBoundSkillIds = uniqueSkillIds(input.declaredBoundSkillIds ?? bundleProjectRecord?.boundSkillIds ?? []);
    const requestedBoundSkillIds = declaredBoundSkillIds.length > 0
      ? declaredBoundSkillIds
      : materializedBoundSkillIds;
    const validBoundSkillIds = requestedBoundSkillIds.filter((skillId) => availableWorkspaceSkillIds.has(skillId));
    const missingBoundSkillIds = requestedBoundSkillIds.filter((skillId) => !availableWorkspaceSkillIds.has(skillId));
    const undeclaredMaterializedSkillIds = declaredBoundSkillIds.length > 0
      ? materializedBoundSkillIds.filter((skillId) => !declaredBoundSkillIds.includes(skillId))
      : [];
    if (missingBoundSkillIds.length > 0) {
      console.warn("[project-store] bundle project declared missing bound skills", {
        projectId,
        missingBoundSkillIds
      });
    }
    if (undeclaredMaterializedSkillIds.length > 0) {
      console.warn("[project-store] bundle project materialized undeclared skills", {
        projectId,
        undeclaredMaterializedSkillIds
      });
    }

    const missingBoundSkillsBlockProject = missingBoundSkillIds.length > 0 && !directWorkspaceEntry;
    const lastError = missingBoundSkillsBlockProject
      ? `Missing bound skills: ${missingBoundSkillIds.join(", ")}`
      : undefined;
    const nextReady = (input.ready ?? bundleProjectRecord?.ready ?? true)
      && (directWorkspaceEntry || requestedBoundSkillIds.length === 0 || validBoundSkillIds.length > 0);
    const project = await this.upsertProject({
      ...(bundleProjectRecord ?? {}),
      id: projectId,
      name: input.projectName,
      description: input.description ?? bundleProjectRecord?.description,
      version: input.version ?? bundleProjectRecord?.version,
      boundSkillIds: validBoundSkillIds,
      ready: nextReady,
      lastError
    });
    const activeProject = await this.getActiveProject().catch(() => null);
    if (!activeProject || activeProject.isBuiltinHome) {
      await this.setActiveProject(project.id);
    }
    return project;
  }

  async removeProject(projectId: string): Promise<void> {
    await this.initialize();
    const workspaceRoot = await this.getWorkspaceRoot();
    const projectsRoot = path.join(workspaceRoot, PROJECTS_DIR);
    const projectDir = this.resolveWorkspaceChildPath(projectsRoot, projectId);
    await rm(projectDir, { recursive: true, force: true }).catch(() => undefined);

    const activeProjectPath = await this.getActiveProjectFilePath();
    const activeRecord = await readJsonFile<ActiveProjectRecord>(activeProjectPath);
    const remainingProjects = sortProjects(await this.readProjects());
    if (remainingProjects.length === 0) {
      await writeJsonFile(activeProjectPath, {});
      return;
    }

    if (!activeRecord?.projectId || activeRecord.projectId === projectId || !remainingProjects.some((project) => project.id === activeRecord.projectId)) {
      await writeJsonFile(activeProjectPath, { projectId: remainingProjects[0].id });
    }
  }

  resolveProjectIdFromSessionId(sessionId: string): string | undefined {
    const matched = /^project:([^:]+):/.exec(sessionId);
    return matched?.[1];
  }

  private async getProjectById(projectId: string): Promise<ProjectSummary> {
    await this.initialize();
    if (isBuiltinHomeProjectId(projectId)) {
      const record = await this.readProjectRecord(projectId);
      if (!record) {
        throw new Error(`Project ${projectId} was not found.`);
      }
      return this.toProjectSummary(record);
    }

    const projects = await this.listProjects();
    const project = projects.find((item) => item.id === projectId);
    if (!project) {
      throw new Error(`Project ${projectId} was not found.`);
    }
    return project;
  }

  private async resolveProjectIdForSession(sessionId: string): Promise<string> {
    const hintedProjectId = this.resolveProjectIdFromSessionId(sessionId);
    if (hintedProjectId) {
      const record = await this.readProjectRecord(hintedProjectId);
      if (record) {
        return hintedProjectId;
      }
    }

    const projects = await this.listProjects();
    for (const project of projects) {
      const sessions = await this.ensureProjectSessionStates(project.id);
      if (sessions.some((session) => session.sessionId === sessionId)) {
        return project.id;
      }
    }

    throw new Error(`Session ${sessionId} was not found.`);
  }

  private async ensureProjectSessionStates(projectId: string): Promise<ProjectSessionState[]> {
    const sessionsPath = await this.getProjectSessionsPath(projectId);
    const existing = await readJsonFile<StoredSessionRecord[]>(sessionsPath);
    const projectRoot = await this.getProjectDir(projectId);
    if (Array.isArray(existing) && existing.length > 0) {
      return sortSessionStates(existing.map((session) => this.toProjectSessionState(projectId, projectRoot, session)));
    }

    const timestamp = nowIso();
    const defaultSession = this.toProjectSessionState(projectId, projectRoot, {
      id: `project:${projectId}:${randomUUID()}`,
      title: "Default Session",
      createdAt: timestamp,
      updatedAt: timestamp,
      lastActiveAt: timestamp,
      contextSnapshotId: null,
      contextLoadedAt: null,
      selectedSkillId: null,
      draft: ""
    });
    await this.writeSessions(projectId, [defaultSession]);
    return [defaultSession];
  }

  private async updateSessionState(
    sessionId: string,
    updater: (session: ProjectSessionState) => ProjectSessionState
  ): Promise<ProjectSessionState> {
    const projectId = await this.resolveProjectIdForSession(sessionId);
    const sessions = await this.ensureProjectSessionStates(projectId);
    const nextSessions = sessions.map((session) => session.sessionId === sessionId ? updater(session) : session);
    const updated = nextSessions.find((session) => session.sessionId === sessionId);
    if (!updated) {
      throw new Error(`Session ${sessionId} was not found.`);
    }
    await this.writeSessions(projectId, nextSessions);
    return updated;
  }

  private async writeSessions(projectId: string, sessions: ProjectSessionState[]): Promise<void> {
    const payload: StoredSessionRecord[] = sessions.map((session) => ({
      id: session.sessionId,
      title: session.title,
      createdAt: session.createdAt,
      updatedAt: session.updatedAt,
      lastActiveAt: session.lastActiveAt,
      contextSnapshotId: session.contextSnapshotId,
      contextLoadedAt: session.contextLoadedAt,
      selectedSkillId: session.selectedSkillId,
      draft: session.draft
    }));
    const sessionsPath = await this.getProjectSessionsPath(projectId);
    await writeJsonFile(sessionsPath, payload);
  }

  private toProjectSessionSummary(session: ProjectSessionState): ProjectSessionSummary {
    return {
      id: session.sessionId,
      projectId: session.projectId,
      title: session.title,
      updatedAt: session.updatedAt
    };
  }

  private toProjectSessionState(projectId: string, projectRoot: string, record: StoredSessionRecord): ProjectSessionState {
    const createdAt = record.createdAt ?? record.updatedAt ?? nowIso();
    const updatedAt = record.updatedAt ?? createdAt;
    const lastActiveAt = record.lastActiveAt ?? updatedAt;
    return {
      sessionId: record.id,
      projectId,
      projectRoot,
      title: record.title,
      createdAt,
      updatedAt,
      lastActiveAt,
      contextSnapshotId: record.contextSnapshotId ?? null,
      contextLoadedAt: record.contextLoadedAt ?? null,
      selectedSkillId: record.selectedSkillId ?? null,
      draft: record.draft ?? ""
    };
  }

  private async ensureBuiltinHomeProject(): Promise<void> {
    const projectDir = await this.getProjectDir(BUILTIN_HOME_PROJECT_ID);
    await mkdir(projectDir, { recursive: true });
    const existing = await readJsonFile<StoredProjectRecord>(path.join(projectDir, PROJECT_FILE));
    if (!existing) {
      const timestamp = nowIso();
      const record: StoredProjectRecord = {
        id: BUILTIN_HOME_PROJECT_ID,
        name: BUILTIN_HOME_PROJECT_NAME,
        description: "Builtin home chat workspace.",
        ready: true,
        boundSkillIds: [],
        updatedAt: timestamp
      };
      await writeJsonFile(path.join(projectDir, PROJECT_FILE), {
        ...record
      });
    }
    await mkdir(path.join(projectDir, "memory"), { recursive: true });
    await this.ensureProjectSessionStates(BUILTIN_HOME_PROJECT_ID);
  }

  private async readProjects(options?: { includeBuiltinHome?: boolean }): Promise<ProjectSummary[]> {
    const projects = new Map<string, ProjectSummary>();
    for (const rootPath of await this.getProjectContainerRoots()) {
      const entries = await readdir(rootPath, { withFileTypes: true }).catch(() => []);
      for (const entry of entries) {
        if (!entry.isDirectory()) {
          continue;
        }
        const projectDir = this.resolveWorkspaceChildPath(rootPath, entry.name);
        const record = await readJsonFile<StoredProjectRecord>(path.join(projectDir, PROJECT_FILE));
        if (!record) {
          continue;
        }
        if (!options?.includeBuiltinHome && isBuiltinHomeProjectId(record.id)) {
          continue;
        }
        if (!projects.has(record.id)) {
          projects.set(record.id, this.toProjectSummary(record));
        }
      }
    }
    return [...projects.values()];
  }

  private async readProjectRecord(projectId: string): Promise<StoredProjectRecord | null> {
    const existingDir = await this.resolveExistingProjectDir(projectId);
    if (existingDir) {
      return readJsonFile<StoredProjectRecord>(path.join(existingDir, PROJECT_FILE));
    }
    return readJsonFile<StoredProjectRecord>(path.join(await this.getProjectDir(projectId), PROJECT_FILE));
  }

  private toProjectSummary(record: StoredProjectRecord): ProjectSummary {
    const packageConfig = normalizeProjectPackageConfig(record);
    const updatedAt = typeof record.updatedAt === "string" && record.updatedAt.trim()
      ? record.updatedAt
      : nowIso();
    return {
      id: record.id,
      name: record.name,
      displayName: deriveProjectDisplayName({
        id: record.id,
        name: record.name,
        description: record.description,
        platform: typeof record.platform === "string" ? record.platform : undefined,
        projectType: typeof record.projectType === "string" ? record.projectType : undefined
      }),
      isBuiltinHome: isBuiltinHomeProjectId(record.id),
      description: record.description,
      version: record.version,
      updatedAt,
      skillCount: record.boundSkillIds?.length ?? 0,
      ready: record.ready !== false,
      projectType: packageConfig?.projectType,
      platform: packageConfig?.platform,
      defaultEntryId: packageConfig?.defaultEntry?.id,
      defaultEntryType: packageConfig?.defaultEntry?.type,
      roles: normalizeProjectRoles(record.roles)
    };
  }

  private async getActiveProjectFilePath(): Promise<string> {
    return path.join(await this.getWorkspaceRoot(), MANIFESTS_DIR, ACTIVE_PROJECT_FILE);
  }

  private async getProjectDir(projectId: string): Promise<string> {
    const containerRoots = await this.getProjectContainerRoots();
    const existingDir = await this.resolveExistingProjectDir(projectId, containerRoots, {
      includeProjectsRoot: false
    });
    if (existingDir && !isBuiltinHomeProjectId(projectId)) {
      return existingDir;
    }
    const workspaceRoot = containerRoots[0] ?? await this.getWorkspaceRoot();
    return this.resolveWorkspaceChildPath(path.join(workspaceRoot, PROJECTS_DIR), projectId);
  }

  private async getProjectContainerRoots(): Promise<string[]> {
    const workspaceRoot = await this.getWorkspaceRoot();
    return [...new Set([
      workspaceRoot,
      path.join(workspaceRoot, "workspace"),
      path.join(workspaceRoot, PROJECTS_DIR)
    ].map((rootPath) => path.resolve(rootPath)))];
  }

  private async resolveExistingProjectDir(
    projectId: string,
    containerRoots?: string[],
    options?: { includeProjectsRoot?: boolean }
  ): Promise<string | null> {
    const roots = options?.includeProjectsRoot === false
      ? (containerRoots ?? await this.getProjectContainerRoots()).filter((rootPath) => path.basename(rootPath) !== PROJECTS_DIR)
      : containerRoots ?? await this.getProjectContainerRoots();
    for (const rootPath of roots) {
      const projectDir = this.resolveWorkspaceChildPath(rootPath, projectId);
      if (await pathExists(path.join(projectDir, PROJECT_FILE))) {
        return projectDir;
      }
    }
    return null;
  }

  private async listWorkspaceSkills(
    projectName: string,
    projectUpdatedAt: string,
    boundSkillIds: Set<string>
  ): Promise<WorkspaceSkillSummary[]> {
    const workspaceRoot = await this.getWorkspaceRoot();
    const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
    const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
    const skills: WorkspaceSkillSummary[] = [];

    for (const entry of dirEntries) {
      if (!entry.isDirectory()) {
        continue;
      }

      const skillId = sanitizeSkillId(entry.name);
      if (boundSkillIds.size > 0 && !boundSkillIds.has(skillId)) {
        continue;
      }

      const skillDir = path.join(skillsRoot, entry.name);
      const resolvedFile = await this.resolveSkillFile(skillDir);
      skills.push(buildSkillSummary({
        skillId,
        name: entry.name,
        description: `Local skill from project ${projectName}.`,
        category: "project",
        projectUpdatedAt,
        resolvedFile
      }));
    }

    return skills;
  }

  private async readWorkspaceSkillIds(): Promise<Set<string>> {
    const workspaceRoot = await this.getWorkspaceRoot();
    const skillsRoot = path.join(workspaceRoot, SKILLS_DIR);
    const dirEntries = await readdir(skillsRoot, { withFileTypes: true }).catch(() => []);
    return new Set(
      dirEntries
        .filter((entry) => entry.isDirectory())
        .map((entry) => sanitizeSkillId(entry.name))
    );
  }

  private async listCuratedGenericSkills(projectUpdatedAt: string): Promise<WorkspaceSkillSummary[]> {
    const genericRoot = this.resolveCuratedSkillsRoot();
    if (!genericRoot) {
      return [];
    }

    const skills: WorkspaceSkillSummary[] = [];
    for (const skillId of CURATED_GENERIC_SKILL_IDS) {
      const definition = getCuratedGenericSkillDefinition(skillId);
      if (!definition) {
        continue;
      }

      const skillDir = path.join(genericRoot, skillId);
      const resolvedFile = await this.resolveSkillFile(skillDir);
      if (!resolvedFile) {
        continue;
      }
      skills.push(buildSkillSummary({
        skillId,
        name: definition.zhName,
        description: definition.zhDescription,
        category: "generic",
        projectUpdatedAt,
        resolvedFile
      }));
    }

    return skills;
  }

  private resolveCuratedSkillsRoot(): string | null {
    return this.qSkillsRoot ?? process.env.QJCLAW_SKILL_CATALOG_ROOT?.trim() ?? null;
  }

  private async resolveCuratedGenericSkillTarget(skillId: string): Promise<SkillExecutionTarget | undefined> {
    const definition = getCuratedGenericSkillDefinition(skillId);
    const genericRoot = this.resolveCuratedSkillsRoot();
    if (!definition || !genericRoot) {
      return undefined;
    }

    const skillDir = path.join(genericRoot, skillId);
    const resolvedFile = await this.resolveSkillFile(skillDir);
    if (!resolvedFile) {
      return undefined;
    }

    return {
      skillId,
      name: definition.zhName,
      fileName: resolvedFile.fileName,
      localPath: path.join(skillDir, resolvedFile.fileName)
    };
  }

  private async resolveSkillFile(skillDir: string): Promise<ResolvedSkillFile | null> {
    const files = await readdir(skillDir, { withFileTypes: true }).catch(() => []);
    const preferred = files.find((file) => file.isFile() && file.name === "SKILL.md");
    const fallback = files.find((file) => file.isFile() && /\.(md|txt)$/i.test(file.name));
    const resolved = preferred ?? fallback;
    if (!resolved) {
      return null;
    }

    return {
      skillDir,
      fileName: resolved.name
    };
  }

  private resolveWorkspaceChildPath(rootPath: string, childName: string): string {
    const trimmed = childName.trim();
    if (!trimmed || path.basename(trimmed) !== trimmed) {
      throw new Error(`Invalid workspace child name: ${childName}`);
    }

    const resolvedRoot = path.resolve(rootPath);
    const targetPath = path.resolve(resolvedRoot, trimmed);
    const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
    if (targetPath !== resolvedRoot && !targetPath.startsWith(rootPrefix)) {
      throw new Error(`Workspace child path escaped root: ${childName}`);
    }
    return targetPath;
  }

  private async getProjectSessionsPath(projectId: string): Promise<string> {
    return path.join(await this.getProjectDir(projectId), SESSIONS_FILE);
  }

  private async getSessionMessagesPath(sessionId: string): Promise<string> {
    const projectId = await this.resolveProjectIdForSession(sessionId);
    const fileName = `${createHash("sha1").update(sessionId).digest("hex")}.json`;
    return path.join(await this.getProjectDir(projectId), SESSION_MESSAGES_DIR, fileName);
  }
}

function uniqueSkillIds(values: Iterable<string>): string[] {
  return [...new Set([...values].map((skillId) => sanitizeSkillId(skillId)).filter(Boolean))];
}
