// mgrSNP.ts
// Settings manager plus static project registry/index helpers.
// Owns settings.json defaults/preferences/custom projects and maintains projectIndex.json.
// Does not own active project runtime state, display state, routes, or recorder policy.

import fs from "node:fs";
import path from "node:path";
import * as crypto from "node:crypto";
import {
  ROOT,
  SETTINGS_PATH,
  PROJECTS_DIR,
  PROJECT_INDEX_PATH
} from "./pathList";

export type CustomProjectRecord = {
  name: string;
  location: string;
};

export type ProjectRecord = {
  serialNo: string;
  name: string;
  location: string;
  path?: string;
  projectFile?: string;
  summary?: string;
  description?: string;
  source?: "projects" | "custom";
  indexedAt?: number;
};

export type ProjectIndex = {
  generatedAt: number;
  projectsRoot: string;
  projects: ProjectRecord[];
};

export type ProjectHealthIssue = {
  severity: "error" | "warning";
  code: string;
  name: string;
  location: string;
  serialNo: string;
  message: string;
};

export type ProjectHealthReport = {
  ok: boolean;
  projects: ProjectRecord[];
  issues: ProjectHealthIssue[];
};

export type ProjectIdentityResult = { ok: boolean; project?: ProjectRecord; error?: string; message?: string };

type ProjectIndexConfig = {
  projectsRoot: string;
  customProjects: CustomProjectRecord[];
};

export type AutoPlayHandling = {
  chatManifests: boolean;
  diagnosticRepair: boolean;
  enhancementSuggestions: boolean;
};

export type RecorderSettings = {
  shellAccess: "deny" | "standard" | "extended";
};

export type NVSettings = {
  lmStudioUrl: string;
  projectsRoot: string;
  customProjects: CustomProjectRecord[];
  autoPlayHandling: AutoPlayHandling;
  recorder: RecorderSettings;
  authTier: string;
  authEmail: string;
  authUserId: string;
  authStatus: "signed_out" | "signed_in";
  authorityStatus: "valid" | "pending_update" | "limited_pending_update";
  authorityMessage: string;
  authorityCheckedAt: number;
  server: { host: string; port: number; portMode: "random" | "fixed"; runtimePort: number };
  bridge: { type: "ngrok" | "none" | "custom"; url: string; autoStart: boolean };
  release: { channel: "beta"; version: string; autoUpdate: boolean };
  agreement: { version: string; acceptedAt: number; accepted: boolean };
};

const DEFAULT_SETTINGS: NVSettings = {
  lmStudioUrl: "http://127.0.0.1:1234",
  projectsRoot: "./projects",
  customProjects: [],
  autoPlayHandling: { chatManifests: false, diagnosticRepair: false, enhancementSuggestions: false },
  recorder: { shellAccess: "deny" },
  authTier: "limited",
  authEmail: "",
  authUserId: "",
  authStatus: "signed_out",
  authorityStatus: "limited_pending_update",
  authorityMessage: "startup validation pending",
  authorityCheckedAt: 0,
  server: { host: "127.0.0.1", port: 0, portMode: "random", runtimePort: 0 },
  bridge: { type: "ngrok", url: "", autoStart: false },
  release: { channel: "beta", version: "0.0.0", autoUpdate: true },
  agreement: { version: "beta-agreement-v1", acceptedAt: 0, accepted: false }
};


function readPackageVersion(): string {
  try {
    const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
    return String(pkg?.version || "0.0.0");
  } catch {
    return "0.0.0";
  }
}

function serialPrefixFromName(name: string): string {
  const letters = String(name || "Project").toUpperCase().replace(/[^A-Z0-9]+/g, "");
  return (letters || "PRJCT").slice(0, 6).padEnd(5, "X");
}

export function createProjectSerialNo(name = "Project"): string {
  const prefix = serialPrefixFromName(name);
  const number = String(Date.now() % 10000).padStart(4, "0");
  return `${prefix}-${number}`;
}

export function normalizeSerialNo(serial: unknown): string | null {
  const s = String(serial || "").trim();
  if (s.toLowerCase() === "system") return "system";
  if (/^[A-Z0-9]{5,8}-[0-9]{3,5}$/.test(s.toUpperCase())) return s.toUpperCase();
  if (/^[0-9a-f]{8}$/i.test(s)) return s.toLowerCase();
  return null;
}
function safeProjectName(name: string): string {
  return String(name || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
}

function isReservedProjectName(name: string): boolean {
  const key = safeProjectName(name);
  return key === "backstage" || key === "universal" || key === "system";
}

function normalizeProjectPath(p: string): string {
  const raw = String(p || "").trim().replace(/\\/g, "/");
  if (!raw) return "";
  if (/^[A-Za-z]:\//.test(raw) || raw.startsWith("/")) return path.normalize(raw).replace(/\\/g, "/");
  return `./${raw.replace(/^\.\/+/, "")}`.replace(/\/+/g, "/");
}

function absPath(p: string): string {
  const clean = normalizeProjectPath(p);
  return path.isAbsolute(clean) ? path.resolve(clean) : path.resolve(ROOT, clean);
}

function relPath(p: string): string {
  const clean = String(p || "").replace(/\\/g, "/");
  if (!clean) return "";
  if (!path.isAbsolute(clean)) return normalizeProjectPath(clean);
  return normalizeProjectPath(path.relative(ROOT, clean));
}

function readJsonFile(filePath: string, fallback: any): any {
  try { return JSON.parse(fs.readFileSync(filePath, "utf8")); } catch { return fallback; }
}

function writeJsonFile(filePath: string, value: unknown): void {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf8");
}

function normalizeCustomProjects(raw: any): CustomProjectRecord[] {
  const list = Array.isArray(raw?.customProjects) ? raw.customProjects : Array.isArray(raw?.projects) ? raw.projects : [];
  const out: CustomProjectRecord[] = [];
  const seen = new Set<string>();
  for (const item of list) {
    const name = String(item?.name || item?.Name || "").trim();
    const location = normalizeProjectPath(String(item?.location || item?.Location || item?.path || ""));
    if (!location || isReservedProjectName(name)) continue;
    if (location.toLowerCase().startsWith("./projects/")) continue;
    const key = location.toLowerCase();
    if (seen.has(key)) continue;
    seen.add(key);
    out.push({ name: name || path.basename(location), location });
  }
  return out.sort((a, b) => a.name.localeCompare(b.name));
}

function readProjectIndexConfig(): ProjectIndexConfig {
  const raw = readJsonFile(SETTINGS_PATH, {});
  return {
    projectsRoot: normalizeProjectPath(String(raw?.projectsRoot || "./projects")),
    customProjects: normalizeCustomProjects(raw)
  };
}

function projectJsonForFolder(folder: string): string {
  return path.join(absPath(folder), "project.json");
}

function cleanProjectDocument(doc: any, name: string, serialNo: string): any {
  const base = doc && typeof doc === "object" ? { ...doc } : {};
  delete base.location;
  delete base.path;
  const details = base?.details && typeof base.details === "object" ? { ...base.details } : {};
  delete details.location;
  delete details.path;
  return {
    ...base,
    name,
    serialNo,
    summary: String(base?.summary ?? details.summary ?? ""),
    notes: String(base?.notes ?? details.notes ?? ""),
    details: {
      ...details,
      name,
      serialNo,
      summary: String(base?.summary ?? details.summary ?? ""),
      notes: String(base?.notes ?? details.notes ?? "")
    }
  };
}

function ensureProjectJsonAt(folder: string, fallbackName: string): ProjectRecord | null {
  const location = normalizeProjectPath(folder);
  if (!location || isReservedProjectName(fallbackName)) return null;
  const absFolder = absPath(location);
  const projectFileAbs = path.join(absFolder, "project.json");
  try { fs.mkdirSync(absFolder, { recursive: true }); } catch { return null; }
  const existing = readJsonFile(projectFileAbs, {});
  const name = String(existing?.name || existing?.details?.name || fallbackName || path.basename(absFolder)).trim();
  if (isReservedProjectName(name)) return null;
  const serialNo = normalizeSerialNo(existing?.serialNo || existing?.details?.serialNo) || createProjectSerialNo(name);
  const cleanDoc = cleanProjectDocument(existing, name, serialNo);
  if (JSON.stringify(existing) !== JSON.stringify(cleanDoc)) writeJsonFile(projectFileAbs, cleanDoc);
  return {
    serialNo,
    name,
    location,
    path: location,
    projectFile: relPath(projectFileAbs),
    indexedAt: Date.now()
  };
}

function scanStandardProjectFolders(projectsRoot: string): ProjectRecord[] {
  const root = absPath(projectsRoot || "./projects");
  const out: ProjectRecord[] = [];
  try {
    fs.mkdirSync(root, { recursive: true });
    for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
      if (!entry.isDirectory() || entry.name.startsWith(".") || isReservedProjectName(entry.name)) continue;
      const location = normalizeProjectPath(path.relative(ROOT, path.join(root, entry.name)));
      const projectFile = path.join(root, entry.name, "project.json");
      if (!fs.existsSync(projectFile)) continue;
      const record = ensureProjectJsonAt(location, entry.name);
      if (record) out.push({ ...record, source: "projects" });
    }
  } catch {}
  return out;
}

function scanCustomProjectFolders(customProjects: CustomProjectRecord[]): ProjectRecord[] {
  const out: ProjectRecord[] = [];
  for (const custom of customProjects || []) {
    const location = normalizeProjectPath(custom.location);
    if (!location) continue;
    const record = ensureProjectJsonAt(location, custom.name || path.basename(location));
    if (record) out.push({ ...record, source: "custom" });
  }
  return out;
}

function dedupeProjects(projects: ProjectRecord[]): ProjectRecord[] {
  const bySerial = new Map<string, ProjectRecord>();
  for (const raw of projects) {
    const serialNo = normalizeSerialNo(raw.serialNo);
    const location = normalizeProjectPath(raw.location || raw.path || "");
    const name = String(raw.name || "").trim();
    if (!serialNo || !location || !name || isReservedProjectName(name)) continue;
    bySerial.set(serialNo, {
      ...raw,
      serialNo,
      name,
      location,
      path: location,
      projectFile: raw.projectFile || relPath(path.join(absPath(location), "project.json")),
      indexedAt: raw.indexedAt || Date.now()
    });
  }
  return [...bySerial.values()].sort((a, b) => a.name.localeCompare(b.name));
}

function readSettingsRaw(): NVSettings {
  try {
    const raw = JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf8"));

    return {
      ...DEFAULT_SETTINGS,
      ...raw,

      projectsRoot: normalizeProjectPath(String(raw?.projectsRoot || DEFAULT_SETTINGS.projectsRoot)),
      customProjects: normalizeCustomProjects(raw),

      autoPlayHandling: {
        ...DEFAULT_SETTINGS.autoPlayHandling,
        ...(raw.autoPlayHandling || {})
      },

      recorder: {
        shellAccess: ["deny", "standard", "extended"].includes(raw?.recorder?.shellAccess)
          ? raw.recorder.shellAccess
          : DEFAULT_SETTINGS.recorder.shellAccess
      },

      authTier: String(raw.authTier ?? DEFAULT_SETTINGS.authTier),
      authEmail: String(raw.authEmail ?? DEFAULT_SETTINGS.authEmail),
      authUserId: String(raw.authUserId ?? DEFAULT_SETTINGS.authUserId),
      authStatus: raw.authStatus === "signed_in" ? "signed_in" : "signed_out",

      authorityStatus: ["valid", "pending_update", "limited_pending_update"].includes(raw.authorityStatus)
        ? raw.authorityStatus
        : DEFAULT_SETTINGS.authorityStatus,

      authorityMessage: String(raw.authorityMessage ?? DEFAULT_SETTINGS.authorityMessage),
      authorityCheckedAt: Number(raw.authorityCheckedAt ?? 0),

      server: {
        host: String(raw.server?.host || DEFAULT_SETTINGS.server.host),
        port: Number(raw.server?.port || 0),
        portMode: raw.server?.portMode === "fixed" ? "fixed" : "random",
        runtimePort: Number(raw.server?.runtimePort || 0)
      },

      bridge: {
        type: raw.bridge?.type === "custom" || raw.bridge?.type === "none"
          ? raw.bridge.type
          : "ngrok",
        url: String(raw.bridge?.url || ""),
        autoStart: Boolean(raw.bridge?.autoStart || String(raw.bridge?.url || "").trim())
      },

      release: {
        channel: "beta",
        version: readPackageVersion(),
        autoUpdate: raw.release?.autoUpdate !== false
      },

      agreement: {
        version: String(raw.agreement?.version || DEFAULT_SETTINGS.agreement.version),
        acceptedAt: Number(raw.agreement?.acceptedAt || 0),
        accepted: Boolean(raw.agreement?.accepted && Number(raw.agreement?.acceptedAt || 0) > 0)
      }
    };
  } catch {
    return {
      ...DEFAULT_SETTINGS,
      release: {
        channel: "beta",
        version: readPackageVersion(),
        autoUpdate: true
      }
    };
  }
}

function writeSettingsRaw(next: NVSettings): void {
  const out: any = {
    ...next,
    projectsRoot: normalizeProjectPath(next.projectsRoot || "./projects"),
    customProjects: normalizeCustomProjects({ customProjects: next.customProjects || [] })
  };
  delete out.projects;
  delete out.activeProject;
  writeJsonFile(SETTINGS_PATH, out);
}

export function getSettings(): NVSettings { return readSettingsRaw(); }

export function saveSettings(patch: Partial<NVSettings>): NVSettings {
  const current = readSettingsRaw();
  const next: NVSettings = {
    ...current,
    ...patch,
    customProjects: patch.customProjects !== undefined ? normalizeCustomProjects({ customProjects: patch.customProjects }) : current.customProjects,
    autoPlayHandling: { ...current.autoPlayHandling, ...(patch.autoPlayHandling || {}) },
    recorder: patch.recorder !== undefined ? { shellAccess: ["deny", "standard", "extended"].includes((patch.recorder as any)?.shellAccess) ? (patch.recorder as any).shellAccess : current.recorder.shellAccess } : current.recorder,
    authTier: patch.authTier !== undefined ? String(patch.authTier || "limited") : current.authTier,
    authEmail: patch.authEmail !== undefined ? String(patch.authEmail || "") : current.authEmail,
    authUserId: patch.authUserId !== undefined ? String(patch.authUserId || "") : current.authUserId,
    authStatus: patch.authStatus === "signed_in" ? "signed_in" : patch.authStatus === "signed_out" ? "signed_out" : current.authStatus,
    authorityStatus: ["valid", "pending_update", "limited_pending_update"].includes(patch.authorityStatus as string) ? patch.authorityStatus as NVSettings["authorityStatus"] : current.authorityStatus,
    authorityMessage: patch.authorityMessage !== undefined ? String(patch.authorityMessage || "") : current.authorityMessage,
    authorityCheckedAt: patch.authorityCheckedAt !== undefined ? Number(patch.authorityCheckedAt || 0) : current.authorityCheckedAt,
    server: patch.server !== undefined ? { host: String(patch.server?.host || current.server?.host || "127.0.0.1"), port: Number(patch.server?.port || 0), portMode: patch.server?.portMode === "fixed" ? "fixed" : "random", runtimePort: Number(patch.server?.runtimePort || current.server?.runtimePort || 0) } : current.server,
    bridge: patch.bridge !== undefined ? { type: patch.bridge?.type === "custom" || patch.bridge?.type === "none" ? patch.bridge.type : "ngrok", url: String(patch.bridge?.url || ""), autoStart: Boolean(patch.bridge?.autoStart || String(patch.bridge?.url || "").trim()) } : current.bridge,
    release: {
      channel: "beta",
      version: readPackageVersion(),
      autoUpdate: patch.release?.autoUpdate !== undefined
        ? patch.release.autoUpdate !== false
        : current.release?.autoUpdate !== false
    },
    agreement: patch.agreement !== undefined ? { version: String(patch.agreement?.version || current.agreement?.version || DEFAULT_SETTINGS.agreement.version), acceptedAt: Number(patch.agreement?.acceptedAt || current.agreement?.acceptedAt || 0), accepted: Boolean(patch.agreement?.accepted && Number(patch.agreement?.acceptedAt || current.agreement?.acceptedAt || 0) > 0) } : current.agreement
  };
  writeSettingsRaw(next);
  updateProjectIndex();
  return next;
}

export function getAutoPlayHandling(): AutoPlayHandling { return readSettingsRaw().autoPlayHandling; }
export function isAutoAcceptEnabled(): boolean { const a = readSettingsRaw().autoPlayHandling; return a.chatManifests || a.diagnosticRepair || a.enhancementSuggestions; }
export function setAuthTier(tier: string): void { writeSettingsRaw({ ...readSettingsRaw(), authTier: String(tier || "limited") }); }
export function getAuthTier(): string { return readSettingsRaw().authTier ?? "limited"; }
export function setAuthSummary(params: { email: string; userId: string; tier: string; status: "signed_out" | "signed_in" }): void { const c = readSettingsRaw(); writeSettingsRaw({ ...c, authEmail: String(params.email || ""), authUserId: String(params.userId || ""), authTier: String(params.tier || "limited"), authStatus: params.status === "signed_in" ? "signed_in" : "signed_out" }); }
export function clearAuthSummary(): void { const c = readSettingsRaw(); writeSettingsRaw({ ...c, authEmail: "", authUserId: "", authTier: "limited", authStatus: "signed_out" }); }

export function updateProjectIndex(): ProjectIndex {
  const config = readProjectIndexConfig();
  const projects = dedupeProjects([
    ...scanStandardProjectFolders(config.projectsRoot || "./projects"),
    ...scanCustomProjectFolders(config.customProjects)
  ]);
  const index: ProjectIndex = { generatedAt: Date.now(), projectsRoot: normalizeProjectPath(config.projectsRoot || "./projects"), projects };
  writeJsonFile(PROJECT_INDEX_PATH, index);
  return index;
}

export function readProjectIndex(): ProjectIndex {
  const existing = readJsonFile(PROJECT_INDEX_PATH, null);
  if (existing && Array.isArray(existing.projects)) {
    return {
      generatedAt: Number(existing.generatedAt || 0),
      projectsRoot: normalizeProjectPath(existing.projectsRoot || "./projects"),
      projects: dedupeProjects(existing.projects)
    };
  }
  return updateProjectIndex();
}

export function projectLookup(query: { all?: boolean; serialNo?: string; name?: string; path?: string } = {}): { projects: ProjectRecord[]; project: ProjectRecord | null } {
  const projects = readProjectIndex().projects;
  if (query.all) return { projects, project: null };
  const serial = normalizeSerialNo(query.serialNo);
  const nameKey = safeProjectName(String(query.name || ""));
  const pathKey = normalizeProjectPath(String(query.path || "")).toLowerCase();
  const project = projects.find(p =>
    (serial && p.serialNo === serial) ||
    (nameKey && safeProjectName(p.name) === nameKey) ||
    (pathKey && normalizeProjectPath(p.location || p.path || "").toLowerCase() === pathKey)
  ) || null;
  return { projects, project };
}

export function getEligibleProjectFolders(): Array<{ name: string; location: string; hasProjectFile: boolean; registered: boolean }> {
  const indexed = readProjectIndex().projects;
  const registered = new Set(indexed.map(p => normalizeProjectPath(p.location).toLowerCase()));
  const folders: Array<{ name: string; location: string; hasProjectFile: boolean; registered: boolean }> = [];
  try {
    fs.mkdirSync(PROJECTS_DIR, { recursive: true });
    for (const entry of fs.readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
      if (!entry.isDirectory() || entry.name.startsWith(".") || isReservedProjectName(entry.name)) continue;
      const loc = normalizeProjectPath(`./projects/${entry.name}`);
      folders.push({ name: entry.name, location: loc, hasProjectFile: fs.existsSync(projectJsonForFolder(loc)), registered: registered.has(loc.toLowerCase()) });
    }
  } catch {}
  for (const p of indexed) {
    if (!folders.some(f => f.location.toLowerCase() === p.location.toLowerCase())) folders.push({ name: p.name, location: p.location, hasProjectFile: fs.existsSync(projectJsonForFolder(p.location)), registered: true });
  }
  return folders.sort((a, b) => a.location.localeCompare(b.location));
}

export function renameProject(currentName: string, nextName: string): ProjectIdentityResult {
  const project = projectLookup({ name: currentName }).project;
  const displayName = String(nextName || "").trim();
  if (!project) return { ok: false, error: "project_not_found", message: "Project not found." };
  if (!displayName || isReservedProjectName(displayName)) return { ok: false, error: "project_name_reserved", message: "That name is reserved." };
  const file = projectJsonForFolder(project.location);
  const doc = readJsonFile(file, {});
  writeJsonFile(file, cleanProjectDocument(doc, displayName, project.serialNo));
  const updated = updateProjectIndex().projects.find(p => p.serialNo === project.serialNo) || { ...project, name: displayName };
  return { ok: true, project: updated };
}

export function repairProjectLocation(projectName: string, nextLocation: string): ProjectRecord | null {
  const project = projectLookup({ name: projectName }).project;
  const location = normalizeProjectPath(nextLocation);
  if (!project || !location) return null;

  const oldLocation = normalizeProjectPath(project.location);
  const oldFolder = absPath(oldLocation);
  const nextFolder = absPath(location);
  const oldFile = projectJsonForFolder(oldLocation);
  const nextFile = projectJsonForFolder(location);
  const oldDoc = readJsonFile(oldFile, {});
  const existingNext = readJsonFile(nextFile, null);
  const existingNextSerial = normalizeSerialNo(existingNext?.serialNo || existingNext?.details?.serialNo);
  if (existingNextSerial && existingNextSerial !== project.serialNo) return null;

  if (oldFolder !== nextFolder && fs.existsSync(oldFolder) && !fs.existsSync(nextFolder)) {
    fs.mkdirSync(path.dirname(nextFolder), { recursive: true });
    fs.renameSync(oldFolder, nextFolder);
  } else {
    fs.mkdirSync(nextFolder, { recursive: true });
  }

  writeJsonFile(nextFile, cleanProjectDocument(oldDoc, project.name, project.serialNo));

  const settings = readSettingsRaw();
  const projectsRoot = normalizeProjectPath(settings.projectsRoot || "./projects").toLowerCase();
  const nextKey = location.toLowerCase();
  const oldKey = oldLocation.toLowerCase();
  const isStandardLocation = nextKey.startsWith(`${projectsRoot.replace(/\/$/, "")}/`);
  const customProjects = settings.customProjects || [];
  const existingCustomIndex = customProjects.findIndex(item => normalizeProjectPath(item.location).toLowerCase() === oldKey || String(item.name || "").trim() === project.name);
  if (existingCustomIndex >= 0) {
    customProjects[existingCustomIndex] = { name: project.name, location };
    writeSettingsRaw({ ...settings, customProjects });
  } else if (!isStandardLocation) {
    writeSettingsRaw({ ...settings, customProjects: [...customProjects, { name: project.name, location }] });
  }

  return updateProjectIndex().projects.find(p => p.serialNo === project.serialNo) ?? null;
}
export function saveProjectFile(projectName: string, content: string): { filePath: string } | null {
  const project = projectLookup({ name: projectName }).project;
  if (!project) return null;
  const filePath = projectJsonForFolder(project.location);
  try {
    const doc = JSON.parse(content);
    const name = String(doc?.name || doc?.details?.name || project.name).trim();
    const serialNo = normalizeSerialNo(doc?.serialNo || doc?.details?.serialNo) || project.serialNo || createProjectSerialNo(name);
    if (isReservedProjectName(name)) return null;
    writeJsonFile(filePath, cleanProjectDocument(doc, name, serialNo));
    updateProjectIndex();
    return { filePath: relPath(filePath) };
  } catch {
    return null;
  }
}

export function getProjectHealthReport(): ProjectHealthReport {
  const projects = readProjectIndex().projects;
  const issues: ProjectHealthIssue[] = [];
  const serials = new Map<string, ProjectRecord>();
  const names = new Map<string, ProjectRecord>();
  for (const p of projects) {
    if (!fs.existsSync(absPath(p.location))) issues.push({ severity: "error", code: "project_location_missing_on_disk", name: p.name, location: p.location, serialNo: p.serialNo, message: "Project location does not exist on disk." });
    if (!fs.existsSync(projectJsonForFolder(p.location))) issues.push({ severity: "error", code: "project_json_missing", name: p.name, location: p.location, serialNo: p.serialNo, message: "Project folder is missing project.json." });
    if (serials.has(p.serialNo)) issues.push({ severity: "error", code: "project_serial_duplicate", name: p.name, location: p.location, serialNo: p.serialNo, message: "Project serialNo duplicates another project." });
    else serials.set(p.serialNo, p);
    const key = safeProjectName(p.name);
    if (key && names.has(key)) issues.push({ severity: "warning", code: "project_name_duplicate", name: p.name, location: p.location, serialNo: p.serialNo, message: "Project name duplicates another project name." });
    else if (key) names.set(key, p);
  }
  return { ok: !issues.some(i => i.severity === "error"), projects, issues };
}

export function toProjectFolderName(name: string): string { return isReservedProjectName(name) ? "" : safeProjectName(name); }
export function toProjectLocation(name: string): string { return normalizeProjectPath(`./projects/${toProjectFolderName(name)}`); }
