import https from 'https';
import fs from 'fs';
import { pipeline } from 'stream/promises';
import { env } from '../config/env';
import type { ExecutionDriver } from './execution-driver';
import { AgentUnreachableError } from './execution-driver';
import type { ContainerInfo } from './docker.service';
import { logger } from '../utils/logger';

export interface AgentBackupResult {
  backupId: string;
  filename: string;
  sizeBytes: number;
  sha256: string;
  manifest: unknown | null;
  createdAt: string;
}

export interface AgentBackupListEntry {
  backupId: string;
  filename: string;
  sizeBytes: number;
  createdAt: string;
}

export interface AgentRestoreUploadResult {
  uploadId: string;
  sizeBytes: number;
  sha256: string;
}

export interface AgentRestoreOptions {
  skipDb?: boolean;
  skipUploads?: boolean;
  skipListmonk?: boolean;
  dryRun?: boolean;
}

export interface AgentRestoreState {
  status: 'UPLOADED' | 'RUNNING' | 'COMPLETED' | 'FAILED';
  uploadId: string;
  startedAt: string;
  completedAt?: string;
  exitCode?: number;
  logTail?: string;
  errorMessage?: string;
  options?: AgentRestoreOptions;
}

export interface AgentUpdateStatus {
  branch: string;
  currentCommit: string;
  currentMessage?: string;
  remoteCommit: string | null;
  commitsBehind: number;
  changelog: Array<{ hash: string; message: string; date: string; author: string }>;
  checkedAt: string;
  error: string | null;
}

export interface AgentUpgradeProgress {
  phase?: number;
  phaseName?: string;
  percentage?: number;
  message?: string;
  timestamp?: string;
}

export interface AgentUpgradeResult {
  success: boolean;
  message?: string;
  previousCommit?: string;
  newCommit?: string;
  commitCount?: number;
  durationSeconds?: number;
  warnings?: string[];
}

export interface StartAgentUpgradeOptions {
  skipBackup?: boolean;
  useRegistry?: boolean;
  branch?: string;
}

export interface StartAgentImageUpgradeOptions {
  imageTag?: string;
}

interface AgentRequestOptions {
  method: 'GET' | 'POST' | 'DELETE';
  path: string;
  body?: unknown;
  timeoutMs?: number;
}

/**
 * RemoteDriver makes HTTPS calls to a remote CCP agent for all operations.
 * Uses mTLS — both CCP (client) and agent (server) present certificates.
 */
export class RemoteDriver implements ExecutionDriver {
  constructor(
    private agentUrl: string,
    private slug: string,
    private clientCert: Buffer,
    private clientKey: Buffer,
    private caCert: Buffer
  ) {}

  // ─── HTTP Client ────────────────────────────────────────────

  private async request<T = unknown>(opts: AgentRequestOptions): Promise<T> {
    const url = new URL(opts.path, this.agentUrl);
    const timeoutMs = opts.timeoutMs || env.AGENT_REQUEST_TIMEOUT_MS;

    const payload = opts.body ? JSON.stringify(opts.body) : undefined;

    return new Promise<T>((resolve, reject) => {
      const req = https.request(
        {
          hostname: url.hostname,
          port: url.port || 7443,
          path: url.pathname + url.search,
          method: opts.method,
          headers: {
            'Content-Type': 'application/json',
            ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
          },
          cert: this.clientCert,
          key: this.clientKey,
          ca: this.caCert,
          rejectUnauthorized: true,
          timeout: timeoutMs,
        },
        (res) => {
          let data = '';
          res.on('data', (chunk) => (data += chunk));
          res.on('end', () => {
            if (res.statusCode && res.statusCode >= 400) {
              try {
                const err = JSON.parse(data);
                reject(new Error(err.message || `Agent returned ${res.statusCode}`));
              } catch {
                reject(new Error(`Agent returned ${res.statusCode}: ${data.substring(0, 500)}`));
              }
              return;
            }

            try {
              resolve(data ? JSON.parse(data) as T : (undefined as T));
            } catch {
              resolve(data as unknown as T);
            }
          });
        }
      );

      req.on('error', (err) => {
        reject(new AgentUnreachableError(this.agentUrl, err));
      });

      req.on('timeout', () => {
        req.destroy();
        reject(new AgentUnreachableError(this.agentUrl, new Error(`Timed out after ${timeoutMs}ms`)));
      });

      if (payload) req.write(payload);
      req.end();
    });
  }

  // ─── Docker Compose Operations ──────────────────────────────

  async composeUp(_projectDir: string, _project: string, services?: string[]): Promise<string> {
    return this.request<string>({
      method: 'POST',
      path: `/instance/${this.slug}/up`,
      body: { services },
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  async composeDown(_projectDir: string, _project: string, removeVolumes?: boolean): Promise<string> {
    return this.request<string>({
      method: 'POST',
      path: `/instance/${this.slug}/down`,
      body: { removeVolumes },
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  async composeStop(_projectDir: string, _project: string): Promise<string> {
    return this.request<string>({
      method: 'POST',
      path: `/instance/${this.slug}/stop`,
    });
  }

  async composeRestart(_projectDir: string, _project: string, service?: string): Promise<string> {
    return this.request<string>({
      method: 'POST',
      path: `/instance/${this.slug}/restart`,
      body: { service },
    });
  }

  async composePull(_projectDir: string, _project: string): Promise<string> {
    return this.request<string>({
      method: 'POST',
      path: `/instance/${this.slug}/pull`,
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  async composeBuild(_projectDir: string, _project: string): Promise<string> {
    return this.request<string>({
      method: 'POST',
      path: `/instance/${this.slug}/build`,
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  async composePs(_projectDir: string, _project: string): Promise<ContainerInfo[]> {
    return this.request<ContainerInfo[]>({
      method: 'GET',
      path: `/instance/${this.slug}/ps`,
    });
  }

  async composeLogs(_projectDir: string, _project: string, service?: string, tail?: number, since?: string): Promise<string> {
    const params = new URLSearchParams();
    if (service) params.set('service', service);
    if (tail) params.set('tail', String(tail));
    if (since) params.set('since', since);
    const qs = params.toString() ? `?${params}` : '';

    return this.request<string>({
      method: 'GET',
      path: `/instance/${this.slug}/logs${qs}`,
    });
  }

  async composeExec(_projectDir: string, _project: string, service: string, command: string, timeoutMs?: number, envVars?: Record<string, string>): Promise<string> {
    return this.request<string>({
      method: 'POST',
      path: `/instance/${this.slug}/exec`,
      body: { service, command, envVars },
      timeoutMs: timeoutMs || env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  // ─── Container Health ───────────────────────────────────────

  async waitForHealthy(containerName: string, timeoutMs = 60_000, pollIntervalMs = 2_000): Promise<boolean> {
    // For remote instances, poll the agent's ps endpoint
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      try {
        const containers = await this.composePs('', '');
        const container = containers.find((c) => c.name.includes(containerName) || c.service === containerName);
        if (container?.health === 'healthy') return true;
        if (container?.state === 'exited' || container?.state === 'dead') {
          throw new Error(`Container ${containerName} exited unexpectedly`);
        }
      } catch (err) {
        if (err instanceof AgentUnreachableError) throw err;
        // Other errors — keep polling
      }
      await new Promise((r) => setTimeout(r, pollIntervalMs));
    }
    throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`);
  }

  async waitForHttp(url: string, timeoutMs = 120_000, pollIntervalMs = 3_000): Promise<boolean> {
    // The URL is a local URL on the remote host. We ask the agent to check it.
    // For now, poll the agent's health endpoint for the instance.
    const deadline = Date.now() + timeoutMs;
    while (Date.now() < deadline) {
      try {
        const containers = await this.composePs('', '');
        const apiContainer = containers.find((c) => c.service === 'api');
        if (apiContainer?.state === 'running' && apiContainer?.health === 'healthy') return true;
      } catch (err) {
        if (err instanceof AgentUnreachableError) throw err;
      }
      await new Promise((r) => setTimeout(r, pollIntervalMs));
    }
    throw new Error(`HTTP endpoint did not respond within ${timeoutMs}ms`);
  }

  // ─── Filesystem Operations ──────────────────────────────────

  async readEnvFile(_basePath: string): Promise<Record<string, string> | null> {
    try {
      return await this.request<Record<string, string>>({
        method: 'GET',
        path: `/instance/${this.slug}/env`,
      });
    } catch {
      return null;
    }
  }

  async writeFiles(_basePath: string, files: Array<{ relativePath: string; content: string }>): Promise<void> {
    await this.request({
      method: 'POST',
      path: `/instance/${this.slug}/files`,
      body: { files },
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  // Approach C pre-flight diff via agent.
  async diffFiles(_basePath: string, files: Array<{ relativePath: string; content: string }>) {
    const resp = await this.request<{ files: Array<{ path: string; status: 'unchanged' | 'modified' | 'created'; diff: string | null; sizeBefore: number; sizeAfter: number }> }>({
      method: 'POST',
      path: `/instance/${this.slug}/files/diff`,
      body: { files },
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
    return resp.files;
  }

  // Approach C: patch specific .env keys in place via agent. Used for
  // isRegistered=true tenants where CCP can't re-render the full .env.
  async patchEnv(_basePath: string, vars: Record<string, string>) {
    return this.request<{ patched: string[]; added: string[] }>({
      method: 'POST',
      path: `/instance/${this.slug}/env/patch`,
      body: { vars },
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  async mkdir(_basePath: string, relativePath: string): Promise<void> {
    await this.request({
      method: 'POST',
      path: `/instance/${this.slug}/mkdir`,
      body: { path: relativePath },
    });
  }

  async fileExists(_basePath: string, relativePath: string): Promise<boolean> {
    try {
      await this.request({
        method: 'GET',
        path: `/instance/${this.slug}/env`, // reuse env endpoint as a proxy for file existence
      });
      return true;
    } catch {
      return false;
    }
  }

  async deleteDirectory(_dirPath: string): Promise<void> {
    // Remote directory deletion is handled by the agent during instance unregistration
    logger.warn('[remote-driver] deleteDirectory called — remote cleanup handled by agent');
  }

  async cloneSource(_basePath: string, gitRepo: string, gitBranch: string, excludes?: string[]): Promise<void> {
    await this.request({
      method: 'POST',
      path: `/instance/${this.slug}/clone-source`,
      body: { gitRepo, gitBranch, excludes },
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  // ─── Backup Operations ──────────────────────────────────────

  /**
   * Trigger a backup on the remote agent. The agent shells out to scripts/backup.sh
   * and returns metadata for the resulting archive. The archive stays on the
   * agent's disk until downloadBackup() + deleteBackup() are called.
   */
  async createBackup(): Promise<AgentBackupResult> {
    return this.request<AgentBackupResult>({
      method: 'POST',
      path: `/instance/${this.slug}/backup`,
      timeoutMs: env.AGENT_LONG_OP_TIMEOUT_MS,
    });
  }

  /**
   * List backup archives currently held on the agent for this slug.
   */
  async listAgentBackups(): Promise<AgentBackupListEntry[]> {
    const resp = await this.request<{ data: AgentBackupListEntry[] }>({
      method: 'GET',
      path: `/instance/${this.slug}/backups`,
    });
    return resp.data;
  }

  /**
   * Delete an archive from the agent's disk. Called after a successful download.
   */
  async deleteBackup(backupId: string): Promise<void> {
    await this.request({
      method: 'DELETE',
      path: `/instance/${this.slug}/backup/${encodeURIComponent(backupId)}`,
    });
  }

  /**
   * Stream a backup archive from the agent to a local file path.
   * Verifies the Content-Length header matches the bytes written.
   */
  async downloadBackup(backupId: string, destPath: string): Promise<{ bytesWritten: number }> {
    const url = new URL(
      `/instance/${this.slug}/backup/${encodeURIComponent(backupId)}/download`,
      this.agentUrl
    );
    const timeoutMs = env.AGENT_LONG_OP_TIMEOUT_MS;

    return new Promise((resolve, reject) => {
      const req = https.request(
        {
          hostname: url.hostname,
          port: url.port || 7443,
          path: url.pathname + url.search,
          method: 'GET',
          headers: { Accept: 'application/gzip' },
          cert: this.clientCert,
          key: this.clientKey,
          ca: this.caCert,
          rejectUnauthorized: true,
          timeout: timeoutMs,
        },
        async (res) => {
          if (res.statusCode && res.statusCode >= 400) {
            let body = '';
            res.on('data', (c) => (body += c));
            res.on('end', () => reject(new Error(`Agent returned ${res.statusCode}: ${body.substring(0, 500)}`)));
            return;
          }

          const expectedSize = res.headers['content-length']
            ? parseInt(res.headers['content-length'] as string, 10)
            : null;

          try {
            const out = fs.createWriteStream(destPath);
            await pipeline(res, out);
            const stats = await fs.promises.stat(destPath);
            if (expectedSize !== null && stats.size !== expectedSize) {
              reject(new Error(`Downloaded size ${stats.size} does not match Content-Length ${expectedSize}`));
              return;
            }
            resolve({ bytesWritten: stats.size });
          } catch (err) {
            reject(err);
          }
        }
      );

      req.on('error', (err) => {
        reject(new AgentUnreachableError(this.agentUrl, err));
      });
      req.on('timeout', () => {
        req.destroy();
        reject(new AgentUnreachableError(this.agentUrl, new Error(`Timed out after ${timeoutMs}ms`)));
      });

      req.end();
    });
  }

  // ─── Restore Operations ─────────────────────────────────────

  /**
   * Stream a backup archive from a local path to the agent's upload endpoint.
   * The expected SHA256 is passed as a query parameter and the agent verifies
   * it during ingestion — if it mismatches, the upload is rejected with 400.
   */
  async uploadRestore(
    archivePath: string,
    expectedSha256: string
  ): Promise<AgentRestoreUploadResult> {
    const stats = await fs.promises.stat(archivePath);
    const url = new URL(
      `/instance/${this.slug}/restore/upload?sha256=${encodeURIComponent(expectedSha256)}`,
      this.agentUrl
    );
    const timeoutMs = env.AGENT_LONG_OP_TIMEOUT_MS;

    return new Promise((resolve, reject) => {
      const req = https.request(
        {
          hostname: url.hostname,
          port: url.port || 7443,
          path: url.pathname + url.search,
          method: 'POST',
          headers: {
            'Content-Type': 'application/octet-stream',
            'Content-Length': String(stats.size),
          },
          cert: this.clientCert,
          key: this.clientKey,
          ca: this.caCert,
          rejectUnauthorized: true,
          timeout: timeoutMs,
        },
        (res) => {
          let body = '';
          res.on('data', (c) => (body += c));
          res.on('end', () => {
            if (res.statusCode && res.statusCode >= 400) {
              try {
                const err = JSON.parse(body);
                reject(new Error(err.message || `Agent returned ${res.statusCode}`));
              } catch {
                reject(new Error(`Agent returned ${res.statusCode}: ${body.substring(0, 500)}`));
              }
              return;
            }
            try {
              resolve(JSON.parse(body) as AgentRestoreUploadResult);
            } catch (err) {
              reject(err);
            }
          });
        }
      );

      req.on('error', (err) => {
        reject(new AgentUnreachableError(this.agentUrl, err));
      });
      req.on('timeout', () => {
        req.destroy();
        reject(new AgentUnreachableError(this.agentUrl, new Error(`Timed out after ${timeoutMs}ms`)));
      });

      const fileStream = fs.createReadStream(archivePath);
      fileStream.on('error', (err) => {
        req.destroy();
        reject(err);
      });
      fileStream.pipe(req);
    });
  }

  /**
   * Tell the agent to apply a previously-uploaded restore archive. The agent
   * fires `scripts/restore.sh` in the background and returns immediately.
   * Use `getRestoreProgress()` to poll for completion.
   */
  async applyRestore(uploadId: string, options: AgentRestoreOptions = {}): Promise<void> {
    await this.request({
      method: 'POST',
      path: `/instance/${this.slug}/restore/${encodeURIComponent(uploadId)}/apply`,
      body: { confirm: true, ...options },
    });
  }

  /**
   * Poll the agent for the current state of a restore.
   */
  async getRestoreProgress(uploadId: string): Promise<AgentRestoreState> {
    return this.request<AgentRestoreState>({
      method: 'GET',
      path: `/instance/${this.slug}/restore/${encodeURIComponent(uploadId)}/progress`,
    });
  }

  /**
   * Delete a restore upload dir from the agent's disk. Called after the CCP
   * has finalized the InstanceRestore row.
   */
  async deleteRestoreUpload(uploadId: string): Promise<void> {
    await this.request({
      method: 'DELETE',
      path: `/instance/${this.slug}/restore/${encodeURIComponent(uploadId)}`,
    });
  }

  // ─── Upgrade Operations ─────────────────────────────────────

  /**
   * Run upgrade-check.sh on the remote and return the parsed status.json.
   */
  async checkForUpdates(): Promise<AgentUpdateStatus> {
    return this.request<AgentUpdateStatus>({
      method: 'POST',
      path: `/instance/${this.slug}/upgrade/check`,
      timeoutMs: 90_000,
    });
  }

  /**
   * Trigger upgrade.sh --api-mode on the remote. Fire-and-forget; agent
   * spawns the script in the background and returns 202 immediately.
   * Use getUpgradeProgress / getUpgradeResult to track completion.
   */
  async startUpgrade(options: StartAgentUpgradeOptions = {}): Promise<void> {
    await this.request({
      method: 'POST',
      path: `/instance/${this.slug}/upgrade/start`,
      body: options,
      timeoutMs: 30_000,
    });
  }

  /**
   * Trigger image-upgrade.sh --api-mode on the remote (Approach B: image-only
   * upgrade — pulls images + recreates core app services without touching
   * the install tree). Fire-and-forget; returns 202 immediately. Uses the
   * same progress/result polling endpoints as startUpgrade.
   */
  async startImageUpgrade(options: StartAgentImageUpgradeOptions = {}): Promise<void> {
    await this.request({
      method: 'POST',
      path: `/instance/${this.slug}/upgrade/start-image-only`,
      body: options,
      timeoutMs: 30_000,
    });
  }

  /**
   * Read the agent's data/upgrade/progress.json. Returns the default zero-state
   * if no progress has been written yet.
   */
  async getUpgradeProgress(): Promise<AgentUpgradeProgress> {
    return this.request<AgentUpgradeProgress>({
      method: 'GET',
      path: `/instance/${this.slug}/upgrade/progress`,
    });
  }

  /**
   * Read the agent's data/upgrade/result.json. Throws if no result is yet
   * available; the caller should treat that as "still running".
   */
  async getUpgradeResult(): Promise<AgentUpgradeResult> {
    return this.request<AgentUpgradeResult>({
      method: 'GET',
      path: `/instance/${this.slug}/upgrade/result`,
    });
  }
}
