const express      = require('express');
const cors         = require('cors');
const path         = require('path');
const db           = require('./db/database');
const auth          = require('./auth');
const generatePdf  = require('./pdf/generator');
const { computeStanding } = require('./lib/rolloff');

const app  = express();
const PORT = process.env.PORT || 3001;

// Create / sync the bootstrap admin account from ADMIN_USERNAME / ADMIN_PASSWORD.
auth.bootstrapAdmin();

app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'client', 'dist')));

// ── Demo static route ─────────────────────────────────────────────────────────
// Serves the standalone stakeholder demo page at /demo/index.html
// Must be registered before the SPA catch-all below.
app.use('/demo', express.static(path.join(__dirname, 'demo')));

// ── Audit helper ─────────────────────────────────────────────────────────────
function audit(action, entityType, entityId, performedBy, details) {
  try {
    db.prepare(`
      INSERT INTO audit_log (action, entity_type, entity_id, performed_by, details)
      VALUES (?, ?, ?, ?, ?)
    `).run(action, entityType, entityId || null, performedBy || null,
      typeof details === 'object' ? JSON.stringify(details) : (details || null));
  } catch (e) {
    console.error('[AUDIT]', e.message);
  }
}

// ── CPAS standing helpers ────────────────────────────────────────────────────
// All point/roll-off math lives in lib/rolloff.js. These helpers just feed it
// the employee's non-negated violations and return the computed standing.
function loadViolations(employeeId) {
  return db.prepare(
    `SELECT id, points, incident_date, negated, violation_name, violation_type, category
     FROM violations
     WHERE employee_id = ? AND negated = 0`
  ).all(employeeId);
}
function standingFor(employeeId, asOf) {
  return computeStanding(loadViolations(employeeId), asOf);
}

// ── Version info (written by Dockerfile at build time) ───────────────────────
// Falls back to { sha: 'dev' } when running outside a Docker build (local dev).
let BUILD_VERSION = { sha: 'dev', shortSha: 'dev', buildTime: null };
try {
  BUILD_VERSION = require('./client/dist/version.json');
} catch (_) { /* pre-build or local dev — stub values are fine */ }

// Health
app.get('/api/health', (req, res) => res.json({
  status:    'ok',
  timestamp: new Date().toISOString(),
  version:   BUILD_VERSION,
}));

// ── Authentication (public) ──────────────────────────────────────────────────
// POST /api/auth/login — exchange username + password for a session token.
app.post('/api/auth/login', (req, res) => {
  const { username, password } = req.body || {};
  if (!username || !password) return res.status(400).json({ error: 'username and password are required' });

  const result = auth.login(username, password);
  if (!result) {
    audit('login_failed', 'user', null, username, {});
    return res.status(401).json({ error: 'Invalid username or password' });
  }
  audit('login_success', 'user', result.user.id, result.user.username, {});
  res.json(result);
});

// ── Auth guard ────────────────────────────────────────────────────────────────
// Every /api/* route registered below this line requires a valid session token.
app.use('/api', auth.requireAuth);

// GET /api/auth/me — current session's user
app.get('/api/auth/me', (req, res) => res.json({ user: req.user }));

// POST /api/auth/logout — invalidate the current session token
app.post('/api/auth/logout', (req, res) => {
  auth.destroySession(req.authToken);
  res.json({ success: true });
});

// ── User management (admin only) ──────────────────────────────────────────────
app.get('/api/users', auth.requireAdmin, (req, res) => {
  res.json(auth.listUsers());
});

app.post('/api/users', auth.requireAdmin, (req, res) => {
  try {
    const user = auth.createUser(req.body || {});
    audit('user_created', 'user', user.id, req.user.username, { username: user.username, role: user.role });
    res.status(201).json(user);
  } catch (err) {
    res.status(err.status || 500).json({ error: err.message });
  }
});

app.patch('/api/users/:id/password', auth.requireAdmin, (req, res) => {
  try {
    const id = parseInt(req.params.id);
    if (!auth.setPassword(id, (req.body || {}).password)) return res.status(404).json({ error: 'User not found' });
    audit('user_password_reset', 'user', id, req.user.username, {});
    res.json({ success: true });
  } catch (err) {
    res.status(err.status || 500).json({ error: err.message });
  }
});

app.delete('/api/users/:id', auth.requireAdmin, (req, res) => {
  const id = parseInt(req.params.id);
  if (id === req.user.id) return res.status(400).json({ error: 'You cannot delete your own account' });
  if (!auth.deleteUser(id)) return res.status(404).json({ error: 'User not found' });
  audit('user_deleted', 'user', id, req.user.username, {});
  res.json({ success: true });
});

// ── Employees ────────────────────────────────────────────────────────────────
app.get('/api/employees', (req, res) => {
  const rows = db.prepare('SELECT id, name, department, supervisor, notes FROM employees ORDER BY name ASC').all();
  res.json(rows);
});

// GET /api/employees/:id — single employee record
app.get('/api/employees/:id', (req, res) => {
  const emp = db.prepare('SELECT id, name, department, supervisor, notes FROM employees WHERE id = ?').get(req.params.id);
  if (!emp) return res.status(404).json({ error: 'Employee not found' });
  res.json(emp);
});

app.post('/api/employees', (req, res) => {
  const { name, department, supervisor } = req.body;
  if (!name) return res.status(400).json({ error: 'name is required' });
  const existing = db.prepare('SELECT * FROM employees WHERE LOWER(name) = LOWER(?)').get(name);
  if (existing) {
    if (department || supervisor) {
      db.prepare('UPDATE employees SET department = COALESCE(?, department), supervisor = COALESCE(?, supervisor) WHERE id = ?')
        .run(department || null, supervisor || null, existing.id);
    }
    // TODO [MINOR #16]: Spreading `existing` then overwriting with possibly-undefined
    // `department`/`supervisor` returns `undefined` for unset fields.
    // Re-query after update or only spread defined values.
    return res.json({ ...existing, department, supervisor });
  }
  const result = db.prepare('INSERT INTO employees (name, department, supervisor) VALUES (?, ?, ?)')
    .run(name, department || null, supervisor || null);
  audit('employee_created', 'employee', result.lastInsertRowid, null, { name });
  res.status(201).json({ id: result.lastInsertRowid, name, department, supervisor });
});

// ── Employee Edit ────────────────────────────────────────────────────────────
// PATCH /api/employees/:id — update name, department, supervisor, or notes
app.patch('/api/employees/:id', (req, res) => {
  const id  = parseInt(req.params.id);
  const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id);
  if (!emp) return res.status(404).json({ error: 'Employee not found' });

  const { name, department, supervisor, notes, performed_by } = req.body;

  // Prevent name collision with a different employee
  if (name && name.trim() !== emp.name) {
    const clash = db.prepare('SELECT id FROM employees WHERE LOWER(name) = LOWER(?) AND id != ?').get(name.trim(), id);
    if (clash) return res.status(409).json({ error: 'An employee with that name already exists', conflictId: clash.id });
  }

  const newName       = (name       || emp.name).trim();
  const newDept       = department  !== undefined ? (department  || null) : emp.department;
  const newSupervisor = supervisor  !== undefined ? (supervisor  || null) : emp.supervisor;
  const newNotes      = notes       !== undefined ? (notes       || null) : emp.notes;

  db.prepare('UPDATE employees SET name = ?, department = ?, supervisor = ?, notes = ? WHERE id = ?')
    .run(newName, newDept, newSupervisor, newNotes, id);

  audit('employee_edited', 'employee', id, performed_by, {
    before: { name: emp.name, department: emp.department, supervisor: emp.supervisor, notes: emp.notes },
    after:  { name: newName,  department: newDept,        supervisor: newSupervisor,  notes: newNotes  },
  });

  res.json({ id, name: newName, department: newDept, supervisor: newSupervisor, notes: newNotes });
});

// ── Employee Merge ───────────────────────────────────────────────────────────
// POST /api/employees/:id/merge — reassign all violations from sourceId → id, then delete source
app.post('/api/employees/:id/merge', (req, res) => {
  const targetId = parseInt(req.params.id);
  const { source_id, performed_by } = req.body;
  if (!source_id) return res.status(400).json({ error: 'source_id is required' });

  const target = db.prepare('SELECT * FROM employees WHERE id = ?').get(targetId);
  const source = db.prepare('SELECT * FROM employees WHERE id = ?').get(source_id);
  if (!target) return res.status(404).json({ error: 'Target employee not found' });
  if (!source) return res.status(404).json({ error: 'Source employee not found' });
  if (targetId === parseInt(source_id)) return res.status(400).json({ error: 'Cannot merge an employee into themselves' });

  const mergeTransaction = db.transaction(() => {
    // Move all violations
    const moved = db.prepare('UPDATE violations SET employee_id = ? WHERE employee_id = ?').run(targetId, source_id);
    // Delete the source employee
    db.prepare('DELETE FROM employees WHERE id = ?').run(source_id);
    return moved.changes;
  });

  const violationsMoved = mergeTransaction();

  audit('employee_merged', 'employee', targetId, performed_by, {
    source: { id: source.id, name: source.name },
    target: { id: target.id, name: target.name },
    violations_reassigned: violationsMoved,
  });

  res.json({ success: true, violations_reassigned: violationsMoved });
});

// ── Employee notes (PATCH shorthand) ─────────────────────────────────────────
// PATCH /api/employees/:id/notes — save free-text notes only
app.patch('/api/employees/:id/notes', (req, res) => {
  const id  = parseInt(req.params.id);
  const emp = db.prepare('SELECT * FROM employees WHERE id = ?').get(id);
  if (!emp) return res.status(404).json({ error: 'Employee not found' });

  const { notes, performed_by } = req.body;
  const newNotes = notes !== undefined ? (notes || null) : emp.notes;

  db.prepare('UPDATE employees SET notes = ? WHERE id = ?').run(newNotes, id);
  audit('employee_notes_updated', 'employee', id, performed_by, { notes: newNotes });
  res.json({ id, notes: newNotes });
});

// Employee score (current snapshot) — includes total violations + negated count
app.get('/api/employees/:id/score', (req, res) => {
  const empId = req.params.id;

  // Active points from the clean-cycle roll-off model
  const standing = standingFor(empId);

  // Total violations (all time) and negated count
  const totals = db.prepare(`
    SELECT
      COUNT(*)                    AS total_violations,
      COALESCE(SUM(negated), 0)  AS negated_count
    FROM violations
    WHERE employee_id = ?
  `).get(empId);

  res.json({
    employee_id:      empId,
    active_points:    standing.activePoints,
    violation_count:  standing.violationCount,
    total_violations: totals ? totals.total_violations  : 0,
    negated_count:    totals ? totals.negated_count     : 0,
  });
});

// ── Roll-off Timeline ────────────────────────────────────────────────────────
// GET /api/employees/:id/expiration — projected clean-cycle roll-off schedule.
// Points only retire after 90 consecutive days with no new violation; any new
// violation resets the clock. Returns the active total plus each future 5-point
// roll-off event (oldest points first) until the balance reaches zero.
app.get('/api/employees/:id/expiration', (req, res) => {
  const standing = standingFor(req.params.id);
  res.json({
    active_points:       standing.activePoints,
    last_violation_date: standing.lastViolationDate,
    next_rolloff_date:   standing.nextRolloffDate,
    schedule:            standing.schedule,
  });
});

// Dashboard
app.get('/api/dashboard', (req, res) => {
  const employees = db.prepare(
    'SELECT id, name, department, supervisor FROM employees'
  ).all();

  // Pull every non-negated violation once, bucket by employee, then run the
  // roll-off model per employee rather than issuing a query each.
  const buckets = new Map();
  for (const v of db.prepare(
    `SELECT id, employee_id, points, incident_date, negated
     FROM violations WHERE negated = 0`
  ).all()) {
    if (!buckets.has(v.employee_id)) buckets.set(v.employee_id, []);
    buckets.get(v.employee_id).push(v);
  }

  const rows = employees.map(e => {
    const standing = computeStanding(buckets.get(e.id) || []);
    return {
      ...e,
      active_points:   standing.activePoints,
      violation_count: standing.violationCount,
    };
  });

  rows.sort((a, b) =>
    b.active_points - a.active_points || a.name.localeCompare(b.name)
  );
  res.json(rows);
});

// Violation history (per employee) with resolutions + amendment count
app.get('/api/violations/employee/:id', (req, res) => {
  const limit = parseInt(req.query.limit) || 50;
  const rows  = db.prepare(`
    SELECT v.*, r.resolution_type, r.details AS resolution_details,
           r.resolved_by, r.created_at AS resolved_at,
           (SELECT COUNT(*) FROM violation_amendments a WHERE a.violation_id = v.id) AS amendment_count
    FROM violations v
    LEFT JOIN violation_resolutions r ON r.violation_id = v.id
    WHERE v.employee_id = ?
    ORDER BY v.incident_date DESC, v.created_at DESC
    LIMIT ?
  `).all(req.params.id, limit);
  res.json(rows);
});

// ── Violation amendment history ──────────────────────────────────────────────
app.get('/api/violations/:id/amendments', (req, res) => {
  const rows = db.prepare(`
    SELECT * FROM violation_amendments WHERE violation_id = ? ORDER BY created_at DESC
  `).all(req.params.id);
  res.json(rows);
});

// Helper: compute prior_active_points at time of insert — the employee's active
// CPAS standing the instant BEFORE this incident, under the clean-cycle model.
// Uses only violations strictly earlier than this incident, evaluated as of the
// incident date.
function getPriorActivePoints(employeeId, incidentDate) {
  const earlier = db.prepare(
    `SELECT id, points, incident_date, negated
     FROM violations
     WHERE employee_id = ? AND negated = 0 AND incident_date < ?`
  ).all(employeeId, incidentDate);
  return computeStanding(earlier, incidentDate).activePoints;
}

// Helper: after a back-dated insert, refresh snapshots on every later
// violation. Under the clean-cycle roll-off model an earlier incident raises
// the running total and shifts the roll-off clock for ALL subsequent
// violations, so their "Prior Active Points" snapshots can all change — not
// just those within 90 days. Snapshots remain immutable w.r.t. negate/restore;
// only timeline-rewriting events (back-dated inserts) trigger a refresh.
function recomputeSnapshotsAfter(employeeId, incidentDate) {
  const affected = db.prepare(`
    SELECT id, incident_date, prior_active_points
    FROM violations
    WHERE employee_id = ?
      AND incident_date > ?
  `).all(employeeId, incidentDate);

  const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
  const changes = [];
  for (const v of affected) {
    const newPrior = getPriorActivePoints(employeeId, v.incident_date);
    if (newPrior !== v.prior_active_points) {
      updateStmt.run(newPrior, v.id);
      changes.push({ id: v.id, incident_date: v.incident_date, old: v.prior_active_points, new: newPrior });
    }
  }
  return changes;
}

// Helper: rebuild prior_active_points for every violation belonging to an
// employee, regardless of negate state. Used by the manual backfill button
// to repair snapshots after a back-dated insert that happened under older
// code (before recomputeSnapshotsAfter existed) or any case where the
// per-row snapshot has drifted from current data.
function recomputeAllSnapshotsForEmployee(employeeId) {
  const rows = db.prepare(`
    SELECT id, incident_date, prior_active_points
    FROM violations
    WHERE employee_id = ?
    ORDER BY incident_date ASC
  `).all(employeeId);

  const updateStmt = db.prepare('UPDATE violations SET prior_active_points = ? WHERE id = ?');
  const changes = [];
  for (const v of rows) {
    const newPrior = getPriorActivePoints(employeeId, v.incident_date);
    if (newPrior !== v.prior_active_points) {
      updateStmt.run(newPrior, v.id);
      changes.push({ id: v.id, incident_date: v.incident_date, old: v.prior_active_points, new: newPrior });
    }
  }
  return { scanned: rows.length, changes };
}

// POST /api/employees/:id/recompute-snapshots
// Manual backfill — rebuild prior_active_points for every violation on this
// employee. Use after a back-dated insert under older code left downstream
// PDFs showing stale "Prior Active Points".
app.post('/api/employees/:id/recompute-snapshots', (req, res) => {
  const empId = parseInt(req.params.id);
  const emp   = db.prepare('SELECT id, name FROM employees WHERE id = ?').get(empId);
  if (!emp) return res.status(404).json({ error: 'Employee not found' });

  const result = db.transaction(() => recomputeAllSnapshotsForEmployee(empId))();

  if (result.changes.length > 0) {
    audit('violation_snapshots_recomputed', 'employee', empId, req.body?.performed_by, {
      reason: 'manual_backfill',
      scanned: result.scanned,
      affected: result.changes,
    });
  }

  res.json({
    success:  true,
    scanned:  result.scanned,
    updated:  result.changes.length,
    changes:  result.changes,
  });
});

// POST new violation
app.post('/api/violations', (req, res) => {
  const {
    employee_id, violation_type, violation_name, category,
    points, incident_date, incident_time, location,
    details, submitted_by, witness_name,
    acknowledged_by, acknowledged_date,
    amount
  } = req.body;

  if (!employee_id || !violation_type || !points || !incident_date) {
    return res.status(400).json({ error: 'Missing required fields' });
  }

  const ptsInt   = parseInt(points);

  // Insert + downstream snapshot refresh run in a single transaction so a
  // failed recompute can't leave the system with a new violation and stale
  // sibling snapshots.
  const insertTxn = db.transaction(() => {
    const priorPts = getPriorActivePoints(employee_id, incident_date);
    const result = db.prepare(`
      INSERT INTO violations (
        employee_id, violation_type, violation_name, category,
        points, incident_date, incident_time, location,
        details, submitted_by, witness_name,
        prior_active_points,
        acknowledged_by, acknowledged_date,
        amount
      ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `).run(
      employee_id, violation_type, violation_name || violation_type,
      category || 'General', ptsInt, incident_date,
      incident_time || null, location || null,
      details || null, submitted_by || null, witness_name || null,
      priorPts,
      acknowledged_by || null, acknowledged_date || null,
      amount || null
    );
    const refreshed = recomputeSnapshotsAfter(employee_id, incident_date);
    return { id: result.lastInsertRowid, refreshed };
  });

  const { id: newId, refreshed } = insertTxn();

  audit('violation_created', 'violation', newId, submitted_by, {
    employee_id, violation_type, points: ptsInt, incident_date,
  });

  // Back-dated insert: log the snapshot refresh so the audit trail explains
  // why downstream violations' PDFs now show different "Prior Active Points".
  if (refreshed.length > 0) {
    audit('violation_snapshots_recomputed', 'violation', newId, submitted_by, {
      reason: 'backdated_insert',
      trigger_incident_date: incident_date,
      affected: refreshed,
    });
  }

  res.status(201).json({ id: newId });
});

// ── Violation Amendment (edit) ───────────────────────────────────────────────
// PATCH /api/violations/:id/amend — edit mutable fields; logs a diff per changed field
const AMENDABLE_FIELDS = ['incident_time', 'location', 'details', 'submitted_by', 'witness_name', 'acknowledged_by', 'acknowledged_date', 'amount'];

// Pre-build one prepared UPDATE statement per amendable field combination is not
// practical (2^n combos), so instead we validate columns against the static
// whitelist and build the clause only from known-safe names at startup.
// The whitelist itself is the guard; no user-supplied column name ever enters SQL.
const AMEND_UPDATE_STMTS = Object.fromEntries(
  AMENDABLE_FIELDS.map(f => [
    f,
    db.prepare(`UPDATE violations SET ${f} = ? WHERE id = ?`)
  ])
);

app.patch('/api/violations/:id/amend', (req, res) => {
  const id        = parseInt(req.params.id);
  const { changed_by, ...updates } = req.body;

  const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
  if (!violation) return res.status(404).json({ error: 'Violation not found' });
  if (violation.negated) return res.status(400).json({ error: 'Cannot amend a negated violation' });

  // Only allow whitelisted fields to be amended
  const allowed = Object.fromEntries(
    Object.entries(updates).filter(([k]) => AMENDABLE_FIELDS.includes(k))
  );
  if (Object.keys(allowed).length === 0) {
    return res.status(400).json({ error: 'No amendable fields provided', amendable: AMENDABLE_FIELDS });
  }

  const amendTransaction = db.transaction(() => {
    const insertAmendment = db.prepare(`
      INSERT INTO violation_amendments (violation_id, changed_by, field_name, old_value, new_value)
      VALUES (?, ?, ?, ?, ?)
    `);
    for (const [field, newVal] of Object.entries(allowed)) {
      const oldVal = violation[field];
      // Use the pre-built statement for this field — no runtime interpolation
      AMEND_UPDATE_STMTS[field].run(newVal, id);
      if (String(oldVal) !== String(newVal)) {
        insertAmendment.run(id, changed_by || null, field, oldVal ?? null, newVal ?? null);
      }
    }
  });

  amendTransaction();

  audit('violation_amended', 'violation', id, changed_by, { fields: Object.keys(allowed) });

  const updated = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
  res.json(updated);
});

// ── Negate a violation ───────────────────────────────────────────────────────
app.patch('/api/violations/:id/negate', (req, res) => {
  const { resolution_type, details, resolved_by } = req.body;
  const id = req.params.id;

  const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
  if (!violation) return res.status(404).json({ error: 'Violation not found' });

  db.prepare('UPDATE violations SET negated = 1 WHERE id = ?').run(id);

  const existing = db.prepare('SELECT id FROM violation_resolutions WHERE violation_id = ?').get(id);
  if (existing) {
    db.prepare(`
      UPDATE violation_resolutions
      SET resolution_type = ?, details = ?, resolved_by = ?, created_at = datetime('now')
      WHERE violation_id = ?
    `).run(resolution_type || 'Resolved', details || null, resolved_by || null, id);
  } else {
    db.prepare(`
      INSERT INTO violation_resolutions (violation_id, resolution_type, details, resolved_by)
      VALUES (?, ?, ?, ?)
    `).run(id, resolution_type || 'Resolved', details || null, resolved_by || null);
  }

  audit('violation_negated', 'violation', id, resolved_by, { resolution_type });
  res.json({ success: true });
});

// ── Restore a negated violation ──────────────────────────────────────────────
app.patch('/api/violations/:id/restore', (req, res) => {
  const id = req.params.id;

  const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
  if (!violation) return res.status(404).json({ error: 'Violation not found' });

  db.prepare('UPDATE violations SET negated = 0 WHERE id = ?').run(id);
  db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id);

  audit('violation_restored', 'violation', id, req.body?.performed_by, {});
  res.json({ success: true });
});

// ── Hard delete a violation ──────────────────────────────────────────────────
app.delete('/api/violations/:id', (req, res) => {
  const id = req.params.id;

  const violation = db.prepare('SELECT * FROM violations WHERE id = ?').get(id);
  if (!violation) return res.status(404).json({ error: 'Violation not found' });

  db.prepare('DELETE FROM violation_resolutions WHERE violation_id = ?').run(id);
  db.prepare('DELETE FROM violations WHERE id = ?').run(id);

  audit('violation_deleted', 'violation', id, req.body?.performed_by, {
    violation_type: violation.violation_type, employee_id: violation.employee_id,
  });
  res.json({ success: true });
});

// ── Violation counts per employee ────────────────────────────────────────────
// GET /api/employees/:id/violation-counts
// Returns { violation_type: count } for the rolling 90-day window (non-negated).
app.get('/api/employees/:id/violation-counts', (req, res) => {
  const rows = db.prepare(`
    SELECT violation_type, COUNT(*) AS count
    FROM violations
    WHERE employee_id = ?
      AND negated = 0
      AND incident_date >= DATE('now', '-90 days')
    GROUP BY violation_type
  `).all(req.params.id);
  const result = {};
  for (const r of rows) result[r.violation_type] = r.count;
  res.json(result);
});

// GET /api/employees/:id/violation-counts/alltime
// Returns { violation_type: { count, max_points_used } } across all time (non-negated).
app.get('/api/employees/:id/violation-counts/alltime', (req, res) => {
  const rows = db.prepare(`
    SELECT violation_type, COUNT(*) AS count, MAX(points) AS max_points_used
    FROM violations
    WHERE employee_id = ?
      AND negated = 0
    GROUP BY violation_type
  `).all(req.params.id);
  const result = {};
  for (const r of rows) result[r.violation_type] = { count: r.count, max_points_used: r.max_points_used };
  res.json(result);
});

// ── Audit log ────────────────────────────────────────────────────────────────
app.get('/api/audit', (req, res) => {
  const limit  = Math.min(parseInt(req.query.limit)  || 100, 500);
  const offset = parseInt(req.query.offset) || 0;
  const type   = req.query.entity_type;
  const id     = req.query.entity_id;

  let sql    = 'SELECT * FROM audit_log';
  const args = [];
  const where = [];
  if (type) { where.push('entity_type = ?'); args.push(type); }
  if (id)   { where.push('entity_id = ?');   args.push(id); }
  if (where.length) sql += ' WHERE ' + where.join(' AND ');
  sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
  args.push(limit, offset);

  res.json(db.prepare(sql).all(...args));
});

// ── Custom Violation Types ────────────────────────────────────────────────────
// Persisted violation type definitions stored in violation_types table.
// type_key is auto-generated (custom_<slug>) to avoid collisions with hardcoded keys.

app.get('/api/violation-types', (req, res) => {
  const rows = db.prepare('SELECT * FROM violation_types ORDER BY category ASC, name ASC').all();
  res.json(rows.map(r => ({ ...r, fields: JSON.parse(r.fields) })));
});

app.post('/api/violation-types', (req, res) => {
  const { name, category, chapter, description, min_points, max_points, fields, created_by } = req.body;
  if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });

  const minPts = parseInt(min_points) || 1;
  const maxPts = parseInt(max_points) || minPts;
  if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });

  // Generate a unique type_key from the name, prefixed with 'custom_'
  const base = 'custom_' + name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
  let typeKey = base;
  let suffix  = 2;
  while (db.prepare('SELECT id FROM violation_types WHERE type_key = ?').get(typeKey)) {
    typeKey = `${base}_${suffix++}`;
  }

  try {
    const result = db.prepare(`
      INSERT INTO violation_types (type_key, name, category, chapter, description, min_points, max_points, fields)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    `).run(
      typeKey,
      name.trim(),
      (category || 'Custom').trim(),
      chapter     || null,
      description || null,
      minPts,
      maxPts,
      JSON.stringify(fields && fields.length ? fields : ['description'])
    );
    const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(result.lastInsertRowid);
    audit('violation_type_created', 'violation_type', result.lastInsertRowid, created_by || null, { name: row.name, category: row.category });
    res.status(201).json({ ...row, fields: JSON.parse(row.fields) });
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.put('/api/violation-types/:id', (req, res) => {
  const id  = parseInt(req.params.id);
  const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
  if (!row) return res.status(404).json({ error: 'Violation type not found' });

  const { name, category, chapter, description, min_points, max_points, fields, updated_by } = req.body;
  if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });

  const minPts = parseInt(min_points) || 1;
  const maxPts = parseInt(max_points) || minPts;
  if (maxPts < minPts) return res.status(400).json({ error: 'max_points must be >= min_points' });

  db.prepare(`
    UPDATE violation_types
    SET name=?, category=?, chapter=?, description=?, min_points=?, max_points=?, fields=?, updated_at=CURRENT_TIMESTAMP
    WHERE id=?
  `).run(
    name.trim(),
    (category || 'Custom').trim(),
    chapter     || null,
    description || null,
    minPts,
    maxPts,
    JSON.stringify(fields && fields.length ? fields : ['description']),
    id
  );

  const updated = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
  audit('violation_type_updated', 'violation_type', id, updated_by || null, { name: updated.name, category: updated.category });
  res.json({ ...updated, fields: JSON.parse(updated.fields) });
});

app.delete('/api/violation-types/:id', (req, res) => {
  const id  = parseInt(req.params.id);
  const row = db.prepare('SELECT * FROM violation_types WHERE id = ?').get(id);
  if (!row) return res.status(404).json({ error: 'Violation type not found' });

  const usage = db.prepare('SELECT COUNT(*) as count FROM violations WHERE violation_type = ?').get(row.type_key);
  if (usage.count > 0) {
    return res.status(409).json({ error: `Cannot delete: ${usage.count} violation(s) reference this type. Negate those violations first.` });
  }

  db.prepare('DELETE FROM violation_types WHERE id = ?').run(id);
  audit('violation_type_deleted', 'violation_type', id, null, { name: row.name, type_key: row.type_key });
  res.json({ ok: true });
});

// ── PDF endpoint ─────────────────────────────────────────────────────────────
app.get('/api/violations/:id/pdf', async (req, res) => {
  try {
    const violation = db.prepare(`
      SELECT v.*, e.name as employee_name, e.department, e.supervisor
      FROM violations v
      JOIN employees e ON e.id = v.employee_id
      WHERE v.id = ?
    `).get(req.params.id);

    if (!violation) return res.status(404).json({ error: 'Violation not found' });

    const standing = standingFor(violation.employee_id);

    const scoreForPdf = {
      employee_id:     violation.employee_id,
      active_points:   violation.prior_active_points != null ? violation.prior_active_points : standing.activePoints,
      violation_count: standing.violationCount,
    };

    const pdfBuffer = await generatePdf(violation, scoreForPdf);
    const safeName  = violation.employee_name.replace(/[^a-z0-9]/gi, '_');

    res.set({
      'Content-Type':        'application/pdf',
      'Content-Disposition': `attachment; filename="CPAS_${safeName}_${violation.incident_date}.pdf"`,
      'Content-Length':       pdfBuffer.length,
    });
    res.end(pdfBuffer);
  } catch (err) {
    console.error('[PDF]', err);
    res.status(500).json({ error: 'PDF generation failed', detail: err.message });
  }
});

// SPA fallback
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'client', 'dist', 'index.html')));

app.listen(PORT, '0.0.0.0', () => console.log(`[CPAS] Server running on port ${PORT}`));
