#!/usr/bin/env node
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const crypto = require('crypto');
const { spawnSync } = require('child_process');
const tar = require('tar');
const minimatch = require('minimatch');

const HOME = process.env.HOME || process.env.USERPROFILE || '.';
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(HOME, '.openclaw');
const OPENCLAW_BACKUP_DIR = process.env.OPENCLAW_BACKUP_DIR || path.join(HOME, '.openclaw-backup');
const BACKUP_REPO_URL = process.env.BACKUP_REPO_URL || '';
const BACKUP_CHANNEL_ID = process.env.BACKUP_CHANNEL_ID || '';
const BACKUP_TZ = process.env.BACKUP_TZ || 'America/Sao_Paulo';
const BACKUP_MAX_HISTORY = Number(process.env.BACKUP_MAX_HISTORY || 7);
const BACKUP_SPLIT_SIZE_MB = Number(process.env.BACKUP_SPLIT_SIZE_MB || 90);

const EXCLUDES = [
  '.openclaw-backup/**',
  'workspace/**',
  'media/inbound/**',
  'agents/main/sessions/*.jsonl.lock',
  'agents/main/sessions/*.jsonl.deleted.*'
];

const formatDateTime = (tz) => {
  const parts = new Intl.DateTimeFormat('sv-SE', {
    timeZone: tz,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false
  }).formatToParts(new Date());
  const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
  return `${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`;
};

const formatShort = (tz) => {
  const parts = new Intl.DateTimeFormat('sv-SE', {
    timeZone: tz,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    hour12: false
  }).formatToParts(new Date());
  const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
  return `${map.year}${map.month}${map.day}-${map.hour}${map.minute}`;
};

const toPosix = (p) => p.split(path.sep).join('/');
const isExcluded = (rel) => {
  const posix = toPosix(rel);
  return EXCLUDES.some((pattern) => minimatch(posix, pattern, { dot: true }));
};

const notify = (title, body) => {
  const payload = `**${title}**\n${body}`;
  console.log(payload);
  if (!BACKUP_CHANNEL_ID) return;
  const res = spawnSync('openclaw', ['message', 'send', '--channel', 'discord', '--target', BACKUP_CHANNEL_ID, '--message', payload], {
    stdio: 'ignore'
  });
  if (res.error) {
    console.error('notify error:', res.error.message);
  }
};

const onError = (nowTs) => {
  notify('❌ Backup failed', `Time: ${nowTs} (${BACKUP_TZ})\nCheck server logs for details.`);
};

const ensureDir = async (dir) => {
  await fsp.mkdir(dir, { recursive: true });
};

const moveHistoryOut = async (dest) => {
  const history = path.join(dest, 'history');
  try {
    await fsp.access(history);
  } catch {
    return null;
  }
  const tmp = path.join(dest, `._history_${Date.now()}`);
  await fsp.rename(history, tmp);
  return tmp;
};

const restoreHistory = async (dest, tmp) => {
  if (!tmp) return;
  const history = path.join(dest, 'history');
  await fsp.rename(tmp, history);
};

const syncDir = async (src, dest) => {
  const historyTmp = await moveHistoryOut(dest).catch(() => null);
  await fsp.rm(dest, { recursive: true, force: true });
  await ensureDir(dest);
  if (historyTmp) await restoreHistory(dest, historyTmp);

  const walk = async (current) => {
    const entries = await fsp.readdir(current, { withFileTypes: true });
    for (const entry of entries) {
      const full = path.join(current, entry.name);
      const rel = path.relative(src, full);
      if (isExcluded(rel)) continue;
      const target = path.join(dest, rel);
      if (entry.isDirectory()) {
        await ensureDir(target);
        await walk(full);
      } else if (entry.isFile()) {
        await ensureDir(path.dirname(target));
        await fsp.copyFile(full, target);
      }
    }
  };

  await walk(src);
};

const listWorkspaceEntries = async (dir) => {
  const entries = [];
  const walk = async (current) => {
    const items = await fsp.readdir(current, { withFileTypes: true });
    for (const item of items) {
      const full = path.join(current, item.name);
      const rel = path.relative(dir, full);
      if (item.isDirectory()) {
        await walk(full);
      } else if (item.isFile()) {
        const stat = await fsp.stat(full);
        entries.push(`${toPosix(rel)}|${stat.size}|${stat.mtimeMs}`);
      }
    }
  };
  await walk(dir);
  return entries;
};

const hashWorkspace = async (workspaceDir) => {
  const entries = await listWorkspaceEntries(workspaceDir);
  entries.sort();
  const hash = crypto.createHash('sha256');
  for (const line of entries) hash.update(line + '\n');
  return hash.digest('hex');
};

const splitFile = async (filePath, outPrefix, splitSizeBytes) => {
  const hash = crypto.createHash('sha256');
  const read = fs.createReadStream(filePath);
  let partIndex = 0;
  let currentSize = 0;
  let out = fs.createWriteStream(`${outPrefix}${String(partIndex).padStart(3, '0')}`);

  await new Promise((resolve, reject) => {
    read.on('data', (chunk) => {
      hash.update(chunk);
      let offset = 0;
      while (offset < chunk.length) {
        const remaining = splitSizeBytes - currentSize;
        const slice = chunk.subarray(offset, offset + remaining);
        out.write(slice);
        currentSize += slice.length;
        offset += slice.length;
        if (currentSize >= splitSizeBytes) {
          out.end();
          partIndex += 1;
          currentSize = 0;
          out = fs.createWriteStream(`${outPrefix}${String(partIndex).padStart(3, '0')}`);
        }
      }
    });
    read.on('error', reject);
    read.on('end', () => {
      out.end();
      resolve();
    });
  });

  return hash.digest('hex');
};

const git = (args, cwd) => {
  const res = spawnSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8' });
  if (res.error) throw res.error;
  return res.stdout.trim();
};

const main = async () => {
  const nowTs = formatDateTime(BACKUP_TZ);
  const shortTs = formatShort(BACKUP_TZ);
  const isoTs = new Date().toISOString();

  try {
    await ensureDir(OPENCLAW_BACKUP_DIR);

    await syncDir(OPENCLAW_HOME, OPENCLAW_BACKUP_DIR);

    const workspaceDir = path.join(OPENCLAW_HOME, 'workspace');
    const workspaceHashFile = path.join(OPENCLAW_BACKUP_DIR, '.workspace.hash');
    const workspaceShaFile = path.join(OPENCLAW_BACKUP_DIR, '.workspace.tar.sha256');

    let prevHash = '';
    try {
      prevHash = (await fsp.readFile(workspaceHashFile, 'utf8')).trim();
    } catch {}

    let workspaceHash = '';
    let workspaceChanged = 0;
    let tarSha256 = '';

    try {
      workspaceHash = await hashWorkspace(workspaceDir);
    } catch {}

    if (workspaceHash && workspaceHash !== prevHash) {
      workspaceChanged = 1;
    }

    if (workspaceChanged) {
      const tarPath = path.join(OPENCLAW_BACKUP_DIR, 'workspace.tar.gz');
      const partPrefix = path.join(OPENCLAW_BACKUP_DIR, 'workspace.tar.gz.part.');

      const entries = await fsp.readdir(OPENCLAW_BACKUP_DIR);
      for (const entry of entries) {
        if (entry.startsWith('workspace.tar.gz.part.')) {
          await fsp.rm(path.join(OPENCLAW_BACKUP_DIR, entry), { force: true });
        }
      }
      await fsp.rm(tarPath, { force: true });

      await tar.c({ gzip: true, cwd: OPENCLAW_HOME, file: tarPath }, ['workspace']);

      const splitSize = BACKUP_SPLIT_SIZE_MB * 1024 * 1024;
      tarSha256 = await splitFile(tarPath, partPrefix, splitSize);
      await fsp.rm(tarPath, { force: true });

      await fsp.writeFile(workspaceHashFile, workspaceHash);
      await fsp.writeFile(workspaceShaFile, tarSha256);
    }

    git(['init', '-q'], OPENCLAW_BACKUP_DIR);
    git(['branch', '-M', 'main'], OPENCLAW_BACKUP_DIR);

    git(['add', '-A'], OPENCLAW_BACKUP_DIR);

    const status = git(['status', '--porcelain'], OPENCLAW_BACKUP_DIR).split('\n').filter(Boolean);
    const added = status.filter((line) => line.startsWith('A ')).map((line) => line.slice(3));
    const modified = status.filter((line) => line.startsWith('M ')).map((line) => line.slice(3));
    const deleted = status.filter((line) => line.startsWith('D ')).map((line) => line.slice(3));

    const report = {
      timestamp: isoTs,
      timezone: BACKUP_TZ,
      counts: {
        added: added.length,
        modified: modified.length,
        deleted: deleted.length
      },
      workspace: {
        changed: workspaceChanged,
        hash: workspaceHash,
        tarSha256
      }
    };

    await fsp.writeFile(path.join(OPENCLAW_BACKUP_DIR, 'backup-report.json'), JSON.stringify(report, null, 2));

    const comment = 'Backup OK. Full snapshot (config + memory + workspace) to minimize recovery time. Excluded .jsonl.lock and .jsonl.deleted.* session files to reduce noise.';
    const phrase = 'You are building something big — and I am here to keep the line steady with you.';

    git(['add', '-A'], OPENCLAW_BACKUP_DIR);
    try {
      git(['commit', '-m', `backup: ${nowTs}`], OPENCLAW_BACKUP_DIR);
    } catch {}

    const commitSha = git(['rev-parse', '--short', 'HEAD'], OPENCLAW_BACKUP_DIR) || 'unknown';

    try {
      git(['remote', 'remove', 'origin'], OPENCLAW_BACKUP_DIR);
    } catch {}

    if (BACKUP_REPO_URL) {
      git(['remote', 'add', 'origin', BACKUP_REPO_URL], OPENCLAW_BACKUP_DIR);
      git(['push', '-u', 'origin', 'main', '--force'], OPENCLAW_BACKUP_DIR);
    }

    const historyDir = path.join(OPENCLAW_BACKUP_DIR, 'history', shortTs);
    await ensureDir(historyDir);
    await fsp.copyFile(path.join(OPENCLAW_BACKUP_DIR, 'backup-report.json'), path.join(historyDir, 'backup-report.json'));
    if (workspaceHash) await fsp.copyFile(path.join(OPENCLAW_BACKUP_DIR, '.workspace.hash'), path.join(historyDir, '.workspace.hash'));
    if (tarSha256) await fsp.copyFile(path.join(OPENCLAW_BACKUP_DIR, '.workspace.tar.sha256'), path.join(historyDir, '.workspace.tar.sha256'));

    const historyRoot = path.join(OPENCLAW_BACKUP_DIR, 'history');
    let historyEntries = [];
    try {
      historyEntries = (await fsp.readdir(historyRoot)).map((name) => path.join(historyRoot, name));
    } catch {}
    const stats = await Promise.all(historyEntries.map(async (entry) => ({
      entry,
      stat: await fsp.stat(entry)
    })));
    stats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
    const excess = stats.slice(BACKUP_MAX_HISTORY);
    for (const item of excess) {
      await fsp.rm(item.entry, { recursive: true, force: true });
    }

    let summary = `**Summary**\n• Time: ${nowTs} (${BACKUP_TZ})\n• Commit: ${commitSha}\n• Changes: +${added.length} ~${modified.length} -${deleted.length}\n• Workspace changed: ${workspaceChanged}`;
    if (tarSha256) summary += `\n• Workspace tar SHA256: ${tarSha256}`;

    const formatList = (label, items) => {
      if (!items.length) return '';
      const top = items.slice(0, 12);
      return `\n\n**${label} (top 12)**\n${top.map((item) => `• ${item}`).join('\n')}`;
    };

    const changes = [
      formatList('Added', added),
      formatList('Modified', modified),
      formatList('Deleted', deleted)
    ].join('');

    const restoreNote = `\n\n**How to restore this backup**\n1) openclaw gateway stop\n2) Rename current OPENCLAW_HOME (e.g., mv ${OPENCLAW_HOME} ${OPENCLAW_HOME}-restore-${shortTs})\n3) git clone ${BACKUP_REPO_URL || '<repo>'} backup\n4) cd backup && git checkout ${commitSha}\n5) Create OPENCLAW_HOME and copy files back\n6) Merge workspace parts (cat workspace.tar.gz.part.* > workspace.tar.gz) and extract with tar\n7) openclaw gateway start\n\nNote: On Windows, use PowerShell (Move-Item/Copy-Item/tar.exe) for the steps above.`;

    notify('✅ Backup completed', `${summary}${changes}\n\n**Comment**\n${comment}\n\n**Phrase**\n${phrase}${restoreNote}`);
  } catch (err) {
    onError(formatDateTime(BACKUP_TZ));
    console.error(err);
    process.exit(1);
  }
};

main();
