Source: routes/projects.js

/**
 * @module routes/projects
 * @description Project CRUD routes. Mounted at `/api/v1/projects` (INF-005).
 *
 * ### Endpoints
 * | Method   | Path                              | Description                                            |
 * |----------|-----------------------------------|--------------------------------------------------------|
 * | `POST`   | `/api/v1/projects`                | Create a project                                       |
 * | `GET`    | `/api/v1/projects`                | List all non-deleted projects                          |
 * | `GET`    | `/api/v1/projects/:id`            | Get a single project                                   |
 * | `PATCH`  | `/api/v1/projects/:id`            | Update project name / URL / credentials                |
 * | `DELETE` | `/api/v1/projects/:id`            | Soft-delete project + cascade soft-delete its data     |
 * | `GET`    | `/api/v1/projects/:id/schedule`   | Get the cron schedule for a project                    |
 * | `PATCH`  | `/api/v1/projects/:id/schedule`   | Create or update the cron schedule for a project       |
 * | `DELETE` | `/api/v1/projects/:id/schedule`   | Remove the cron schedule for a project                 |
 * | `GET`    | `/api/v1/projects/:id/notifications` | Get notification settings for a project             |
 * | `PATCH`  | `/api/v1/projects/:id/notifications` | Create or update notification settings              |
 * | `DELETE` | `/api/v1/projects/:id/notifications` | Remove notification settings for a project          |
 */

import { Router } from "express";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as testRepo from "../database/repositories/testRepo.js";
import * as runRepo from "../database/repositories/runRepo.js";
import * as activityRepo from "../database/repositories/activityRepo.js";
import * as healingRepo from "../database/repositories/healingRepo.js";
import * as webhookTokenRepo from "../database/repositories/webhookTokenRepo.js";
import * as scheduleRepo from "../database/repositories/scheduleRepo.js";
import { getDatabase } from "../database/sqlite.js";
import { generateProjectId, generateScheduleId } from "../utils/idGenerator.js";
import * as environmentRepo from "../database/repositories/environmentRepo.js";
import { logActivity } from "../utils/activityLogger.js";
import { encryptCredentials, decryptCredentials } from "../utils/credentialEncryption.js";
import { validateProjectPayload, sanitise } from "../utils/validate.js";
import { randomUUID } from "node:crypto";
import { actor } from "../utils/actor.js";
import { sanitiseProjectForClient, sanitiseEnvCredentialsForClient } from "../utils/projectSanitiser.js";
import { reloadSchedule, stopSchedule, getNextRunAt } from "../scheduler.js";
import { requireRole } from "../middleware/requireRole.js";
import * as notificationSettingsRepo from "../database/repositories/notificationSettingsRepo.js";
import * as metricSamplesRepo from "../database/repositories/metricSamplesRepo.js";
import { generateNotificationSettingId } from "../utils/idGenerator.js";
import { validateUrl } from "../utils/ssrfGuard.js";
import cron from "node-cron";

const router = Router();


function validateQualityGates(payload) {
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return "qualityGates must be an object";
  const gates = {};
  if (payload.minPassRate != null) {
    if (!Number.isFinite(payload.minPassRate) || payload.minPassRate < 0 || payload.minPassRate > 100) return "minPassRate must be between 0 and 100";
    gates.minPassRate = payload.minPassRate;
  }
  if (payload.maxFlakyPct != null) {
    if (!Number.isFinite(payload.maxFlakyPct) || payload.maxFlakyPct < 0 || payload.maxFlakyPct > 100) return "maxFlakyPct must be between 0 and 100";
    gates.maxFlakyPct = payload.maxFlakyPct;
  }
  if (payload.maxFailures != null) {
    if (!Number.isInteger(payload.maxFailures) || payload.maxFailures < 0) return "maxFailures must be a non-negative integer";
    gates.maxFailures = payload.maxFailures;
  }
  // Reject empty payloads — without at least one gate field, the stored
  // `{}` would render as "Active" in the UI and cause the evaluator to
  // return `{ passed: true }` for every run despite no thresholds being
  // configured. Clients clearing all gates should use DELETE instead.
  if (Object.keys(gates).length === 0) {
    return "qualityGates must contain at least one gate field (minPassRate, maxFlakyPct, maxFailures)";
  }
  return gates;
}


function validateWebVitalsBudgets(payload) {
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return "webVitalsBudgets must be an object";
  const out = {};
  for (const key of ["lcp", "cls", "inp", "ttfb"]) {
    if (payload[key] != null) {
      if (!Number.isFinite(payload[key]) || payload[key] < 0) return `${key} must be a non-negative number`;
      out[key] = payload[key];
    }
  }
  if (Object.keys(out).length === 0) return "webVitalsBudgets must include at least one of: lcp, cls, inp, ttfb";
  return out;
}


// ─── Project CRUD ─────────────────────────────────────────────────────────────


router.get("/:id/environments", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  // REVIEW.md security checklist: never ship plaintext passwords over the
  // wire. Decrypt to read username for display, then funnel through
  // sanitiseEnvCredentialsForClient → { username, _hasAuth: true } only.
  const rows = environmentRepo.listByProject(project.id).map((row) => ({
    ...row,
    credentials: sanitiseEnvCredentialsForClient(decryptCredentials(row.credentials)),
  }));
  res.json(rows);
});

router.post("/:id/environments", requireRole("admin"), async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const name = sanitise(req.body?.name, 120);
  const baseUrl = req.body?.baseUrl?.trim();
  if (!name || !baseUrl) return res.status(400).json({ error: "name and baseUrl are required" });
  // SSRF-safe URL validation — `environment.baseUrl` is used as the crawl
  // target on any run that picks this env (`envScopedProject` in
  // `utils/envScope.js`), so an admin who pastes `http://169.254.169.254`
  // or `http://localhost:6379` here could point the Playwright crawler at
  // cloud-metadata / RFC1918 / loopback hosts. Same two-layer guard used
  // for `previewUrl` on the webhook path (`routes/trigger.js`) and the
  // notification webhooks below.
  const urlErr = await validateUrl(baseUrl);
  if (urlErr) return res.status(400).json({ error: `baseUrl: ${urlErr}` });
  const id = `ENV-${randomUUID()}`;
  const row = { id, projectId: project.id, name, baseUrl, credentials: encryptCredentials(req.body?.credentials) || null, createdAt: new Date().toISOString(), workspaceId: project.workspaceId || null };
  environmentRepo.create(row);
  // Sanitise on the way out — even on POST, the response must NOT echo the
  // password the caller just sent (REVIEW.md: any password round-trip is a
  // logging/proxy/replay leak surface).
  res.status(201).json({ ...row, credentials: sanitiseEnvCredentialsForClient(decryptCredentials(row.credentials)) });
});

router.patch("/:id/environments/:environmentId", requireRole("admin"), async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const existing = environmentRepo.getById(req.params.environmentId);
  if (!existing || existing.projectId !== project.id) return res.status(404).json({ error: "not found" });
  const fields = {};
  if (req.body?.name !== undefined) fields.name = sanitise(req.body.name, 120);
  if (req.body?.baseUrl !== undefined) {
    const trimmed = req.body.baseUrl?.trim() || "";
    // SSRF-safe URL validation on PATCH too — see POST handler above for
    // the full rationale. Empty string short-circuits past the validator
    // because `validateUrl("")` would reject before reaching SQLite, but
    // an empty baseUrl is functionally broken regardless: the next run
    // using this env would fall back to undefined behaviour. We let the
    // validator reject empties so the admin sees a clear error.
    const urlErr = await validateUrl(trimmed);
    if (urlErr) return res.status(400).json({ error: `baseUrl: ${urlErr}` });
    fields.baseUrl = trimmed;
  }
  // Credentials handling — three cases:
  //   1. Key absent      → don't touch the stored value (existing behaviour).
  //   2. `credentials: null` → explicit clear.
  //   3. Object payload  → merge: blank `username` / `password` fall back to
  //      the existing stored value. Required because the frontend no longer
  //      echoes the password (it was a REVIEW.md security violation); without
  //      the merge, editing a row to rename it would also wipe the stored
  //      secret because `password: ""` would re-encrypt as empty. Mirrors the
  //      project PATCH path's merge logic at routes/projects.js:243-273.
  if (Object.hasOwn(req.body || {}, 'credentials')) {
    if (req.body.credentials === null) {
      fields.credentials = null;
    } else if (req.body.credentials && typeof req.body.credentials === "object") {
      const incoming = req.body.credentials;
      const existingDecrypted = decryptCredentials(existing.credentials) || {};
      const merged = {
        username: incoming.username || existingDecrypted.username || "",
        password: incoming.password || existingDecrypted.password || "",
      };
      fields.credentials = (merged.username || merged.password) ? encryptCredentials(merged) : null;
    }
  }
  environmentRepo.update(existing.id, fields);
  const updated = environmentRepo.getById(existing.id);
  res.json({ ...updated, credentials: sanitiseEnvCredentialsForClient(decryptCredentials(updated.credentials)) });
});

router.delete("/:id/environments/:environmentId", requireRole("admin"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const existing = environmentRepo.getById(req.params.environmentId);
  if (!existing || existing.projectId !== project.id) return res.status(404).json({ error: "not found" });
  environmentRepo.remove(existing.id);
  res.json({ ok: true });
});


router.post("/", requireRole("qa_lead"), (req, res) => {
  const validationErr = validateProjectPayload(req.body);
  if (validationErr) return res.status(400).json({ error: validationErr });

  const name = sanitise(req.body.name, 200);
  const url = req.body.url?.trim() || "";
  const credentials = req.body.credentials;

  const id = generateProjectId();
  const project = {
    id,
    name,
    url,
    credentials: encryptCredentials(credentials) || null,
    createdAt: new Date().toISOString(),
    status: "idle",
    workspaceId: req.workspaceId || null,
  };
  projectRepo.create(project);

  logActivity({ ...actor(req),
    type: "project.create", projectId: id, projectName: name,
    detail: `Project created — "${name}" (${url})`,
  });

  res.status(201).json(sanitiseProjectForClient(project));
});

router.get("/", (req, res) => {
  res.json(projectRepo.getAll(req.workspaceId).map(sanitiseProjectForClient));
});

router.get("/:id", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  res.json(sanitiseProjectForClient(project));
});

/**
 * PATCH /api/projects/:id
 * Update a project's name, URL, and/or credentials.
 *
 * Credentials handling:
 *   - `credentials: null` clears stored credentials entirely.
 *   - If `credentials` is an object with blank `username` or `password`,
 *     the existing encrypted value for that field is preserved. This lets
 *     the edit form round-trip without requiring the user to re-type secrets
 *     (which the server never sends back — see `projectSanitiser.js`).
 */
router.patch("/:id", requireRole("qa_lead"), (req, res) => {
  const existing = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!existing) return res.status(404).json({ error: "not found" });

  // Allow threshold-only PATCHes (from AutoApprovalPanel) to skip the
  // name/url validation gate. To keep this bypass tight, require the body
  // to contain *only* `autoApproveThreshold` — any extra keys (e.g. an
  // attempted `status` or `workspaceId` injection) force the request
  // back through `validateProjectPayload` and the field whitelist below.
  // Allow threshold-only PATCHes (from AutoApprovalPanel) to skip the
  // name/url validation gate. To keep this bypass tight, require the body
  // to contain *only* `autoApproveThreshold` — any extra keys (e.g. an
  // attempted `status` or `workspaceId` injection) force the request
  // back through `validateProjectPayload` and the field whitelist below.
  const bodyKeys = req.body && typeof req.body === "object" ? Object.keys(req.body) : [];
  // Single-field PATCH bypass — `autoApproveThreshold` (AUTO-003b) and
  // `iterationCap` (CAP-001) are configured from dedicated panels in
  // `ProjectQualityCard.jsx` that send only their own field. Each bypass
  // is gated on the body containing *exactly* its field name so an
  // attempted `status` / `workspaceId` injection still falls through to
  // the full `validateProjectPayload` + field-whitelist path below.
  const SINGLE_FIELD_BYPASS = new Set(["autoApproveThreshold", "iterationCap", "strictPiiFirewall", "piiAllowlist"]);
  const isSingleFieldPatch = bodyKeys.length === 1 && SINGLE_FIELD_BYPASS.has(bodyKeys[0]);
  if (!isSingleFieldPatch) {
    const validationErr = validateProjectPayload(req.body);
    if (validationErr) return res.status(400).json({ error: validationErr });
  }

  const name = req.body.name !== undefined ? sanitise(req.body.name, 200) : existing.name;
  const url  = req.body.url !== undefined ? (req.body.url?.trim() || "") : existing.url;

  const fields = { name, url };
  if (Object.hasOwn(req.body, "autoApproveThreshold")) {
    const threshold = req.body.autoApproveThreshold;
    // Disallow 0 to prevent a footgun: with `confidenceScore >= 0` always
    // true, threshold=0 would auto-approve every generated test (including
    // zero-quality ones). Use `null` to disable auto-approval.
    if (threshold !== null && (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1)) {
      return res.status(400).json({ error: "autoApproveThreshold must be null or a number greater than 0 and at most 1." });
    }
    fields.autoApproveThreshold = threshold;
  }

  // CAP-001: per-project fixture iteration cap. `null` clears the column so
  // the server-side default (10) re-applies. Otherwise must be an integer
  // in [1, 100] — the runtime `clampIterationCap` also enforces this range,
  // but rejecting bad input at the API surface gives the user a clear error
  // instead of silently clamping a typo.
  if (Object.hasOwn(req.body, "iterationCap")) {
    const cap = req.body.iterationCap;
    if (cap !== null && (!Number.isInteger(cap) || cap < 1 || cap > 100)) {
      return res.status(400).json({ error: "iterationCap must be null or an integer between 1 and 100." });
    }
    fields.iterationCap = cap;
  }

  // SEC-006: per-project PII firewall toggle. `null` is rejected — the
  // column is `NOT NULL DEFAULT 1`, so clients clear the override by
  // explicitly sending `true` (the default) instead. Only booleans are
  // accepted; the bypass set at the top of this handler already restricts
  // single-field PATCHes to this exact key.
  if (Object.hasOwn(req.body, "strictPiiFirewall")) {
    const v = req.body.strictPiiFirewall;
    if (typeof v !== "boolean") {
      return res.status(400).json({ error: "strictPiiFirewall must be a boolean." });
    }
    fields.strictPiiFirewall = v;
  }

  // SEC-006: per-project PII allowlist. Accepts `null` (clear) or an
  // array of non-empty strings. Bounded length to keep the per-redact
  // Set lookup cheap and to discourage clients from pasting megabytes
  // of "allowed" values into the column.
  if (Object.hasOwn(req.body, "piiAllowlist")) {
    const v = req.body.piiAllowlist;
    if (v === null) {
      fields.piiAllowlist = null;
    } else if (Array.isArray(v) && v.every((s) => typeof s === "string") && v.length <= 200) {
      fields.piiAllowlist = v.map((s) => s.trim()).filter(Boolean);
    } else {
      return res.status(400).json({ error: "piiAllowlist must be null or an array of up to 200 strings." });
    }
  }

  if (req.body.credentials === null) {
    fields.credentials = null;
  } else if (req.body.credentials) {
    // Merge: any blank secret falls back to the existing stored value so the
    // client can PATCH without re-sending the password.
    //
    // We normalise `existing.credentials` through `decryptCredentials()` first
    // so legacy unencrypted rows (no `_encrypted` flag, pre-dating the
    // encryption module) and current encrypted rows are both handled
    // uniformly. Then the merged plaintext object is passed through
    // `encryptCredentials()` exactly once — no post-hoc copy of raw stored
    // fields into a `_encrypted: true` object, which would mix plaintext
    // values into an "encrypted" envelope and permanently corrupt the row
    // (next `decryptCredentials()` call throws and returns `null`).
    //
    // Selectors fall back to existing values when the client omits or blanks
    // them, so editing a legacy explicit-selector project doesn't silently
    // wipe its login strategy. The new frontend only sends username +
    // password (selectors are auto-detected at crawl time); without this
    // fallback any project rename/credential rotation would clobber the
    // saved selectors.
    const incoming = req.body.credentials;
    const existingDecrypted = decryptCredentials(existing.credentials) || {};
    const merged = {
      usernameSelector: incoming.usernameSelector ?? existingDecrypted.usernameSelector ?? "",
      passwordSelector: incoming.passwordSelector ?? existingDecrypted.passwordSelector ?? "",
      submitSelector:   incoming.submitSelector   ?? existingDecrypted.submitSelector   ?? "",
      username: incoming.username || existingDecrypted.username || "",
      password: incoming.password || existingDecrypted.password || "",
    };
    fields.credentials = encryptCredentials(merged);
  }

  projectRepo.update(req.params.id, fields);

  logActivity({ ...actor(req),
    type: "project.update", projectId: req.params.id, projectName: name,
    detail: `Project updated — "${name}" (${url})`,
  });

  const updated = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  res.json(sanitiseProjectForClient(updated));
});

router.delete("/:id", requireRole("admin"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });

  // Refuse soft-deletion while async operations are in progress
  const activeRun = runRepo.findActiveByProjectId(req.params.id);
  if (activeRun) {
    return res.status(409).json({
      error: "Cannot delete project while operations are running. Wait for active crawls or test runs to complete.",
    });
  }

  // Check for automation config that will be permanently destroyed (not recoverable
  // via restore) so the response can inform the frontend for a user-facing warning.
  const existingTokens  = webhookTokenRepo.getByProjectId(req.params.id);
  const existingSchedule = scheduleRepo.getByProjectId(req.params.id);

  // Wrap the cascade soft-delete in a transaction so all three tables get the
  // same `datetime('now')` value.  This guarantees the cascade-restore in
  // recycleBin.js (which uses `deletedAt >= project.deletedAt`) never misses
  // children due to a second-boundary crossing between separate statements.
  const db = getDatabase();
  let testIds, runIds;
  db.transaction(() => {
    projectRepo.deleteById(req.params.id);
    testIds = testRepo.deleteByProjectId(req.params.id);
    runIds  = runRepo.deleteByProjectId(req.params.id);
    // Trigger tokens are not soft-deleted — they are always hard-deleted
    // immediately since they are security credentials, not recoverable data.
    // Restoring the project will NOT restore these — CI pipelines will need
    // new tokens.
    webhookTokenRepo.deleteByProjectId(req.params.id);
    // Stop any armed cron task so the scheduler doesn't keep firing for a
    // soft-deleted project (which would log repeated warnings every interval).
    // Restoring the project will NOT restore the schedule — it must be
    // reconfigured manually.
    scheduleRepo.deleteByProjectId(req.params.id);
  })();
  stopSchedule(req.params.id);

  logActivity({ ...actor(req),
    type: "project.delete", projectId: req.params.id, projectName: project.name,
    detail: `Project soft-deleted — "${project.name}" (${testIds.length} tests, ${runIds.length} runs moved to recycle bin)`,
  });

  res.json({
    ok: true,
    deletedTests: testIds.length,
    deletedRuns: runIds.length,
    // Inform the client about permanently destroyed automation config so the
    // frontend can display a warning (these are NOT restored on project restore).
    destroyedTokens: existingTokens.length,
    destroyedSchedule: !!existingSchedule,
  });
});


/**
 * GET /api/v1/projects/:id/pages
 * Return URLs discovered on the latest successful crawl (or recorder run)
 * for this project, with the project's seed URL prepended so the dropdown
 * is never empty.
 *
 * Backed by `runRepo.getLatestDiscoveredPageUrls()` so the query is a
 * single `SELECT pages LIMIT 1` instead of `SELECT *` + per-row JSON parse
 * of every run's heavy columns — the dropdown is fetched on every
 * recorder modal open and previously scaled poorly on projects with long
 * run history. We intentionally do NOT filter on `status = 'completed'`:
 * `crawler.js` flips status to `"completed_empty"` when a crawl finishes
 * without generating tests (auth-walled sites, SPAs with no interactive
 * elements, AI-rate-limited runs), but those runs still persist a valid
 * `run.pages` array — filtering them out caused the dropdown to show only
 * the seed URL on any project whose latest crawl didn't yield tests.
 */
router.get("/:id/pages", requireRole("viewer"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const pages = runRepo.getLatestDiscoveredPageUrls(req.params.id);
  const unique = Array.from(new Set([project.url, ...pages].filter(Boolean)));
  res.json({ urls: unique });
});

router.get("/:id/quality-gates", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  res.json({ qualityGates: project.qualityGates || null });
});

router.patch("/:id/quality-gates", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const validated = validateQualityGates(req.body?.qualityGates ?? req.body);
  if (typeof validated === "string") return res.status(400).json({ error: validated });
  projectRepo.update(req.params.id, { qualityGates: validated });
  const updated = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  res.json({ qualityGates: updated.qualityGates || null });
});

router.delete("/:id/quality-gates", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  projectRepo.update(req.params.id, { qualityGates: null });
  res.json({ ok: true, qualityGates: null });
});


router.get("/:id/web-vitals-budgets", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  res.json({ webVitalsBudgets: project.webVitalsBudgets || null });
});

router.patch("/:id/web-vitals-budgets", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const validated = validateWebVitalsBudgets(req.body?.webVitalsBudgets ?? req.body);
  if (typeof validated === "string") return res.status(400).json({ error: validated });
  projectRepo.update(req.params.id, { webVitalsBudgets: validated });
  const updated = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  res.json({ webVitalsBudgets: updated.webVitalsBudgets || null });
});

router.delete("/:id/web-vitals-budgets", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  projectRepo.update(req.params.id, { webVitalsBudgets: null });
  res.json({ ok: true, webVitalsBudgets: null });
});


/**
 * GET /api/v1/projects/:id/metrics?key=<metricKey>&since=<ms>&limit=<n>
 *
 * Read a project's time-series samples for a single metric (MET-001 +
 * AUTO-017.3). Powers the `<TrendChart>` instances in
 * `ProjectQualityCard`'s Web Vitals tab. Workspace-scoped — falls through
 * to the same 404 as every other project route when the caller isn't a
 * member.
 */
router.get("/:id/metrics", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "not found" });
  const key = typeof req.query.key === "string" ? req.query.key : "";
  if (!key) return res.status(400).json({ error: "key is required" });
  const since = Number.isFinite(Number(req.query.since)) ? Number(req.query.since) : 0;
  const rawLimit = Number(req.query.limit);
  // Cap at 200 to match `getSeries`'s default ceiling — keeps the
  // `<TrendChart>` last-30 window cheap and prevents callers from
  // pulling the whole table.
  const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(200, Math.floor(rawLimit))) : 200;
  const samples = metricSamplesRepo.getSeries(req.params.id, key, { since, limit });
  res.json({ samples });
});

// ─── Schedule endpoints ───────────────────────────────────────────────────────

/**
 * GET /api/projects/:id/schedule
 * Return the current schedule for a project, or null if none exists.
 */
router.get("/:id/schedule", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });
  const schedule = scheduleRepo.getByProjectId(req.params.id);
  res.json({ schedule: schedule || null });
});

/**
 * PATCH /api/projects/:id/schedule
 * Create or update the cron schedule for a project.
 *
 * Body:
 *   cronExpr {string}  - 5-field cron expression (required)
 *   timezone {string}  - IANA timezone name (default "UTC")
 *   enabled  {boolean} - Whether the schedule is active (default true)
 */
router.patch("/:id/schedule", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });

  const { cronExpr, timezone = "UTC", enabled = true } = req.body || {};

  if (!cronExpr || typeof cronExpr !== "string") {
    return res.status(400).json({ error: "cronExpr is required" });
  }
  if (!cron.validate(cronExpr)) {
    return res.status(400).json({ error: `Invalid cron expression: "${cronExpr}"` });
  }
  // Reject expressions with seconds field (6-part) — node-cron supports it
  // but we only expose the standard 5-field format to users.
  if (cronExpr.trim().split(/\s+/).length !== 5) {
    return res.status(400).json({ error: "cronExpr must be a standard 5-field expression (minute hour dom month dow)" });
  }

  // Validate timezone — an invalid IANA name would throw a RangeError in
  // toLocaleString (used by getNextRunAt) and crash with a 500.
  try {
    Intl.DateTimeFormat(undefined, { timeZone: timezone });
  } catch {
    return res.status(400).json({ error: `Invalid timezone: "${timezone}"` });
  }

  const existing = scheduleRepo.getByProjectId(req.params.id);
  const now = new Date().toISOString();
  const nextRunAt = getNextRunAt(cronExpr, timezone);

  const schedule = scheduleRepo.upsert({
    id: existing?.id || generateScheduleId(),
    projectId: req.params.id,
    cronExpr,
    timezone,
    enabled: Boolean(enabled),
    lastRunAt: existing?.lastRunAt || null,
    nextRunAt,
    createdAt: existing?.createdAt || now,
    updatedAt: now,
  });

  // Hot-reload the cron task without a restart
  reloadSchedule(req.params.id);

  logActivity({
    ...actor(req),
    type: "schedule.update",
    projectId: project.id,
    projectName: project.name,
    detail: `Schedule ${existing ? "updated" : "created"} — ${cronExpr} (${timezone})`,
  });

  res.json({ ok: true, schedule });
});

/**
 * DELETE /api/projects/:id/schedule
 * Remove the cron schedule for a project entirely.
 */
router.delete("/:id/schedule", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });

  const existing = scheduleRepo.getByProjectId(req.params.id);
  if (!existing) return res.status(404).json({ error: "No schedule found for this project" });

  scheduleRepo.deleteByProjectId(req.params.id);
  stopSchedule(req.params.id);

  logActivity({
    ...actor(req),
    type: "schedule.delete",
    projectId: project.id,
    projectName: project.name,
    detail: `Schedule removed`,
  });

  res.json({ ok: true });
});

// ─── Notification settings endpoints (FEA-001) ───────────────────────────────

/**
 * GET /api/projects/:id/notifications
 * Return the notification settings for a project, or null if none exist.
 */
router.get("/:id/notifications", (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });
  const settings = notificationSettingsRepo.getByProjectId(req.params.id);
  res.json({ notifications: settings || null });
});

/**
 * PATCH /api/projects/:id/notifications
 * Create or update notification settings for a project.
 *
 * Body:
 *   teamsWebhookUrl  {string}  - Microsoft Teams incoming webhook URL (optional)
 *   emailRecipients  {string}  - Comma-separated email addresses (optional)
 *   webhookUrl       {string}  - Generic webhook URL (optional)
 *   enabled          {boolean} - Whether notifications are active (default true)
 */
router.patch("/:id/notifications", requireRole("qa_lead"), async (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });

  const { teamsWebhookUrl, emailRecipients, webhookUrl, enabled = true } = req.body || {};

  // Validate at least one channel is configured
  const hasTeams = typeof teamsWebhookUrl === "string" && teamsWebhookUrl.trim();
  const hasEmail = typeof emailRecipients === "string" && emailRecipients.trim();
  const hasWebhook = typeof webhookUrl === "string" && webhookUrl.trim();

  if (!hasTeams && !hasEmail && !hasWebhook) {
    return res.status(400).json({ error: "At least one notification channel must be configured (teamsWebhookUrl, emailRecipients, or webhookUrl)" });
  }

  // SSRF-safe URL validation for webhook URLs (protocol, private hosts, DNS resolution)
  if (hasTeams) {
    const teamsErr = await validateUrl(teamsWebhookUrl);
    if (teamsErr) return res.status(400).json({ error: `teamsWebhookUrl: ${teamsErr}` });
  }
  if (hasWebhook) {
    const webhookErr = await validateUrl(webhookUrl);
    if (webhookErr) return res.status(400).json({ error: `webhookUrl: ${webhookErr}` });
  }

  // Validate email format (basic check)
  if (hasEmail) {
    const emails = emailRecipients.split(",").map(e => e.trim()).filter(Boolean);
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    for (const email of emails) {
      if (!emailRegex.test(email)) {
        return res.status(400).json({ error: `Invalid email address: "${email}"` });
      }
    }
  }

  const existing = notificationSettingsRepo.getByProjectId(req.params.id);
  const now = new Date().toISOString();

  const settings = notificationSettingsRepo.upsert({
    id: existing?.id || generateNotificationSettingId(),
    projectId: req.params.id,
    teamsWebhookUrl: hasTeams ? teamsWebhookUrl.trim() : null,
    emailRecipients: hasEmail ? emailRecipients.trim() : null,
    webhookUrl: hasWebhook ? webhookUrl.trim() : null,
    enabled: Boolean(enabled),
    createdAt: existing?.createdAt || now,
    updatedAt: now,
  });

  logActivity({
    ...actor(req),
    type: "notifications.update",
    projectId: project.id,
    projectName: project.name,
    detail: `Notification settings ${existing ? "updated" : "created"}`,
  });

  res.json({ ok: true, notifications: settings });
});

/**
 * DELETE /api/projects/:id/notifications
 * Remove notification settings for a project.
 */
router.delete("/:id/notifications", requireRole("qa_lead"), (req, res) => {
  const project = projectRepo.getByIdInWorkspace(req.params.id, req.workspaceId);
  if (!project) return res.status(404).json({ error: "Project not found" });

  const existing = notificationSettingsRepo.getByProjectId(req.params.id);
  if (!existing) return res.status(404).json({ error: "No notification settings found for this project" });

  notificationSettingsRepo.deleteByProjectId(req.params.id);

  logActivity({
    ...actor(req),
    type: "notifications.delete",
    projectId: project.id,
    projectName: project.name,
    detail: "Notification settings removed",
  });

  res.json({ ok: true });
});

export default router;