core_backup_session.js

'use strict';

/**
 * @fileoverview Backup session lifecycle management.
 * @module backup/session
 */

const path = require('path');
const crypto = require('crypto');
const {
  SessionStatus,
  VALID_TRANSITIONS,
  getSessionDir,
  getBackupsDir,
  getLatestFile,
} = require('./constants');
const { ensureDirSync, copyFilePreservingStructureSync, listDirsSync } = require('./file-ops');
const {
  createManifest,
  addFileToManifest,
  updateManifestStatus,
  writeManifestSync,
  readManifestSync,
} = require('./manifest');
const fs = require('../fs-adapter');

/**
 * Generates unique session ID: YYYY-MM-DD_HH-MM-SS_command_rand
 * @param {string} command
 * @returns {string}
 */
function generateSessionId(command) {
  const now = new Date();
  const date = now.toISOString().slice(0, 10);
  const time = now.toTimeString().slice(0, 8).replace(/:/g, '-');
  const cmd = command.replace(/[^a-zA-Z0-9]/g, '-').slice(0, 20);
  const rand = crypto.randomBytes(2).toString('hex');
  return `${date}_${time}_${cmd}_${rand}`;
}

/**
 * @param {string} sessionId
 * @returns {{id: string, timestamp: Date, date: string, time: string}|null}
 */
function parseSessionId(sessionId) {
  const parts = sessionId.split('_');
  if (parts.length < 4) {
    return null;
  }
  const [date, time] = parts;
  const timestamp = new Date(`${date}T${time.replace(/-/g, ':')}`);
  return { id: sessionId, timestamp, date, time };
}

function getManifestValue(manifest, key, defaultValue) {
  return manifest?.[key] || defaultValue;
}

function extractSessionInfo(manifest, dir) {
  return {
    id: dir,
    status: getManifestValue(manifest, 'status', 'unknown'),
    fileCount: manifest?.files?.length || 0,
    command: getManifestValue(manifest, 'command', 'unknown'),
  };
}

function parseSessionForList(cwd, dir) {
  const parsed = parseSessionId(dir);
  if (!parsed) {
    return null;
  }
  const manifest = readManifestSync(cwd, dir);
  return {
    ...extractSessionInfo(manifest, dir),
    timestamp: parsed.timestamp,
  };
}

/**
 * Backup session with state machine lifecycle
 */
class Session {
  /**
   * @param {string} cwd
   * @param {string} command
   * @param {string} [sessionId]
   */
  constructor(cwd, command, sessionId = null) {
    this.cwd = cwd;
    this.command = command;
    this.id = sessionId || generateSessionId(command);
    this.sessionDir = getSessionDir(cwd, this.id);
    this.manifest = createManifest(this.id, command, cwd);
    this.files = [];
  }

  get status() {
    return this.manifest.status;
  }

  validateTransition(newStatus) {
    const allowed = VALID_TRANSITIONS[this.status] || [];
    if (!allowed.includes(newStatus)) {
      throw new Error(`Invalid transition: ${this.status} -> ${newStatus}`);
    }
  }

  setStatus(status, stats = null, error = null) {
    this.validateTransition(status);
    updateManifestStatus(this.manifest, { status, stats, error });
    this.save();
  }

  save() {
    ensureDirSync(this.sessionDir);
    writeManifestSync(this.cwd, this.id, this.manifest);
  }

  start() {
    this.setStatus(SessionStatus.BACKING_UP);
    return this;
  }

  backupFile(filePath) {
    if (this.status !== SessionStatus.BACKING_UP) {
      throw new Error(`Cannot backup file in status: ${this.status}`);
    }
    const fileInfo = copyFilePreservingStructureSync(filePath, this.sessionDir, this.cwd);
    addFileToManifest(this.manifest, fileInfo);
    this.files.push(fileInfo);
    this.save();
    return fileInfo;
  }

  markReady() {
    this.setStatus(SessionStatus.READY);
    return this;
  }

  beginModifications() {
    this.setStatus(SessionStatus.IN_PROGRESS);
    return this;
  }

  saveReport(reportPath) {
    if (!fs.existsSync(reportPath)) {
      return null;
    }
    const reportDest = path.join(this.sessionDir, 'report.json');
    fs.copyFileSync(reportPath, reportDest);
    this.manifest.reportFile = 'report.json';
    this.save();
    return reportDest;
  }

  complete(stats = {}) {
    this.setStatus(SessionStatus.COMPLETED, stats);
    this.updateLatest();
    return this;
  }

  rollback() {
    this.setStatus(SessionStatus.ROLLED_BACK);
    return this;
  }

  fail(error) {
    updateManifestStatus(this.manifest, { status: SessionStatus.FAILED, error });
    this.save();
    return this;
  }

  updateLatest() {
    const latestFile = getLatestFile(this.cwd);
    try {
      fs.writeFileSync(latestFile, this.id, 'utf-8');
    } catch {
      // Non-critical: latest file update failure is acceptable
    }
  }

  getBackedUpFiles() {
    return this.manifest.files.map(f => ({
      original: path.join(this.cwd, f.original),
      backup: path.join(this.sessionDir, f.backup),
      size: f.size,
    }));
  }

  /**
   * @param {string} cwd
   * @param {string} sessionId
   * @returns {Session|null}
   */
  static load(cwd, sessionId) {
    const manifest = readManifestSync(cwd, sessionId);
    if (!manifest) {
      return null;
    }
    const session = new Session(cwd, manifest.command, sessionId);
    session.manifest = manifest;
    session.files = manifest.files || [];
    return session;
  }

  /**
   * @param {string} cwd
   * @returns {string|null}
   */
  static getLatestId(cwd) {
    const latestFile = getLatestFile(cwd);
    try {
      return fs.readFileSync(latestFile, 'utf-8').trim();
    } catch {
      return null;
    }
  }

  /**
   * @param {string} cwd
   * @returns {Session|null}
   */
  static loadLatest(cwd) {
    const latestId = Session.getLatestId(cwd);
    return latestId ? Session.load(cwd, latestId) : null;
  }

  /**
   * @param {string} cwd
   * @returns {Object[]}
   */
  static listAll(cwd) {
    const backupsDir = getBackupsDir(cwd);
    const dirs = listDirsSync(backupsDir);
    return dirs
      .map(dir => parseSessionForList(cwd, dir))
      .filter(Boolean)
      .sort((a, b) => b.timestamp - a.timestamp);
  }

  /**
   * @param {string} cwd
   * @returns {Object[]}
   */
  static findIncomplete(cwd) {
    const sessions = Session.listAll(cwd);
    return sessions.filter(
      s => s.status === SessionStatus.BACKING_UP || s.status === SessionStatus.IN_PROGRESS,
    );
  }
}

module.exports = {
  Session,
  generateSessionId,
  parseSessionId,
};