// Copyright (c) 2026 Hellmai Ltd
// SPDX-License-Identifier: AGPL-3.0-only

/**
 * WU ID Format Primitives (WU-2552)
 *
 * Canonical zero-padded WU ID primitives. All ID formatting, parsing,
 * and regex construction flows through this module so that width, strict
 * mode, and prefix can be centrally configured via workspace.yaml
 * `software_delivery.wu_id.{width,strict,prefix}`.
 *
 * Design principles:
 * - Pure functions, no I/O, no config reads (caller supplies config).
 * - Hardcoded choices: separator '-', lowercase digits, padding char '0',
 *   starting number 1, gap-fill policy 'never reuse'.
 * - `wuIdRegex` is intentionally permissive (matches both padded and unpadded
 *   digit counts) so that consumers scanning files for legacy + canonical
 *   IDs stay backward compatible during the transition. Strict canonical
 *   form is enforced separately via `isCanonicalWuId`.
 *
 * @module wu-id-format
 */

import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { createError, ErrorCodes } from '@lumenflow/kernel/primitives/error-handler';
import { readSoftwareDeliveryConfig } from '../config/workspace-reader.js';

/** Minimum allowed zero-padding width. */
export const WU_ID_WIDTH_MIN = 1;

/** Maximum allowed zero-padding width. */
export const WU_ID_WIDTH_MAX = 6;

/**
 * Prefix validation pattern: uppercase letter start, followed by 1-9 more
 * uppercase-alphanumeric characters, terminated by a dash. Matches 'WU-',
 * 'TASK-', 'STORY-', 'TICKET-', 'JIRA123-'. Rejects lowercase, missing dash,
 * single-char prefixes, and over-long prefixes.
 */
export const WU_ID_PREFIX_PATTERN = /^[A-Z][A-Z0-9]{1,9}-$/;

/** Canonical WU ID configuration. */
export interface WuIdConfig {
  /** Zero-padding width. Default 4 (WU-0001). */
  width: number;
  /**
   * Strict mode. When true, non-canonical legacy IDs are flagged as errors
   * by wu:doctor and wu:create canonicalizes --id input. When false, legacy
   * IDs are tolerated as warnings for incremental migration.
   */
  strict: boolean;
  /** Canonical prefix including trailing dash. Default 'WU-'. */
  prefix: string;
}

/** Default WU ID configuration. */
export const WU_ID_DEFAULTS: WuIdConfig = Object.freeze({
  width: 4,
  strict: true,
  prefix: 'WU-',
});

/**
 * Validate and resolve a partial WU ID config against the defaults.
 *
 * @throws {Error} If width is not an integer in [1, 6] or prefix does not
 *   match {@link WU_ID_PREFIX_PATTERN}.
 */
export function validateWuIdConfig(partial: Partial<WuIdConfig> = {}): WuIdConfig {
  const width = partial.width ?? WU_ID_DEFAULTS.width;
  const strict = partial.strict ?? WU_ID_DEFAULTS.strict;
  const prefix = partial.prefix ?? WU_ID_DEFAULTS.prefix;

  if (!Number.isInteger(width) || width < WU_ID_WIDTH_MIN || width > WU_ID_WIDTH_MAX) {
    throw createError(
      ErrorCodes.VALIDATION_ERROR,
      `Invalid wu_id.width: ${String(width)}. Must be an integer between ${WU_ID_WIDTH_MIN} and ${WU_ID_WIDTH_MAX}.`,
    );
  }

  if (typeof prefix !== 'string' || !WU_ID_PREFIX_PATTERN.test(prefix)) {
    throw createError(
      ErrorCodes.VALIDATION_ERROR,
      `Invalid wu_id.prefix: "${String(prefix)}". Must match ${WU_ID_PREFIX_PATTERN} (e.g., 'WU-', 'TASK-', 'STORY-').`,
    );
  }

  return { width, strict, prefix };
}

/**
 * Format an integer as a canonical WU ID.
 *
 * Zero-pads to the configured width. If the integer exceeds 10^width, the
 * result is wider than width (overflow is preserved, never truncated).
 *
 * @throws {Error} If n is not a positive integer.
 */
export function formatWuId(n: number, config: Partial<WuIdConfig> = {}): string {
  if (!Number.isInteger(n) || n < 1) {
    throw createError(
      ErrorCodes.VALIDATION_ERROR,
      `Invalid WU number: ${String(n)}. Must be a positive integer.`,
    );
  }
  const { width, prefix } = validateWuIdConfig(config);
  return `${prefix}${String(n).padStart(width, '0')}`;
}

/**
 * Parse a WU ID (or YAML/stamp filename) into its prefix and numeric parts.
 *
 * Returns null for any input that does not match the configured prefix or
 * does not carry a positive integer body. Accepts both canonical padded and
 * legacy unpadded forms.
 *
 * Strips `.yaml` and `.done` extensions before parsing.
 */
export function parseWuId(
  id: string,
  config: Partial<WuIdConfig> = {},
): { prefix: string; num: number } | null {
  if (typeof id !== 'string' || id.length === 0) {
    return null;
  }
  const { prefix } = validateWuIdConfig(config);
  const stripped = id.replace(/\.(yaml|done)$/, '');
  if (!stripped.startsWith(prefix)) {
    return null;
  }
  const body = stripped.slice(prefix.length);
  if (!/^\d+$/.test(body)) {
    return null;
  }
  const num = parseInt(body, 10);
  if (!Number.isFinite(num) || num < 1) {
    return null;
  }
  return { prefix, num };
}

/**
 * Build an anchored regex that matches WU IDs (both padded and unpadded
 * digit counts) for the configured prefix. Intended for file scans and
 * consumers that previously hardcoded `/^WU-\d+$/`.
 *
 * Strict canonical-form enforcement lives in {@link isCanonicalWuId}, not here.
 */
export function wuIdRegex(config: Partial<WuIdConfig> = {}): RegExp {
  // eslint-disable-next-line security/detect-non-literal-regexp -- prefix escaped
  return new RegExp(`^${wuIdBodyPattern(config)}$`);
}

/**
 * Return an unanchored pattern string (for embedding in longer regexes)
 * that matches the WU id body — the configured prefix followed by one
 * or more digits. Consumers that previously hardcoded `WU-\\d+` inside
 * a larger regex should use this helper so prefix configuration
 * (`software_delivery.wu_id.prefix`) is honored.
 *
 * The pattern does NOT include anchors; callers decide whether to
 * anchor with `^`/`$` or embed in a path/substring match.
 */
export function wuIdBodyPattern(config: Partial<WuIdConfig> = {}): string {
  const { prefix } = validateWuIdConfig(config);
  const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  return `${escaped}\\d+`;
}

/**
 * Return true if the given string is a canonical WU ID for the configured
 * width and prefix. Canonical means:
 * - Prefix matches.
 * - Digit body is at least `width` characters long.
 * - The digit body is not shorter than width (no unpadded legacy).
 * - Overflow (body wider than width) is allowed so the sequence never truncates.
 */
export function isCanonicalWuId(id: string, config: Partial<WuIdConfig> = {}): boolean {
  const { width, prefix } = validateWuIdConfig(config);
  if (typeof id !== 'string' || !id.startsWith(prefix)) {
    return false;
  }
  const body = id.slice(prefix.length);
  if (!/^\d+$/.test(body)) {
    return false;
  }
  return body.length >= width;
}

/**
 * Canonicalize a (possibly legacy) WU ID to the canonical form for the
 * configured width. Returns null for unparseable input or prefix mismatch.
 *
 * Already-canonical IDs are returned unchanged. Overflow IDs (wider than
 * width) are preserved.
 */
export function canonicalizeWuId(id: string, config: Partial<WuIdConfig> = {}): string | null {
  const parsed = parseWuId(id, config);
  if (!parsed) {
    return null;
  }
  return formatWuId(parsed.num, config);
}

/**
 * Load the canonical WU id config from workspace.yaml
 * `software_delivery.wu_id.{width,strict,prefix}`.
 *
 * Falls back to {@link WU_ID_DEFAULTS} when the block is absent, so
 * existing repos without any wu_id config continue to see WU- prefixed
 * ids (backward compatible) with the only visible change being
 * zero-padding on newly-generated ids.
 *
 * @param options.projectRoot - Optional override for the project root.
 */
export function loadWuIdConfig(options: { projectRoot?: string } = {}): WuIdConfig {
  const cwd = options.projectRoot ?? process.cwd();
  let section: Record<string, unknown>;
  try {
    section = readSoftwareDeliveryConfig(cwd);
  } catch {
    // Graceful fallback when workspace.yaml is absent/unreadable — matches
    // core's non-strictWorkspace behaviour.
    return validateWuIdConfig({});
  }
  // Accept both snake_case (canonical YAML) and camelCase (post-normalization)
  // spellings so this primitive stays callable whether the caller passes a raw
  // YAML section or a pre-normalized shape.
  const wuIdBlock =
    (section['wuId'] as Partial<WuIdConfig> | undefined) ??
    (section['wu_id'] as Partial<WuIdConfig> | undefined);
  return validateWuIdConfig(wuIdBlock ?? {});
}

/** Inputs for {@link scanExistingWuNumbers}. */
export interface ScanExistingWuNumbersInput {
  /** Absolute path to the WU YAML directory. */
  wuDir: string;
  /** Absolute path to the stamps directory. */
  stampsDir: string;
  /** Absolute path to `wu-events.jsonl` (may or may not exist). */
  eventsPath: string;
}

/**
 * Scan every existing WU id integer across the yaml directory, stamps
 * directory, and event log. Used by wu:create for integer-level
 * collision detection so `WU-14` and `WU-0014` can never coexist.
 *
 * Results are deduplicated across sources. Entries whose prefix does
 * not match the configured prefix are ignored. Extension suffixes
 * (`.yaml`, `.done`) are stripped before parsing.
 */
export function scanExistingWuNumbers(
  input: ScanExistingWuNumbersInput,
  config: Partial<WuIdConfig> = {},
): Set<number> {
  const result = new Set<number>();

  const addFromEntries = (entries: readonly string[]): void => {
    for (const entry of entries) {
      const parsed = parseWuId(entry, config);
      if (parsed) {
        result.add(parsed.num);
      }
    }
  };

  if (existsSync(input.wuDir)) {
    addFromEntries(readdirSync(input.wuDir));
  }
  if (existsSync(input.stampsDir)) {
    addFromEntries(readdirSync(input.stampsDir));
  }
  if (existsSync(input.eventsPath)) {
    try {
      const content = readFileSync(input.eventsPath, { encoding: 'utf-8' });
      for (const line of content.split('\n')) {
        const trimmed = line.trim();
        if (trimmed.length === 0) continue;
        try {
          const event = JSON.parse(trimmed) as {
            wuId?: string;
            from?: string;
            to?: string;
          };
          for (const candidate of [event.wuId, event.from, event.to]) {
            if (typeof candidate === 'string') {
              const parsed = parseWuId(candidate, config);
              if (parsed) {
                result.add(parsed.num);
              }
            }
          }
        } catch {
          // Ignore malformed lines; the events file is allowed to have gaps.
        }
      }
    } catch {
      // Read errors fall through; caller treats this as "no known ids".
    }
  }

  return result;
}

/**
 * Returns true when `num` is already present in the given set.
 * Thin wrapper for call-site readability at the wu:create integration point.
 */
export function integerCollides(existing: ReadonlySet<number>, num: number): boolean {
  return existing.has(num);
}

/**
 * Returns true when the given WU number is within the width-exhaustion
 * warning band (>= 90% of 10^width) but still below the width ceiling.
 *
 * Numbers that already exceed the width capacity return false so the
 * warning only fires once, not on every subsequent overflow id.
 */
export function isWidthExhaustionWarning(num: number, config: Partial<WuIdConfig> = {}): boolean {
  const { width } = validateWuIdConfig(config);
  const capacity = Math.pow(10, width);
  const threshold = capacity * 0.9;
  return num >= threshold && num < capacity;
}
