/**
 * engine/qa-runbooks.js — W-mpeiwz6k0005bf34-a
 *
 * Per-project QA runbook persistence + CRUD helpers. Runbooks are test plans
 * that travel with a project entry, mirroring the
 * projects/<name>/pull-requests.json precedent. Each runbook is one JSON file
 * at <MINIONS_DIR>/projects/<project>/runbooks/<id>.json.
 *
 * Pure persistence + validation only — this module does NOT spawn agents,
 * dispatch runs, or touch UI. The dispatch endpoint, run records, and QA UI
 * are intentionally deferred to follow-up plan items.
 *
 * All writes go through mutateJsonFileLocked per the repo convention. The id
 * field is globally unique across projects (kebab-case, ≤64 chars) so reads
 * by id can locate the file without the caller knowing the project.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');

const RUNBOOKS_DIR = 'runbooks';

const _KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

// Mirrors shared.PROJECT_NAME_RE — kept local to avoid a require cycle and to
// keep this module self-contained for path-traversal hardening (review feedback
// on PR #2694: id/project params previously flowed into path.join without
// validation, so `..%2F..%2F..%2Fconfig` could read MINIONS_DIR/config.json
// and DELETE could wipe dispatch.json).
const _PROJECT_NAME_RE = /^[a-zA-Z0-9_\-]{1,64}$/;

const ARTIFACT_TYPES = ['screenshot', 'video', 'log', 'other'];

const LIMITS = {
  idMax: 64,
  nameMax: 200,
  targetNameMax: 200,
  stepDescriptionMax: 500,
  stepCommandMax: 2000,
  artifactLabelMax: 200,
  artifactPathMax: 500,
  stepsMax: 20,
  artifactsMax: 20,
};

function _projectsDir() {
  return path.join(shared.MINIONS_DIR, 'projects');
}

function _runbooksDir(projectName) {
  return path.join(_projectsDir(), projectName, RUNBOOKS_DIR);
}

function _runbookPath(projectName, id) {
  return path.join(_runbooksDir(projectName), id + '.json');
}

function _isNonEmptyString(v) {
  return typeof v === 'string' && v.length > 0;
}

// Guards against path traversal at the module boundary. Mirrors the validation
// saveRunbook already applies via validateRunbook(). Reject anything that isn't
// a safe kebab-case id ≤ idMax chars so it can never reach path.join().
function _isSafeId(id) {
  return _isNonEmptyString(id) && id.length <= LIMITS.idMax && _KEBAB_RE.test(id);
}

// Guards against path traversal via the project segment. Project directory
// names on disk follow shared.PROJECT_NAME_RE — anything outside that set
// (path separators, `..`, null bytes, whitespace) cannot be a real project.
function _isSafeProjectName(name) {
  return _isNonEmptyString(name) && _PROJECT_NAME_RE.test(name);
}

/**
 * Validate a runbook spec. Returns { ok: boolean, errors: string[] }.
 * Never throws.
 */
function validateRunbook(spec) {
  const errors = [];
  if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
    return { ok: false, errors: ['spec must be a plain object'] };
  }

  if (!_isNonEmptyString(spec.id)) {
    errors.push('id is required (non-empty string)');
  } else {
    if (spec.id.length > LIMITS.idMax) errors.push('id exceeds ' + LIMITS.idMax + ' chars');
    if (!_KEBAB_RE.test(spec.id)) errors.push('id must be kebab-case (a-z, 0-9, hyphens; no leading/trailing hyphen)');
  }

  if (!_isNonEmptyString(spec.name)) {
    errors.push('name is required (non-empty string)');
  } else if (spec.name.length > LIMITS.nameMax) {
    errors.push('name exceeds ' + LIMITS.nameMax + ' chars');
  }

  if (!_isNonEmptyString(spec.project)) {
    errors.push('project is required (non-empty string)');
  } else if (!_PROJECT_NAME_RE.test(spec.project)) {
    // Reject path-traversal / illegal project names at the schema layer so
    // they never reach path.join in saveRunbook (review feedback on PR #2694:
    // POST /api/qa/runbooks with project="../engine" previously wrote arbitrary
    // JSON outside MINIONS_DIR).
    errors.push('project must match ' + _PROJECT_NAME_RE.source + ' (alphanumerics, underscore, hyphen; 1-64 chars)');
  }

  if (!_isNonEmptyString(spec.targetName)) {
    errors.push('targetName is required (non-empty string)');
  } else if (spec.targetName.length > LIMITS.targetNameMax) {
    errors.push('targetName exceeds ' + LIMITS.targetNameMax + ' chars');
  }

  if (!Array.isArray(spec.steps)) {
    errors.push('steps must be an array');
  } else {
    if (spec.steps.length > LIMITS.stepsMax) {
      errors.push('steps exceeds max of ' + LIMITS.stepsMax);
    }
    for (let i = 0; i < spec.steps.length; i++) {
      const s = spec.steps[i];
      if (!s || typeof s !== 'object' || Array.isArray(s)) {
        errors.push('steps[' + i + '] must be an object');
        continue;
      }
      if (!_isNonEmptyString(s.description)) {
        errors.push('steps[' + i + '].description is required (non-empty string)');
      } else if (s.description.length > LIMITS.stepDescriptionMax) {
        errors.push('steps[' + i + '].description exceeds ' + LIMITS.stepDescriptionMax + ' chars');
      }
      if (s.command !== undefined && s.command !== null) {
        if (typeof s.command !== 'string') {
          errors.push('steps[' + i + '].command must be a string when present');
        } else if (s.command.length > LIMITS.stepCommandMax) {
          errors.push('steps[' + i + '].command exceeds ' + LIMITS.stepCommandMax + ' chars');
        }
      }
    }
  }

  if (!Array.isArray(spec.expectedArtifacts)) {
    errors.push('expectedArtifacts must be an array');
  } else {
    if (spec.expectedArtifacts.length > LIMITS.artifactsMax) {
      errors.push('expectedArtifacts exceeds max of ' + LIMITS.artifactsMax);
    }
    for (let i = 0; i < spec.expectedArtifacts.length; i++) {
      const a = spec.expectedArtifacts[i];
      if (!a || typeof a !== 'object' || Array.isArray(a)) {
        errors.push('expectedArtifacts[' + i + '] must be an object');
        continue;
      }
      if (!_isNonEmptyString(a.type) || !ARTIFACT_TYPES.includes(a.type)) {
        errors.push('expectedArtifacts[' + i + '].type must be one of: ' + ARTIFACT_TYPES.join(', '));
      }
      if (!_isNonEmptyString(a.label)) {
        errors.push('expectedArtifacts[' + i + '].label is required (non-empty string)');
      } else if (a.label.length > LIMITS.artifactLabelMax) {
        errors.push('expectedArtifacts[' + i + '].label exceeds ' + LIMITS.artifactLabelMax + ' chars');
      }
      if (a.path !== undefined && a.path !== null) {
        if (typeof a.path !== 'string') {
          errors.push('expectedArtifacts[' + i + '].path must be a string when present');
        } else if (a.path.length > LIMITS.artifactPathMax) {
          errors.push('expectedArtifacts[' + i + '].path exceeds ' + LIMITS.artifactPathMax + ' chars');
        }
      }
    }
  }

  return { ok: errors.length === 0, errors };
}

function _readRunbookFile(filePath) {
  let raw;
  try { raw = fs.readFileSync(filePath, 'utf8'); }
  catch (_e) { return null; }
  try { return JSON.parse(raw); }
  catch (_e) { return null; }
}

function _listProjectNames() {
  const dir = _projectsDir();
  let entries;
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
  catch (_e) { return []; }
  return entries.filter(e => e.isDirectory()).map(e => e.name);
}

/**
 * List runbooks across all projects, or filtered to a single project. Each
 * returned record is the parsed file contents (already includes id + project
 * + timestamps).
 */
function listRunbooks(project) {
  let projects;
  if (project === undefined || project === null || project === '') {
    projects = _listProjectNames();
  } else {
    // Hardened: reject traversal/illegal project names instead of letting them
    // flow into path.join (review feedback on PR #2694).
    if (!_isSafeProjectName(project)) return [];
    projects = [project];
  }
  const out = [];
  for (const name of projects) {
    const dir = _runbooksDir(name);
    let files;
    try { files = fs.readdirSync(dir); }
    catch (_e) { continue; }
    for (const f of files) {
      if (!f.endsWith('.json')) continue;
      const parsed = _readRunbookFile(path.join(dir, f));
      if (parsed && typeof parsed === 'object') out.push(parsed);
    }
  }
  return out;
}

/**
 * Find a runbook by globally-unique id. Returns the parsed record or null.
 */
function getRunbook(id) {
  // Hardened: reject traversal ids before they can reach path.join + existsSync
  // (review feedback on PR #2694).
  if (!_isSafeId(id)) return null;
  for (const name of _listProjectNames()) {
    const filePath = _runbookPath(name, id);
    if (fs.existsSync(filePath)) {
      return _readRunbookFile(filePath);
    }
  }
  return null;
}

/**
 * Locate the project that currently owns id, or null if not present.
 */
function _findOwningProject(id) {
  for (const name of _listProjectNames()) {
    if (fs.existsSync(_runbookPath(name, id))) return name;
  }
  return null;
}

/**
 * Create or update a runbook. Sets createdAt on first save and updatedAt on
 * every save. Throws on validation failure. Rejects cross-project renames —
 * if id already exists under a different project, the caller must
 * deleteRunbook(id) first.
 */
function saveRunbook(spec) {
  const v = validateRunbook(spec);
  if (!v.ok) {
    const err = new Error('invalid runbook: ' + v.errors.join('; '));
    err.validationErrors = v.errors;
    throw err;
  }
  const existingProject = _findOwningProject(spec.id);
  if (existingProject && existingProject !== spec.project) {
    throw new Error('runbook id "' + spec.id + '" already exists under project "' + existingProject + '" — delete it before saving under "' + spec.project + '"');
  }

  const filePath = _runbookPath(spec.project, spec.id);
  fs.mkdirSync(path.dirname(filePath), { recursive: true });

  const nowIso = new Date().toISOString();
  const result = shared.mutateJsonFileLocked(filePath, (data) => {
    const prior = (data && typeof data === 'object' && !Array.isArray(data)) ? data : {};
    return {
      id: spec.id,
      name: spec.name,
      project: spec.project,
      targetName: spec.targetName,
      steps: spec.steps.map(s => {
        const out = { description: s.description };
        if (typeof s.command === 'string' && s.command.length > 0) out.command = s.command;
        return out;
      }),
      expectedArtifacts: spec.expectedArtifacts.map(a => {
        const out = { type: a.type, label: a.label };
        if (typeof a.path === 'string' && a.path.length > 0) out.path = a.path;
        return out;
      }),
      createdAt: _isNonEmptyString(prior.createdAt) ? prior.createdAt : nowIso,
      updatedAt: nowIso,
    };
  }, { defaultValue: {} });
  return result;
}

/**
 * Remove a runbook by id. No-op when the id is not found. Returns true when
 * a file was removed.
 *
 * Coordination: acquires the runbook's lock via withFileLock so a concurrent
 * saveRunbook can't be mid-rename when we unlink. The unlink happens inside
 * the lock callback (single fs call — keeps the callback synchronous and
 * fast per the repo convention).
 */
function deleteRunbook(id) {
  // Hardened: reject traversal ids before they can reach _findOwningProject /
  // path.join / unlink (review feedback on PR #2694).
  if (!_isSafeId(id)) return false;
  const owning = _findOwningProject(id);
  if (!owning) return false;
  const filePath = _runbookPath(owning, id);
  shared.withFileLock(filePath + '.lock', () => {
    try { fs.unlinkSync(filePath); } catch (_e) { /* already gone */ }
    try { fs.unlinkSync(filePath + '.backup'); } catch (_e) { /* optional */ }
  });
  return true;
}

module.exports = {
  ARTIFACT_TYPES,
  LIMITS,
  validateRunbook,
  listRunbooks,
  getRunbook,
  saveRunbook,
  deleteRunbook,
  // internals exposed for testing
  _runbookPath,
  _runbooksDir,
};
