import { readFileSync } from 'node:fs';

/**
 * Framework path-ownership manifest (#791).
 *
 * The updater must operate from an explicit framework-owned path manifest and
 * NEVER write outside it. This module is the TypeScript reader for the shared
 * SSOT manifest (`packages/mosaic/framework/framework-manifest.txt`) that the
 * bash installer also consumes. Keeping both paths on one data file is what
 * closes the two-copies-drift failure class (see #631 → #791).
 *
 * Everything here is pure (parse + resolve + plan) so the ownership guarantee
 * is unit- and property-testable without touching the filesystem.
 */

export type Ownership = 'framework' | 'operator';

/**
 * Thrown when the manifest is missing, empty, or malformed. A distinct type lets
 * callers (e.g. finalizeStage) tell a pre-sync validation abort — where NO files
 * were touched — apart from a generic mid-sync filesystem failure, and message
 * the user accurately (#791 blocker-C).
 */
export class ManifestError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ManifestError';
  }
}

export interface FrameworkManifest {
  /** Globs the updater MAY create/overwrite, and prune only when retired. */
  readonly framework: readonly string[];
  /** Globs the updater must NEVER write over or prune. Win over `framework`. */
  readonly operator: readonly string[];
}

type Section = 'framework' | 'operator' | null;

/**
 * Parse the line-oriented manifest text. `#` comments and blank lines are
 * ignored; `[framework]` / `[operator]` headers switch the active section.
 * Lines before any header are rejected — the format must be explicit.
 */
export function parseManifest(text: string): FrameworkManifest {
  const framework: string[] = [];
  const operator: string[] = [];
  let section: Section = null;

  const lines = text.split(/\r?\n/);
  for (let i = 0; i < lines.length; i++) {
    const raw = lines[i] ?? '';
    const line = raw.trim();
    if (line === '' || line.startsWith('#')) continue;

    if (line === '[framework]') {
      section = 'framework';
      continue;
    }
    if (line === '[operator]') {
      section = 'operator';
      continue;
    }
    if (line.startsWith('[')) {
      throw new ManifestError(`Unknown manifest section header on line ${i + 1}: ${line}`);
    }

    if (section === null) {
      throw new ManifestError(
        `Manifest entry before any [section] header on line ${i + 1}: ${line}`,
      );
    }
    (section === 'framework' ? framework : operator).push(line);
  }

  // Fail CLOSED on an empty or comment-only manifest. A manifest with zero
  // framework-owned globs would make resolveOwnership() return `operator` for
  // every path: an upgrade would prune nothing and refresh nothing — a silent
  // no-op indistinguishable from success. Refuse loudly instead, mirroring the
  // bash reader's `manifest_load` guard so both halves reject it identically (#791 B2).
  if (framework.length === 0) {
    throw new ManifestError(
      'Framework manifest defines no [framework] paths — refusing to proceed (empty or malformed manifest).',
    );
  }

  // Fail CLOSED on framework entries that normalize to nothing usable. A manifest
  // like `[framework]\n/` or `[framework]\n./` passes the length check above but
  // every entry normalizes to an empty (or bare-dot) glob that matches no real
  // path — so the compiled framework matcher is empty and every path resolves
  // `operator`: the same silent no-op as an empty manifest. Require at least one
  // entry with a real, non-dot character (the bash reader applies the identical
  // `[^/.]` test, so both halves reject these inputs together — #791 blocker-B).
  if (!framework.some(isUsableFrameworkGlob)) {
    throw new ManifestError(
      'Framework manifest defines no usable [framework] paths (every entry is empty or a bare dot segment) — refusing to proceed (malformed manifest).',
    );
  }

  return { framework, operator };
}

/**
 * A framework glob is usable only if, once normalized, it still contains a
 * character other than `/` or `.` — i.e. it names a real path segment or a
 * wildcard. `''`, `/`, `./`, `.`, `..` are all unusable (they compile to a glob
 * that matches nothing). Kept byte-compatible with the bash `[[ =~ [^/.] ]]`
 * test so TS and bash accept/reject exactly the same manifests.
 */
function isUsableFrameworkGlob(glob: string): boolean {
  return /[^/.]/.test(normalizeRel(glob));
}

/** Read and parse the manifest from a framework root directory. */
export function loadManifest(frameworkRoot: string): FrameworkManifest {
  const file = `${frameworkRoot}/framework-manifest.txt`;
  let text: string;
  try {
    text = readFileSync(file, 'utf-8');
  } catch (err) {
    // A missing/unreadable manifest must fail closed with a clear message, not a
    // raw ENOENT that a caller might mistake for an empty result set (#791 B2/B3).
    throw new ManifestError(
      `Cannot read framework manifest at ${file}: ${(err as Error).message} — refusing to sync (fail-closed).`,
    );
  }
  return parseManifest(text);
}

/**
 * Match a mosaic-home-relative POSIX path against one glob.
 *
 * Supported: `**` (any depth, including zero segments) and `*` (any run of
 * characters within a single segment, not crossing `/`). A glob with no
 * wildcard matches either the exact path OR any path beneath it (so a bare
 * directory entry like `memory` covers `memory/notes.md`).
 */
export function matchGlob(glob: string, relPath: string): boolean {
  const path = normalizeRel(relPath);
  const pattern = normalizeRel(glob);
  if (pattern === '') return false;

  if (!pattern.includes('*')) {
    // Exact file, or any descendant of a bare directory prefix.
    return path === pattern || path.startsWith(`${pattern}/`);
  }

  const re = new RegExp(`^${globToRegExpBody(pattern)}$`);
  return re.test(path);
}

/** True if the path matches any glob in the list. */
export function matchesAny(globs: readonly string[], relPath: string): boolean {
  return globs.some((g) => matchGlob(g, relPath));
}

/**
 * Resolve ownership of a mosaic-home-relative path (deny-wins / fail-safe):
 * operator globs win, then framework globs, else operator by default.
 */
export function resolveOwnership(manifest: FrameworkManifest, relPath: string): Ownership {
  if (matchesAny(manifest.operator, relPath)) return 'operator';
  if (matchesAny(manifest.framework, relPath)) return 'framework';
  return 'operator';
}

/**
 * The set of `[framework]` subtree roots that pruning is allowed to descend
 * into (glob entries of the form `dir/**`). Single-file framework entries
 * (e.g. `CONSTITUTION.md`) are reconcile-managed and never pruned.
 */
export function frameworkSubtreeRoots(manifest: FrameworkManifest): string[] {
  const roots: string[] = [];
  for (const g of manifest.framework) {
    if (g.endsWith('/**')) roots.push(g.slice(0, -3));
  }
  return roots;
}

export interface PrunePlanInput {
  readonly manifest: FrameworkManifest;
  /** Mosaic-home-relative paths currently present in the target. */
  readonly targetPaths: readonly string[];
  /** Mosaic-home-relative paths the framework currently ships (source). */
  readonly sourcePaths: readonly string[];
}

/**
 * Pure prune planner — the testable seam of the #791 fix.
 *
 * Returns the delete-set: target paths that are framework-owned, live inside a
 * shipped framework subtree, and are absent from the current source (retired
 * framework files). By construction the result never contains an operator-owned
 * or unknown path: those either resolve to `operator` or fall outside every
 * framework subtree root, so they are structurally unreachable by pruning.
 */
export function planPrune(input: PrunePlanInput): string[] {
  const { manifest, targetPaths, sourcePaths } = input;
  const source = new Set(sourcePaths.map(normalizeRel));
  const roots = frameworkSubtreeRoots(manifest);

  const deleteSet: string[] = [];
  for (const raw of targetPaths) {
    const path = normalizeRel(raw);
    if (source.has(path)) continue; // still shipped — keep
    if (resolveOwnership(manifest, path) !== 'framework') continue; // operator/unknown — never prune
    if (!roots.some((root) => path === root || path.startsWith(`${root}/`))) continue; // outside shipped subtrees
    deleteSet.push(path);
  }
  return deleteSet;
}

function normalizeRel(p: string): string {
  return p.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
}

/** Translate a glob body (already normalized) into a RegExp source fragment. */
function globToRegExpBody(pattern: string): string {
  let out = '';
  for (let i = 0; i < pattern.length; i++) {
    const c = pattern[i];
    if (c === undefined) continue;
    if (c === '*') {
      if (pattern[i + 1] === '*') {
        // `**` — any depth. `a/**` must also match the bare root `a`, so when a
        // literal `/` was just emitted, make it optional along with the rest.
        i++;
        let trailingSlash = false;
        if (pattern[i + 1] === '/') {
          i++;
          trailingSlash = true;
        }
        if (out.endsWith('/')) {
          out = `${out.slice(0, -1)}(?:/.*)?`;
        } else if (trailingSlash) {
          out += '(?:.*/)?';
        } else {
          out += '.*';
        }
      } else {
        out += '[^/]*';
      }
    } else {
      out += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
    }
  }
  return out;
}
