import {
  decodePercentEscapes,
  isPathInside,
  posixNormalize,
} from '../utils/path.js';

export interface PathPolicy {
  /** Chroot-style absolute root. All paths must lie inside this directory. */
  rootScope?: string;
  /** Reject `..` segments after normalization. Default true. */
  forbidTraversal?: boolean;
  /** Hard cap on raw-input length (post-decode). Default 4096. */
  maxLength?: number;
  /** Allow symlink components. Default false (consumer enforces realpath if true). */
  allowSymlinks?: boolean;
  /** App-specific extra check. Returns 'allow' | 'reject'. */
  extra?: (normalizedPath: string) => 'allow' | 'reject';
}

export type PathValidationResult =
  | { ok: true; normalized: string }
  | { ok: false; reason: string };

const DEFAULT_MAX_LENGTH = 4096;
/** Bytes that must never appear in any path. */
const FORBIDDEN_RE = /[\x00-\x08\x0a-\x1f\x7f\\]/;

/**
 * Validate and normalize a path against the configured policy.
 *
 * Performs (in order):
 *  1. Length check on raw input.
 *  2. Forbidden-bytes check on raw input (NUL/CR/LF/DEL/backslash).
 *  3. Percent-decode (defense against `%2e%2e` smuggling).
 *  4. Forbidden-bytes check on decoded form.
 *  5. POSIX normalization.
 *  6. `..` traversal rejection.
 *  7. Root-scope boundary check.
 *  8. Consumer-supplied `extra` predicate.
 *
 * Returns the normalized path on success. The handler MUST use the
 * normalized form, not the raw input — the raw could trip TOCTOU on
 * the caller's filesystem.
 */
export function validatePath(
  rawPath: string,
  policy: PathPolicy = {},
): PathValidationResult {
  const maxLength = policy.maxLength ?? DEFAULT_MAX_LENGTH;
  const forbidTraversal = policy.forbidTraversal ?? true;

  if (typeof rawPath !== 'string') {
    return { ok: false, reason: 'path must be a string' };
  }
  if (rawPath.length === 0) {
    return { ok: false, reason: 'path is empty' };
  }
  if (rawPath.length > maxLength) {
    return { ok: false, reason: `path exceeds ${maxLength} characters` };
  }
  if (FORBIDDEN_RE.test(rawPath)) {
    return { ok: false, reason: 'path contains forbidden control or backslash characters' };
  }

  const decoded = decodePercentEscapes(rawPath);
  if (FORBIDDEN_RE.test(decoded)) {
    return { ok: false, reason: 'percent-decoded path contains forbidden characters' };
  }
  if (!decoded.startsWith('/')) {
    return { ok: false, reason: 'path must be absolute' };
  }

  if (forbidTraversal) {
    // Detect `..` segments BEFORE normalization (otherwise they'd be silently
    // collapsed). Handles both raw and decoded forms.
    if (/(^|\/)\.\.(\/|$)/.test(rawPath) || /(^|\/)\.\.(\/|$)/.test(decoded)) {
      return { ok: false, reason: 'path contains `..` traversal segment' };
    }
  }

  const normalized = posixNormalize(decoded);

  if (policy.rootScope !== undefined) {
    const root = posixNormalize(policy.rootScope);
    if (!isPathInside(normalized, root)) {
      return { ok: false, reason: 'path is outside root scope' };
    }
  }

  if (policy.extra !== undefined && policy.extra(normalized) === 'reject') {
    return { ok: false, reason: 'path rejected by policy.extra' };
  }

  return { ok: true, normalized };
}
