/**
 * Path policy for SSH tools.
 *
 *   validateRemotePath  — segment-boundary check against the connection's
 *                         `remote_path_prefix`. Rejects relative paths,
 *                         parent refs, embedded NULs, and anything that
 *                         resolves outside the prefix.
 *
 *   validateLocalPath   — workspace containment check for upload/download
 *                         endpoints. Resolves the candidate path, then walks
 *                         each existing component and rejects on symlinks
 *                         (so a planted symlink can't escape the workspace
 *                         via O_NOFOLLOW-style realpath following).
 *
 * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 2).
 */
import * as path from 'node:path';
import { promises as fs } from 'node:fs';

export type RemotePathRejection =
  | 'empty'
  | 'has_nul'
  | 'has_parent_ref'
  | 'outside_prefix';

export type LocalPathRejection =
  | 'empty'
  | 'has_nul'
  | 'not_absolute_after_resolve'
  | 'outside_workspace'
  | 'symlink_in_path'
  | 'leaf_is_symlink'
  | 'not_found'
  | 'stat_failed';

export interface RemotePathResult {
  ok: boolean;
  reason?: RemotePathRejection;
  /** Normalized path when ok. */
  normalized?: string;
}

export interface LocalPathResult {
  ok: boolean;
  reason?: LocalPathRejection;
  /** Absolute, normalized path when ok. */
  resolved?: string;
}

/**
 * Detect the primary path separator used in a prefix string.
 * Windows-style: drive letter (`C:\`), UNC (`\\server\share`), or backslashes
 * without forward slashes. POSIX-style: everything else.
 */
function detectSeparator(p: string): '/' | '\\' {
  if (/^[a-zA-Z]:[\\/]/.test(p)) return '\\';
  if (p.startsWith('\\\\')) return '\\';
  if (p.includes('\\') && !p.includes('/')) return '\\';
  return '/';
}

/**
 * Check a candidate REMOTE path against the per-connection prefix.
 *
 * Pure string operations — no FS I/O (remote FS isn't ours to stat).
 * Supports both POSIX (`/`) and Windows (`\`) path styles. The prefix's
 * primary separator is used to compare; mixing styles between prefix and
 * candidate path will trip the segment-boundary check.
 *
 *   prefix = '/home/u'
 *     '/home/u'              → ok
 *     '/home/u/'             → ok (normalized to '/home/u')
 *     '/home/u/file'         → ok
 *     '/home/u/sub/file'     → ok
 *     '/home/u2/file'        → outside_prefix (segment boundary)
 *     '/home/u/../etc/pwd'   → has_parent_ref
 *     '/etc/passwd'          → outside_prefix
 *     ''                     → empty
 *     '/foo\x00bar'          → has_nul
 *
 *   prefix = 'C:\\Users\\agent'
 *     'C:\\Users\\agent\\file' → ok
 *     'C:\\Users\\agent2\\f'   → outside_prefix
 *     'C:\\..\\Windows\\sys'   → has_parent_ref
 *
 *   prefix = 'srv/agent' (no leading slash)
 *     'srv/agent/file'       → ok
 *     'other/file'           → outside_prefix
 */
export function validateRemotePath(remotePath: string, prefix: string): RemotePathResult {
  if (typeof remotePath !== 'string' || remotePath.length === 0) {
    return { ok: false, reason: 'empty' };
  }
  if (remotePath.includes('\0')) {
    return { ok: false, reason: 'has_nul' };
  }
  // Reject `..` as a path segment using either separator — `..` anywhere is
  // unsafe regardless of canonicalisation.
  const rawSegments = remotePath.split(/[\\/]/);
  if (rawSegments.includes('..')) {
    return { ok: false, reason: 'has_parent_ref' };
  }

  const sep = detectSeparator(prefix);
  // POSIX paths normalize via path.posix (collapses '//' and '/./').
  // Windows paths get a lightweight collapse of repeated backslashes only;
  // posix.normalize would corrupt drive letters or UNC heads.
  const normalize = (s: string): string => {
    if (sep === '/') return path.posix.normalize(s);
    // Preserve leading '\\' (UNC) by capturing it before collapsing.
    const uncHead = s.startsWith('\\\\') ? '\\\\' : '';
    const body = s.slice(uncHead.length).replace(/\\{2,}/g, '\\');
    return uncHead + body;
  };

  const stripTrailingSep = (s: string): string =>
    s.length > 1 && (s.endsWith('/') || s.endsWith('\\')) ? s.slice(0, -1) : s;

  const normalized = normalize(remotePath);
  const normalizedTrimmed = stripTrailingSep(normalized);
  const prefixTrimmed = stripTrailingSep(prefix);

  if (normalizedTrimmed === prefixTrimmed) {
    return { ok: true, normalized: normalizedTrimmed };
  }
  const prefixWithSep =
    prefixTrimmed === '/' || prefixTrimmed === '\\'
      ? prefixTrimmed
      : `${prefixTrimmed}${sep}`;
  if (normalizedTrimmed.startsWith(prefixWithSep)) {
    return { ok: true, normalized: normalizedTrimmed };
  }
  return { ok: false, reason: 'outside_prefix' };
}

export interface ValidateLocalArgs {
  localPath: string;
  /**
   * Realpath-resolved workspace root. Caller is responsible for ensuring this
   * has no symlinks in its own ancestry (typically a freshly-resolved fs.realpath).
   */
  workspaceRoot: string;
  /**
   * When true, fail if the leaf doesn't exist (upload semantics — the file
   * being uploaded must exist locally). When false, the leaf may not exist
   * yet (download semantics — we're about to create it). Default: false.
   */
  mustExist?: boolean;
}

/**
 * Check a candidate LOCAL path stays inside the workspace and contains no
 * symlinks anywhere from the workspace down.
 *
 * We deliberately do NOT use fs.realpath on the input, because realpath
 * silently follows symlinks. Instead we walk each component below
 * workspaceRoot and lstat — if any component is a symlink we reject. A
 * symlink as the leaf (the target itself) is also rejected.
 *
 * Caveat: the workspace root must already be realpath-resolved by the
 * caller (i.e. no symlinks in its own ancestry). The orchestrator's worker
 * setup guarantees this for per-job workspaces.
 */
export async function validateLocalPath(args: ValidateLocalArgs): Promise<LocalPathResult> {
  const { localPath, workspaceRoot, mustExist = false } = args;
  if (typeof localPath !== 'string' || localPath.length === 0) {
    return { ok: false, reason: 'empty' };
  }
  if (localPath.includes('\0') || workspaceRoot.includes('\0')) {
    return { ok: false, reason: 'has_nul' };
  }
  // Resolve relative paths against the workspace root.
  const resolved = path.isAbsolute(localPath)
    ? path.normalize(localPath)
    : path.resolve(workspaceRoot, localPath);
  // path.resolve always returns an absolute path; double-check defensively.
  if (!path.isAbsolute(resolved)) {
    return { ok: false, reason: 'not_absolute_after_resolve' };
  }

  const wsRoot = path.resolve(workspaceRoot);
  const rel = path.relative(wsRoot, resolved);
  if (rel !== '' && (rel.startsWith('..') || path.isAbsolute(rel))) {
    return { ok: false, reason: 'outside_workspace' };
  }

  // Walk components from workspaceRoot down; for each existing component,
  // ensure it is not a symlink. Stop at the first non-existent component.
  const parts = rel === '' ? [] : rel.split(path.sep).filter((s) => s.length > 0);
  let current = wsRoot;
  for (let i = 0; i < parts.length; i++) {
    current = path.join(current, parts[i]);
    let st;
    try {
      st = await fs.lstat(current);
    } catch (err) {
      const code = (err as NodeJS.ErrnoException).code;
      if (code === 'ENOENT') {
        // The leaf doesn't exist — that's allowed when !mustExist.
        if (i < parts.length - 1) {
          // Non-leaf missing: a future create would be in a non-existent
          // directory. Treat as not_found; caller can choose to mkdir or fail.
          return mustExist
            ? { ok: false, reason: 'not_found' }
            : { ok: true, resolved };
        }
        return mustExist
          ? { ok: false, reason: 'not_found' }
          : { ok: true, resolved };
      }
      return { ok: false, reason: 'stat_failed' };
    }
    if (st.isSymbolicLink()) {
      return {
        ok: false,
        reason: i === parts.length - 1 ? 'leaf_is_symlink' : 'symlink_in_path',
      };
    }
  }
  return { ok: true, resolved };
}
