import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { createReadStream } from 'fs';
import { Prisma, RestoreStatus, AuditAction, InstanceStatus } from '@prisma/client';
import { prisma } from '../lib/prisma';
import { env } from '../config/env';
import { AppError } from '../middleware/error-handler';
import { logger } from '../utils/logger';
import { getRemoteDriverForInstance } from './execution-driver';
import type { AgentRestoreOptions, AgentRestoreState } from './remote-driver';

/**
 * Validate that a path is within the allowed backup storage boundary.
 */
function assertPathWithinBoundary(filePath: string, boundary: string, label: string): void {
  const normalized = path.resolve(filePath);
  const normalizedBoundary = path.resolve(boundary);
  if (!normalized.startsWith(normalizedBoundary + path.sep)) {
    throw new AppError(403, `${label} path outside allowed directory`, 'FORBIDDEN');
  }
}

/**
 * Compute SHA-256 hash of a file by streaming its contents.
 */
async function fileHash(filePath: string): Promise<string> {
  return new Promise((resolve, reject) => {
    const hash = crypto.createHash('sha256');
    const stream = createReadStream(filePath);
    stream.on('data', (chunk) => hash.update(chunk));
    stream.on('end', () => resolve(hash.digest('hex')));
    stream.on('error', reject);
  });
}

const POLL_INTERVAL_MS = 3_000;
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 min

interface StartRestoreArgs {
  backupId: string;
  triggeredById?: string;
  ipAddress?: string | null;
  options?: AgentRestoreOptions;
}

/**
 * Kick off a restore for the given backup. Creates an InstanceRestore row
 * and runs the full upload → apply → poll loop asynchronously. Returns the
 * row so the caller (HTTP handler) can respond immediately.
 */
export async function createRestore(args: StartRestoreArgs) {
  const backup = await prisma.backup.findUnique({
    where: { id: args.backupId },
    include: { instance: true },
  });
  if (!backup) {
    throw new AppError(404, 'Backup not found', 'NOT_FOUND');
  }
  if (backup.status !== 'COMPLETED') {
    throw new AppError(400, `Backup is ${backup.status}, not COMPLETED`, 'INVALID_STATE');
  }
  if (!backup.archivePath) {
    throw new AppError(400, 'Backup has no archive path', 'NO_ARCHIVE');
  }

  const instance = backup.instance;
  if (instance.status !== InstanceStatus.RUNNING) {
    throw new AppError(400, `Cannot restore to instance in ${instance.status} state`, 'INVALID_STATE');
  }
  // Phase B only supports remote restore. Local restore is deliberately stubbed
  // — if you need it, add a performLocalRestore branch below. This also covers
  // the registered-but-local case (CCP-adopted instances) since they have
  // isRemote=false.
  if (!instance.isRemote) {
    throw new AppError(501, 'Local restore is not implemented — Phase B covers remote only', 'NOT_IMPLEMENTED');
  }

  // Make sure the archive is where it says it is and inside the boundary
  assertPathWithinBoundary(backup.archivePath, env.BACKUP_STORAGE_PATH, 'Backup archive');
  try {
    await fs.access(backup.archivePath);
  } catch {
    throw new AppError(404, 'Archive file is missing on disk', 'ARCHIVE_MISSING');
  }

  const restore = await prisma.instanceRestore.create({
    data: {
      instanceId: instance.id,
      backupId: backup.id,
      status: RestoreStatus.PENDING,
      triggeredById: args.triggeredById ?? null,
    },
  });

  // Fire-and-forget orchestration
  performRemoteRestore(restore.id, backup.archivePath, args.options ?? {}, args.triggeredById, args.ipAddress ?? null)
    .catch((err) => {
      logger.error(`[restore] ${restore.id} failed: ${(err as Error).message}`);
    });

  return restore;
}

/**
 * End-to-end remote restore orchestration.
 *
 * Flow:
 *   1. Compute sha256 of the archive on CCP disk
 *   2. Upload to agent with sha256 query param (agent re-verifies on stream)
 *   3. Apply via agent (shells out to restore.sh --force)
 *   4. Poll progress every 3s until COMPLETED/FAILED or timeout
 *   5. Delete the agent-side upload
 *   6. Update the InstanceRestore row + audit log
 */
/**
 * Write a BACKUP_RESTORE audit log entry. Wrapped in a try/catch so that an
 * audit-log DB failure can never mask the underlying restore status update.
 *
 * Called in all three terminal paths:
 *   - success (outcome: 'success')
 *   - agent reported failure (outcome: 'agent_failed')
 *   - orchestration error / timeout / unexpected throw (outcome: 'orchestration_error')
 */
async function writeRestoreAuditLog(args: {
  restoreId: string;
  instanceId: string;
  backupId: string;
  triggeredById?: string;
  ipAddress?: string | null;
  options: AgentRestoreOptions;
  outcome: 'success' | 'agent_failed' | 'orchestration_error';
  sha256?: string;
  uploadId?: string | null;
  errorMessage?: string;
}): Promise<void> {
  if (!args.triggeredById) return;
  try {
    await prisma.auditLog.create({
      data: {
        userId: args.triggeredById,
        instanceId: args.instanceId,
        action: AuditAction.BACKUP_RESTORE,
        details: {
          backupId: args.backupId,
          restoreId: args.restoreId,
          source: 'remote',
          outcome: args.outcome,
          options: args.options as unknown as Prisma.InputJsonValue,
          ...(args.sha256 ? { sha256: args.sha256 } : {}),
          ...(args.uploadId ? { agentUploadId: args.uploadId } : {}),
          ...(args.errorMessage ? { errorMessage: args.errorMessage.substring(0, 500) } : {}),
        },
        ipAddress: args.ipAddress ?? null,
      },
    });
  } catch (err) {
    logger.error(`[restore] failed to write audit log for ${args.restoreId}: ${(err as Error).message}`);
  }
}

async function performRemoteRestore(
  restoreId: string,
  archivePath: string,
  options: AgentRestoreOptions,
  triggeredById?: string,
  ipAddress?: string | null
) {
  const restore = await prisma.instanceRestore.findUnique({
    where: { id: restoreId },
    include: { instance: true, backup: true },
  });
  if (!restore) {
    logger.error(`[restore] row ${restoreId} vanished mid-flight`);
    return;
  }
  const instance = restore.instance;

  let uploadId: string | null = null;
  let sha256: string | undefined;
  try {
    await prisma.instanceRestore.update({
      where: { id: restoreId },
      data: { status: RestoreStatus.UPLOADING },
    });

    const driver = await getRemoteDriverForInstance({
      id: instance.id,
      slug: instance.slug,
      isRemote: instance.isRemote,
      agentUrl: instance.agentUrl,
    });

    // 1. Compute local SHA256 (authoritative — the agent will verify against this).
    // We persist this in the audit log so there's an immutable record of exactly
    // which bytes were restored, useful for post-incident comparison.
    logger.info(`[restore] ${instance.slug}: hashing archive ${path.basename(archivePath)}`);
    sha256 = await fileHash(archivePath);

    // 2. Stream upload to agent
    logger.info(`[restore] ${instance.slug}: uploading archive (sha256=${sha256.substring(0, 16)}...)`);
    const uploadResult = await driver.uploadRestore(archivePath, sha256);
    uploadId = uploadResult.uploadId;

    await prisma.instanceRestore.update({
      where: { id: restoreId },
      data: { uploadId, status: RestoreStatus.RUNNING },
    });

    // 3. Apply
    logger.info(`[restore] ${instance.slug}: applying restore ${uploadId}`);
    await driver.applyRestore(uploadId, options);

    // 4. Poll progress
    const deadline = Date.now() + POLL_TIMEOUT_MS;
    let finalState: AgentRestoreState | null = null;
    while (Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
      try {
        const state = await driver.getRestoreProgress(uploadId);
        // Mirror progress to the DB row so the UI shows updates
        await prisma.instanceRestore.update({
          where: { id: restoreId },
          data: {
            progressJson: state as unknown as Prisma.InputJsonValue,
            logTail: state.logTail ?? null,
          },
        });
        if (state.status === 'COMPLETED' || state.status === 'FAILED') {
          finalState = state;
          break;
        }
      } catch (err) {
        logger.warn(`[restore] ${instance.slug}: poll error: ${(err as Error).message}`);
        // Keep polling — transient network blips shouldn't fail the restore
      }
    }

    if (!finalState) {
      throw new Error(`Restore timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s`);
    }

    // 5. Clean up agent-side upload (best effort)
    try {
      await driver.deleteRestoreUpload(uploadId);
    } catch (err) {
      logger.warn(`[restore] ${instance.slug}: failed to delete agent upload ${uploadId}: ${(err as Error).message}`);
    }

    // 6. Finalize DB row
    if (finalState.status === 'COMPLETED') {
      await prisma.instanceRestore.update({
        where: { id: restoreId },
        data: {
          status: RestoreStatus.COMPLETED,
          progressJson: finalState as unknown as Prisma.InputJsonValue,
          logTail: finalState.logTail ?? null,
          completedAt: new Date(),
        },
      });

      await writeRestoreAuditLog({
        restoreId,
        instanceId: instance.id,
        backupId: restore.backupId,
        triggeredById,
        ipAddress,
        options,
        outcome: 'success',
        sha256,
        uploadId,
      });

      logger.info(`[restore] ${instance.slug}: restore ${restoreId} COMPLETED`);
    } else {
      const errMsg = finalState.errorMessage || `Agent reported FAILED (exit ${finalState.exitCode})`;
      await prisma.instanceRestore.update({
        where: { id: restoreId },
        data: {
          status: RestoreStatus.FAILED,
          progressJson: finalState as unknown as Prisma.InputJsonValue,
          logTail: finalState.logTail ?? null,
          errorMessage: errMsg,
          completedAt: new Date(),
        },
      });
      await writeRestoreAuditLog({
        restoreId,
        instanceId: instance.id,
        backupId: restore.backupId,
        triggeredById,
        ipAddress,
        options,
        outcome: 'agent_failed',
        sha256,
        uploadId,
        errorMessage: errMsg,
      });
      logger.warn(`[restore] ${instance.slug}: restore ${restoreId} FAILED (exit ${finalState.exitCode})`);
    }
  } catch (err) {
    const errMsg = (err as Error).message;
    await prisma.instanceRestore.update({
      where: { id: restoreId },
      data: {
        status: RestoreStatus.FAILED,
        errorMessage: errMsg,
        completedAt: new Date(),
      },
    });
    await writeRestoreAuditLog({
      restoreId,
      instanceId: instance.id,
      backupId: restore.backupId,
      triggeredById,
      ipAddress,
      options,
      outcome: 'orchestration_error',
      sha256,
      uploadId,
      errorMessage: errMsg,
    });
    logger.error(`[restore] ${restore.instance.slug}: ${errMsg}`);

    // Best-effort cleanup of the agent upload if we got that far
    if (uploadId) {
      try {
        const driver = await getRemoteDriverForInstance({
          id: instance.id,
          slug: instance.slug,
          isRemote: instance.isRemote,
          agentUrl: instance.agentUrl,
        });
        await driver.deleteRestoreUpload(uploadId);
      } catch { /* ignore */ }
    }
  }
}

/**
 * List restores with optional filtering and pagination.
 */
export async function listRestores(instanceId?: string, page = 1, limit = 50) {
  const where = instanceId ? { instanceId } : {};
  const [data, total] = await Promise.all([
    prisma.instanceRestore.findMany({
      where,
      orderBy: { startedAt: 'desc' },
      skip: (page - 1) * limit,
      take: limit,
      include: {
        instance: { select: { id: true, name: true, slug: true } },
        backup: { select: { id: true, archivePath: true, sizeBytes: true } },
      },
    }),
    prisma.instanceRestore.count({ where }),
  ]);
  return { data, total, page, limit };
}

/**
 * Get a single restore by ID.
 */
export async function getRestore(restoreId: string) {
  const restore = await prisma.instanceRestore.findUnique({
    where: { id: restoreId },
    include: {
      instance: { select: { id: true, name: true, slug: true } },
      backup: { select: { id: true, archivePath: true, sizeBytes: true, manifest: true } },
    },
  });
  if (!restore) {
    throw new AppError(404, 'Restore not found', 'NOT_FOUND');
  }
  return restore;
}
