/**
 * Context loader: prints PRODUCT.md (and DESIGN.md if present) as one
 * markdown block on stdout, or prints a `NO_PRODUCT_MD:` message when no
 * PRODUCT.md is found anywhere. The skill keys off that message to branch:
 * from-scratch build commands (init / teach / craft / shape) and clear
 * build/shape intent divert into the init flow, while scoped commands proceed
 * using the existing code as context.
 *
 * Path resolution (first match wins):
 *   1. Active project root, if PRODUCT.md or DESIGN.md is there
 *   2. Active project .agents/context/ then docs/
 *   3. Monorepo root context, using the same order, as a per-file fallback
 *   4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) — power-user
 *      escape hatch, only consulted when defaults are empty
 *   5. Active project root as a "nothing found" default
 *
 * `resolveContextDir()` and `loadContext()` are also exported for the
 * server-side scripts (live.mjs, live-server.mjs) that need the structured
 * shape rather than the markdown block.
 */
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseTargetOptions } from './lib/target-args.mjs';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';

const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const FALLBACK_DIRS = ['.agents/context', 'docs'];
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
  'node_modules',
  '.git',
  'dist',
  'build',
  '.next',
  '.nuxt',
  '.svelte-kit',
  '.turbo',
  '.cache',
  'coverage',
]);

// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.

const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, '');
const UPDATE_CACHE_PATH =
  process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json');
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
const FETCH_TIMEOUT_MS = 1200;

export function resolveContextDir(cwd = process.cwd(), options = {}) {
  return resolveContext(cwd, options).contextDir;
}

export function loadContext(cwd = process.cwd(), options = {}) {
  const resolved = resolveContext(cwd, options);
  const absCwd = path.resolve(cwd);
  const productPath = resolved.productPath;
  const designPath = resolved.designPath;
  const product = productPath ? safeRead(productPath) : null;
  const design = designPath ? safeRead(designPath) : null;
  return {
    hasProduct: !!product,
    product,
    productPath: productPath ? path.relative(absCwd, productPath) : null,
    hasDesign: !!design,
    design,
    designPath: designPath ? path.relative(absCwd, designPath) : null,
    contextDir: resolved.contextDir,
    productContextDir: productPath ? path.dirname(productPath) : null,
    designContextDir: designPath ? path.dirname(designPath) : null,
    projectRoot: resolved.projectRoot,
    repoRoot: resolved.repoRoot,
    isMonorepo: resolved.isMonorepo,
  };
}

function resolveContext(cwd = process.cwd(), options = {}) {
  const absCwd = path.resolve(cwd);
  const project = resolveProject(absCwd, options);
  const projectContextDir = resolveLocalContextDir(project.projectRoot);
  const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
    ? resolveLocalContextDir(project.repoRoot)
    : null;

  let productPath =
    (projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
    || (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
  let designPath =
    (projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
    || (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);

  let envContextDir = null;
  if (!productPath && !designPath) {
    envContextDir = resolveEnvContextDir(absCwd);
    if (envContextDir) {
      productPath = firstExisting(envContextDir, PRODUCT_NAMES);
      designPath = firstExisting(envContextDir, DESIGN_NAMES);
    }
  }

  return {
    contextDir: productPath
      ? path.dirname(productPath)
      : designPath
        ? path.dirname(designPath)
        : envContextDir || project.projectRoot,
    productPath,
    designPath,
    projectRoot: project.projectRoot,
    repoRoot: project.repoRoot,
    isMonorepo: project.isMonorepo,
    targetDir: project.targetDir,
  };
}

export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
  return resolveProject(cwd, options).projectRoot;
}

export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
  if (hasTargetOption(options)) return null;
  const project = resolveProject(cwd);
  if (
    !project.isMonorepo
    || !project.projectRoot
    || !project.repoRoot
    || path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
  ) {
    return null;
  }
  const targetCandidates = discoverTargetCandidates(project.repoRoot);
  // No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
  // or a marker file with no apps/packages children): there is nothing to choose,
  // so treat the repo root as the active project rather than blocking on an empty
  // selection prompt that the user cannot answer.
  if (targetCandidates.length === 0) return null;
  return {
    targetPath: null,
    projectRoot: project.projectRoot,
    repoRoot: project.repoRoot,
    targetCandidates,
  };
}

function resolveProject(cwd = process.cwd(), options = {}) {
  const absCwd = path.resolve(cwd);
  const targetDir = resolveTargetDir(absCwd, options);
  let repoRoot = findMonorepoRoot(targetDir);
  if (!repoRoot && targetDir !== absCwd) {
    const cwdRepoRoot = findMonorepoRoot(absCwd);
    if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
      repoRoot = cwdRepoRoot;
    }
  }
  if (!repoRoot) {
    return {
      targetDir,
      projectRoot: absCwd,
      repoRoot: absCwd,
      isMonorepo: false,
    };
  }
  return {
    targetDir,
    projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
    repoRoot,
    isMonorepo: true,
  };
}

function isPathInside(candidate, root) {
  const rel = path.relative(root, candidate);
  return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}

function resolveLocalContextDir(root) {
  if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
    return root;
  }
  for (const rel of FALLBACK_DIRS) {
    const candidate = path.resolve(root, rel);
    if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
      return candidate;
    }
  }
  return null;
}

function resolveEnvContextDir(cwd) {
  const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
  if (!envDir || !envDir.trim()) return null;
  const trimmed = envDir.trim();
  return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}

function resolveTargetDir(cwd, options = {}) {
  const targetPath = options && typeof options === 'object' ? options.targetPath : null;
  if (!targetPath || !String(targetPath).trim()) return cwd;
  const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
  try {
    const stat = fs.statSync(abs);
    return stat.isDirectory() ? abs : path.dirname(abs);
  } catch {
    return path.extname(abs) ? path.dirname(abs) : abs;
  }
}

function findMonorepoRoot(startDir) {
  let dir = path.resolve(startDir);
  const homeDir = path.resolve(os.homedir());
  while (true) {
    if (dir === homeDir) return null;
    // isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
    // root that also carries its own .git is still recognized. The trade-off is
    // deliberate — a directory with a monorepo *marker* but no workspace patterns
    // and no apps/packages children is not a monorepo root, so its .git stops
    // traversal and a further-up root is not searched. The nested .git is treated
    // as an independent project boundary, which is the intended isolation.
    if (isMonorepoRoot(dir)) return dir;
    if (hasGitBoundary(dir)) return null;
    const parent = path.dirname(dir);
    if (parent === dir) return null;
    dir = parent;
  }
}

function isMonorepoRoot(dir) {
  if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
  if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
  return hasFallbackWorkspaceChildren(dir);
}

function hasGitBoundary(dir) {
  return fs.existsSync(path.join(dir, '.git'));
}

function hasFallbackWorkspaceChildren(dir) {
  for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
    const base = path.join(dir, name);
    let entries;
    try {
      entries = fs.readdirSync(base, { withFileTypes: true });
    } catch {
      continue;
    }
    if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
  }
  return false;
}

function discoverTargetCandidates(repoRoot) {
  const roots = new Map();
  const patterns = readWorkspacePatterns(repoRoot);
  for (const pattern of patterns) {
    for (const root of discoverRootsForPattern(repoRoot, pattern)) {
      roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
    }
  }
  if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
    for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
      const base = path.join(repoRoot, name);
      let entries;
      try {
        entries = fs.readdirSync(base, { withFileTypes: true });
      } catch {
        continue;
      }
      for (const entry of entries) {
        if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
        const root = path.join(base, entry.name);
        roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
      }
    }
  }
  return [...roots.entries()]
    .filter(([rel]) => rel && !rel.startsWith('..'))
    // Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
    // sends an excluded package back to the repo root, so an excluded folder must not appear as a
    // selectable target — choosing it would silently resolve to the root instead.
    .filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([rel, root]) => {
      const targetExample = findTargetExample(repoRoot, root);
      return {
        name: path.basename(root),
        path: rel,
        targetExample,
        ...resolveCandidateContextSummary(repoRoot, root, targetExample),
      };
    });
}

function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
  const ctx = resolveContext(repoRoot, { targetPath });
  return {
    productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
    productPath: contextSourcePath(ctx.productPath, repoRoot),
    designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
    designPath: contextSourcePath(ctx.designPath, repoRoot),
  };
}

// Selection candidates surface one of four statuses: 'child' (a canonical
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
// intentionally covers two non-canonical locations: a file inside the project
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
function contextSourceStatus(filePath, repoRoot, projectRoot) {
  if (!filePath) return 'missing';
  const absPath = path.resolve(filePath);
  const absProjectRoot = path.resolve(projectRoot);
  const absRepoRoot = path.resolve(repoRoot);
  if (isPathInsideOrEqual(absPath, absProjectRoot)) {
    return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
  }
  if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
    return 'inherited';
  }
  return 'fallback';
}

function contextSourcePath(filePath, repoRoot) {
  if (!filePath) return null;
  const rel = path.relative(repoRoot, filePath);
  if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
    return rel.split(path.sep).join('/');
  }
  return filePath;
}

function discoverRootsForPattern(repoRoot, rawPattern) {
  const pattern = normalizeWorkspacePattern(rawPattern);
  if (!pattern || pattern.startsWith('!')) return [];
  const segments = pattern.split('/').filter(Boolean);
  if (!segments.length) return [];
  const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
  const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
  const base = path.join(repoRoot, ...literalPrefix);
  if (!fs.existsSync(base)) return [];
  if (segments.includes('**')) {
    const packageRoots = [];
    walkDirs(base, (dir) => {
      if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
    });
    if (packageRoots.length) return packageRoots;
    return directChildDirs(base);
  }
  return expandSimplePattern(repoRoot, segments);
}

function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
  if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
  const segment = patternSegments[index];
  if (!segment.includes('*')) {
    return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
  }
  let entries;
  try {
    entries = fs.readdirSync(current, { withFileTypes: true });
  } catch {
    return [];
  }
  const roots = [];
  for (const entry of entries) {
    if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
    if (!segmentMatches(segment, entry.name)) continue;
    roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
  }
  return roots;
}

function directChildDirs(dir) {
  try {
    return fs.readdirSync(dir, { withFileTypes: true })
      .filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
      .map((entry) => path.join(dir, entry.name));
  } catch {
    return [];
  }
}

function walkDirs(root, visit) {
  let entries;
  try {
    entries = fs.readdirSync(root, { withFileTypes: true });
  } catch {
    return;
  }
  for (const entry of entries) {
    if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
    const dir = path.join(root, entry.name);
    visit(dir);
    walkDirs(dir, visit);
  }
}

function isCandidateProjectRoot(dir) {
  return !!(
    fs.existsSync(path.join(dir, 'package.json'))
    || firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
    || fs.existsSync(path.join(dir, 'src'))
    || fs.existsSync(path.join(dir, 'app'))
    || fs.existsSync(path.join(dir, 'pages'))
    || fs.existsSync(path.join(dir, 'public'))
  );
}

function isIgnoredWorkspaceDiscoveryDir(name) {
  return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
}

function findTargetExample(repoRoot, projectRoot) {
  const examples = [
    'src/App.jsx',
    'src/App.tsx',
    'src/main.jsx',
    'src/main.tsx',
    'src/index.jsx',
    'src/index.ts',
    'app/page.tsx',
    'pages/index.tsx',
    'public/index.html',
  ];
  for (const rel of examples) {
    const abs = path.join(projectRoot, rel);
    if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
  }
  return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
}

function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
  const rel = path.relative(repoRoot, targetDir);
  if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
  const relSegments = rel.split(path.sep).filter(Boolean);
  const patterns = readWorkspacePatterns(repoRoot);
  const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
  if (!excluded) {
    for (const pattern of patterns) {
      const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
      if (projectRoot) return projectRoot;
    }
  }
  if (excluded) return repoRoot;
  if (
    relSegments.length >= 2
    && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
  ) {
    return path.join(repoRoot, relSegments[0], relSegments[1]);
  }
  const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
  if (nearest) return nearest;
  return repoRoot;
}

function isExcludedByWorkspacePattern(relSegments, patterns) {
  return patterns.some((rawPattern) => {
    const pattern = normalizeWorkspacePattern(rawPattern);
    if (!pattern.startsWith('!')) return false;
    return workspacePatternMatchesRel(pattern.slice(1), relSegments);
  });
}

function nearestProjectLikeRoot(repoRoot, targetDir) {
  let dir = path.resolve(targetDir);
  const stop = path.resolve(repoRoot);
  while (dir && dir !== stop) {
    if (
      firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
      || fs.existsSync(path.join(dir, 'package.json'))
    ) {
      return dir;
    }
    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  return null;
}

function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
  let dir = path.resolve(targetDir);
  const stop = path.resolve(stopDir || repoRoot);
  const root = path.resolve(repoRoot);
  while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
    if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
    const parent = path.dirname(dir);
    if (parent === dir) break;
    dir = parent;
  }
  return null;
}

function isPathInsideOrEqual(candidate, root) {
  return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
}

function workspacePatternMatchesRel(pattern, relSegments) {
  const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
  if (!patternSegments.length) return false;
  if (patternSegments.includes('**')) {
    const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
    const literalPrefix = firstGlobIndex === -1
      ? patternSegments
      : patternSegments.slice(0, firstGlobIndex);
    if (relSegments.length < literalPrefix.length + 1) return false;
    for (let i = 0; i < literalPrefix.length; i++) {
      if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
    }
    return true;
  }
  if (relSegments.length < patternSegments.length) return false;
  for (let i = 0; i < patternSegments.length; i++) {
    if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
  }
  return true;
}

function readWorkspacePatterns(repoRoot) {
  return [
    ...readPackageWorkspaces(repoRoot),
    ...readPnpmWorkspaces(repoRoot),
    ...readLernaWorkspaces(repoRoot),
  ].filter(Boolean);
}

function readPackageWorkspaces(repoRoot) {
  const pkg = readJson(path.join(repoRoot, 'package.json'));
  const workspaces = pkg?.workspaces;
  if (Array.isArray(workspaces)) return workspaces;
  if (Array.isArray(workspaces?.packages)) return workspaces.packages;
  return [];
}

function readLernaWorkspaces(repoRoot) {
  const lerna = readJson(path.join(repoRoot, 'lerna.json'));
  return Array.isArray(lerna?.packages) ? lerna.packages : [];
}

function readPnpmWorkspaces(repoRoot) {
  try {
    const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
    const patterns = [];
    let inPackages = false;
    for (const line of body.split(/\r?\n/)) {
      const trimmed = stripYamlInlineComment(line).trim();
      if (!trimmed || trimmed.startsWith('#')) continue;
      const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
      if (flowMatch) {
        patterns.push(...parseYamlFlowList(flowMatch[1]));
        inPackages = false;
        continue;
      }
      if (/^packages:\s*$/.test(trimmed)) {
        inPackages = true;
        continue;
      }
      if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
      if (inPackages) {
        const match = trimmed.match(/^-\s*(.+)$/);
        if (match) patterns.push(unquoteYamlValue(match[1]));
      }
    }
    return patterns;
  } catch {
    return [];
  }
}

function stripYamlInlineComment(line) {
  let quote = null;
  for (let i = 0; i < line.length; i++) {
    const ch = line[i];
    if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
      quote = quote === ch ? null : quote || ch;
      continue;
    }
    if (ch === '#' && !quote) return line.slice(0, i);
  }
  return line;
}

function parseYamlFlowList(body) {
  const items = [];
  let quote = null;
  let current = '';
  for (let i = 0; i < body.length; i++) {
    const ch = body[i];
    if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
      quote = quote === ch ? null : quote || ch;
      current += ch;
      continue;
    }
    if (ch === ',' && !quote) {
      const value = unquoteYamlValue(current);
      if (value) items.push(value);
      current = '';
      continue;
    }
    current += ch;
  }
  const value = unquoteYamlValue(current);
  if (value) items.push(value);
  return items;
}

function unquoteYamlValue(value) {
  return String(value || '')
    .trim()
    .replace(/^['"]|['"]$/g, '');
}

function readJson(filePath) {
  try {
    return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
  } catch {
    return null;
  }
}

function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
  const pattern = normalizeWorkspacePattern(rawPattern);
  if (!pattern || pattern.startsWith('!')) return null;
  const patternSegments = pattern.split('/').filter(Boolean);
  if (!patternSegments.length) return null;
  if (patternSegments.includes('**')) {
    return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
  }
  if (relSegments.length < patternSegments.length) return null;
  for (let i = 0; i < patternSegments.length; i++) {
    if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
  }
  return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
}

function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
  const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
  const literalPrefix = firstGlobIndex === -1
    ? patternSegments
    : patternSegments.slice(0, firstGlobIndex);
  if (relSegments.length < literalPrefix.length + 1) return null;
  for (let i = 0; i < literalPrefix.length; i++) {
    if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
  }
  const prefixDir = path.join(repoRoot, ...literalPrefix);
  const targetDir = path.join(repoRoot, ...relSegments);
  const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
  if (packageRoot) return packageRoot;
  return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
}

function normalizeWorkspacePattern(pattern) {
  return String(pattern || '')
    .trim()
    .replace(/^['"]|['"]$/g, '')
    .replace(/^\.\//, '')
    .replace(/\/+$/, '');
}

function segmentMatches(patternSegment, relSegment) {
  if (patternSegment === '*') return true;
  if (!patternSegment.includes('*')) return patternSegment === relSegment;
  const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
  return re.test(relSegment);
}

function firstExisting(dir, names) {
  for (const name of names) {
    const abs = path.join(dir, name);
    if (fs.existsSync(abs)) return abs;
  }
  return null;
}

function safeRead(p) {
  try {
    return fs.readFileSync(p, 'utf-8');
  } catch {
    return null;
  }
}

function escapeRegExp(value) {
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
 * Read the first non-empty line under a bare `## <heading>` section of
 * PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
 * section is absent. The heading match is exact (`\s*$`) so near-miss
 * headings like `## Register guidelines` don't shadow the real field.
 */
export function extractSectionValue(product, heading) {
  if (!product) return null;
  const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
  const lines = product.split('\n');
  for (let i = 0; i < lines.length; i++) {
    if (headingRe.test(lines[i].trim())) {
      for (let j = i + 1; j < lines.length; j++) {
        const next = lines[j].trim();
        // A new heading before any value means the section is empty.
        if (/^#{1,6}\s/.test(next)) return null;
        if (next) return next;
      }
    }
  }
  return null;
}

/**
 * Pull the register (`brand` or `product`) out of PRODUCT.md by looking
 * for a `## Register` section and reading the first non-empty line that
 * follows it. Returns null when the file is legacy / register-less.
 */
export function extractRegister(product) {
  const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
  return word === 'brand' || word === 'product' ? word : null;
}

/**
 * Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
 * by looking for a `## Platform` section and reading the first non-empty line
 * that follows it. `adaptive` is for cross-platform apps (Flutter, React
 * Native) that ship both iOS and Android from one codebase; a line that names
 * both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
 * when the file is legacy / platform-less, which the skill treats as `web`
 * (the default the general rules already assume).
 */
export function extractPlatform(product) {
  const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
  if (!value) return null;
  if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
  // A short list naming both native targets (`ios, android`, `ios and
  // android`) = adaptive. Only list separators and the two platform words may
  // appear; anything else (prose, negations) is unrecognized and falls
  // through to the CLI's WARNING path.
  const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
  if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
    && tokens.includes('ios') && tokens.includes('android')) {
    return 'adaptive';
  }
  return null;
}

/**
 * Read the installed skill's own version from the sibling SKILL.md frontmatter
 * (this file lives at `<skill>/scripts/context.mjs`). Returns null when the
 * frontmatter is missing or unreadable.
 */
function readLocalSkillVersion() {
  try {
    const here = path.dirname(fileURLToPath(import.meta.url));
    const skillMd = path.join(here, '..', 'SKILL.md');
    const content = fs.readFileSync(skillMd, 'utf-8');
    const match = content.match(/^version:\s*(.+)$/m);
    return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
  } catch {
    return null;
  }
}

function readUpdateCache() {
  try {
    return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8'));
  } catch {
    return {};
  }
}

function writeUpdateCache(cache) {
  try {
    fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });
    fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));
  } catch {
    // Best-effort: a read-only home dir just means we re-poll next session.
  }
}

/** Compare dotted numeric versions. Returns >0 when a is newer than b. */
function compareSemver(a, b) {
  const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
  const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
    const diff = (pa[i] || 0) - (pb[i] || 0);
    if (diff !== 0) return diff;
  }
  return 0;
}

async function fetchLatestSkillVersion() {
  try {
    const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
    if (!res.ok) return null;
    const data = await res.json();
    return typeof data?.skills === 'string' ? data.skills : null;
  } catch {
    return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal
  }
}

function buildUpdateDirective(localVersion, latestVersion) {
  return (
    `UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
    `(installed v${localVersion}, latest v${latestVersion}). ` +
    `Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
    `Update now? It runs \`npx impeccable update\`." ` +
    `If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
    `Either way, continue the current task without waiting, and do not raise this again.`
  );
}

/**
 * Best-effort update directive for the boot output. Returns a string to append
 * or null. Polls the version endpoint at most once per day (cached globally in
 * the user's home dir) and re-surfaces a given version at most once per week so
 * the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
 */
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
  let value;
  for (const name of ['config.json', 'config.local.json']) {
    try {
      const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
      if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
    } catch { /* missing or malformed: ignore */ }
  }
  return value === false;
}

async function computeUpdateDirective(now = Date.now()) {
  try {
    if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
    if (updateCheckDisabledByConfig()) return null;
    const localVersion = readLocalSkillVersion();
    if (!localVersion) return null;

    const cache = readUpdateCache();

    // Poll the network only when the throttle window has elapsed. Stamp
    // lastCheck even on failure so an offline machine doesn't poll every boot.
    if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) {
      const latest = await fetchLatestSkillVersion();
      cache.lastCheck = now;
      if (latest) cache.latestVersion = latest;
      writeUpdateCache(cache);
    }

    const latest = cache.latestVersion;
    if (!latest || compareSemver(latest, localVersion) <= 0) return null;

    // Anti-nag: surface a given version at most once per RENOTIFY window.
    if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) {
      return null;
    }
    cache.notifiedVersion = latest;
    cache.notifiedAt = now;
    writeUpdateCache(cache);

    return buildUpdateDirective(localVersion, latest);
  } catch {
    return null;
  }
}

async function cli() {
  let cliOptions;
  try {
    cliOptions = parseCliOptions(process.argv.slice(2));
  } catch (err) {
    if (err?.name === 'TargetArgError') {
      process.stderr.write(`${err.message}\n`);
      process.exit(1);
    }
    throw err;
  }
  const targetProvided = hasTargetOption(cliOptions);
  const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
  const selection = resolveTargetSelection(process.cwd(), cliOptions);
  if (selection) {
    process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
    process.exit(0);
  }
  const ctx = loadContext(process.cwd(), cliOptions);
  const updateDirective = await computeUpdateDirective();

  if (!ctx.hasProduct) {
    // Direct stdout message instead of relying on empty output as a signal
    // — cheap models miss the empty case more often than the explicit one.
    const parts = [
      'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +
      'Follow SKILL.md Setup step 1: for `init`, `teach`, `craft`, `shape`, ' +
      'or wording that clearly maps to a from-scratch build/shape flow, load ' +
      'reference/init.md and write PRODUCT.md first; for any other (scoped) ' +
      'command against existing code, proceed using the code as context and ' +
      `offer \`${IMPECCABLE_COMMAND} init\` as a suggestion (do not block).`,
    ];
    parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
    if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
      parts.push(buildMissingTargetDirective());
    }
    if (updateDirective) parts.push(updateDirective);
    process.stdout.write(parts.join('\n\n---\n\n') + '\n');
    process.exit(0);
  }
  const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
  if (ctx.hasDesign) {
    parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
  }
  parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
  if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
    parts.push(buildMissingTargetDirective());
  }
  const register = extractRegister(ctx.product);
  const next = register
    ? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
    : `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
  parts.push(next);
  const platform = extractPlatform(ctx.product);
  const nativeRefs =
    platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : [];
  if (nativeRefs.length) {
    const refList = nativeRefs.map(p => `\`reference/${p}.md\``).join(' and ');
    const label = platform === 'adaptive' ? '`adaptive` (both iOS and Android)' : `\`${platform}\``;
    parts.push(
      `NEXT STEP: This project targets ${label}. Also read ${refList} for native conventions, in addition to the register reference.`,
    );
  } else if (!platform) {
    // A `## Platform` section that names something we don't recognize (a
    // toolchain like `flutter`, a typo) would otherwise silently fall back to
    // web — the wrong default exactly when the user tried to say "native".
    const rawPlatform = extractSectionValue(ctx.product, 'Platform');
    if (rawPlatform) {
      parts.push(
        `WARNING: PRODUCT.md's \`## Platform\` value \`${rawPlatform}\` is not recognized; treating the project as \`web\`. Valid values are \`web\`, \`ios\`, \`android\`, or \`adaptive\` (cross-platform, ships both). If this project is native, fix the field (name the design language the app renders, not the toolchain) and surface it to the user.`,
      );
    }
  }
  if (updateDirective) parts.push(updateDirective);
  process.stdout.write(parts.join('\n\n---\n\n') + '\n');
}

function parseCliOptions(args) {
  return parseTargetOptions(args, { strict: true });
}

function hasTargetOption(options) {
  return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}

function pathExistsForTarget(cwd, targetPath) {
  const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
  return fs.existsSync(abs);
}

function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
  const targetPath = hasTargetOption(options) ? options.targetPath : null;
  return `RESOLVED_CONTEXT:\n${JSON.stringify({
    targetPath,
    ...(targetPath ? { targetExists } : {}),
    projectRoot: ctx.projectRoot,
    repoRoot: ctx.repoRoot,
    productPath: ctx.productPath,
    designPath: ctx.designPath,
  }, null, 2)}`;
}

function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
  if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
  return !!(
    ctx.isMonorepo
    && (!targetProvided || targetExists === false)
    && ctx.projectRoot
    && ctx.repoRoot
    && path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
  );
}

function buildMissingTargetDirective() {
  const script = process.argv[1] || 'context.mjs';
  return (
    'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
    'If the user named a file, route, or child app, do not answer from this output. ' +
    `Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
  );
}

function buildTargetSelectionDirective(selection) {
  return (
    `TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
    'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
    'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
    'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
  );
}

// Run cli() only when this module is the entry point. Compare realpaths
// rather than endsWith(): a loose suffix match also fires for unrelated
// scripts like `load-context.mjs`, and realpath tolerates symlinked
// invocation (the test harness symlinks the skill dir).
function invokedAsScript() {
  const arg = process.argv[1];
  if (!arg) return false;
  try {
    return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));
  } catch {
    return false;
  }
}

if (invokedAsScript()) {
  cli();
}
