import fs from 'fs/promises';
import path from 'path';
import { parse as parseDotenv } from 'dotenv';
import { AgentError } from '../middleware/error-handler';
import { logger } from '../utils/logger';

/**
 * Validate that a resolved path is within the allowed basePath.
 * Prevents path traversal attacks.
 */
function assertWithin(filePath: string, basePath: string): void {
  const resolvedFile = path.resolve(filePath);
  const resolvedBase = path.resolve(basePath);
  if (!resolvedFile.startsWith(resolvedBase + '/') && resolvedFile !== resolvedBase) {
    throw new AgentError(403, `Path ${filePath} is outside allowed directory`, 'PATH_TRAVERSAL');
  }
}

export async function readEnvFile(basePath: string): Promise<Record<string, string>> {
  const envPath = path.join(basePath, '.env');
  const content = await fs.readFile(envPath, 'utf-8');
  return parseDotenv(Buffer.from(content));
}

export async function writeFiles(
  basePath: string,
  files: Array<{ relativePath: string; content: string }>
): Promise<void> {
  for (const file of files) {
    const filePath = path.join(basePath, file.relativePath);
    assertWithin(filePath, basePath);
    await fs.mkdir(path.dirname(filePath), { recursive: true });
    await fs.writeFile(filePath, file.content, 'utf-8');
    logger.debug(`[files] Wrote ${filePath}`);
  }
}

/**
 * Diff proposed files against current on-disk contents at basePath.
 * For Approach C pre-flight preview: operator sees per-file change summary
 * before applying re-rendered templates. Returns one DiffResult per proposed
 * file. Uses a small inline LCS-based unified diff to avoid new deps.
 */
export interface DiffResult {
  path: string;
  status: 'unchanged' | 'modified' | 'created';
  diff: string | null;
  sizeBefore: number;
  sizeAfter: number;
}

const DIFF_MAX_LINES = 500;

function unifiedDiff(oldText: string, newText: string, relativePath: string): string {
  // Compact unified-diff: line-level LCS, emit context + changed lines.
  // Not a full GNU diff — adequate for compose/env/conf inspection in the UI.
  const oldLines = oldText.split('\n');
  const newLines = newText.split('\n');

  // Build LCS table (line-level). For files up to ~1500 lines this is O(N*M)
  // which is fine; we truncate output length not algorithm runtime.
  const m = oldLines.length, n = newLines.length;
  const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
  for (let i = m - 1; i >= 0; i--) {
    for (let j = n - 1; j >= 0; j--) {
      dp[i][j] = oldLines[i] === newLines[j]
        ? dp[i + 1][j + 1] + 1
        : Math.max(dp[i + 1][j], dp[i][j + 1]);
    }
  }
  // Backtrack to emit unified-style hunks
  const out: string[] = [`--- a/${relativePath}`, `+++ b/${relativePath}`];
  let i = 0, j = 0, oldStart = 0, newStart = 0;
  const hunk: string[] = [];
  let emittedLines = 0;
  while ((i < m || j < n) && emittedLines < DIFF_MAX_LINES) {
    if (i < m && j < n && oldLines[i] === newLines[j]) {
      hunk.push(` ${oldLines[i]}`);
      i++; j++;
    } else if (j < n && (i === m || dp[i][j + 1] >= dp[i + 1][j])) {
      hunk.push(`+${newLines[j]}`);
      j++; newStart++;
    } else {
      hunk.push(`-${oldLines[i]}`);
      i++; oldStart++;
    }
    emittedLines++;
  }
  if (emittedLines >= DIFF_MAX_LINES) hunk.push(`... (diff truncated at ${DIFF_MAX_LINES} lines)`);
  out.push(...hunk);
  return out.join('\n');
}

export async function diffFiles(
  basePath: string,
  files: Array<{ relativePath: string; content: string }>
): Promise<DiffResult[]> {
  const results: DiffResult[] = [];
  for (const file of files) {
    const filePath = path.join(basePath, file.relativePath);
    assertWithin(filePath, basePath);
    const sizeAfter = Buffer.byteLength(file.content, 'utf-8');

    let current: string | null = null;
    try {
      current = await fs.readFile(filePath, 'utf-8');
    } catch {
      current = null;
    }

    if (current === null) {
      results.push({
        path: file.relativePath,
        status: 'created',
        diff: null,
        sizeBefore: 0,
        sizeAfter,
      });
      continue;
    }

    const sizeBefore = Buffer.byteLength(current, 'utf-8');
    if (current === file.content) {
      results.push({
        path: file.relativePath,
        status: 'unchanged',
        diff: null,
        sizeBefore,
        sizeAfter,
      });
      continue;
    }

    results.push({
      path: file.relativePath,
      status: 'modified',
      diff: unifiedDiff(current, file.content, file.relativePath),
      sizeBefore,
      sizeAfter,
    });
  }
  return results;
}

/**
 * Patch specific keys in the tenant's .env file in place. Used by Approach C
 * upgrade for install.sh tenants where CCP can't re-render the full .env
 * (no encryptedSecrets in DB) but still needs to update Instance.imageTag-
 * derived values like IMAGE_TAG. Preserves comments, blank lines, and key
 * order; replaces existing keys, appends new ones at the end.
 *
 * Keys are validated against ENV_KEY_RE; values are written verbatim
 * (no shell escaping beyond what dotenv expects — newlines in values
 * are rejected to prevent .env smuggling).
 */
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;

export async function patchEnv(
  basePath: string,
  vars: Record<string, string>
): Promise<{ patched: string[]; added: string[] }> {
  const envPath = path.join(basePath, '.env');
  assertWithin(envPath, basePath);

  // Validate inputs before touching disk
  for (const [k, v] of Object.entries(vars)) {
    if (!ENV_KEY_RE.test(k)) {
      throw new AgentError(400, `Invalid env key: ${k}`, 'VALIDATION');
    }
    if (/[\r\n]/.test(v)) {
      throw new AgentError(400, `env value for ${k} contains newline`, 'VALIDATION');
    }
  }

  let current = '';
  try {
    current = await fs.readFile(envPath, 'utf-8');
  } catch (err) {
    throw new AgentError(404, `.env not found at ${envPath}`, 'NOT_FOUND');
  }

  const lines = current.split('\n');
  const patched: string[] = [];
  const remaining = new Set(Object.keys(vars));

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    // Match KEY=... (allowing leading whitespace? .env conventionally doesn't but be defensive)
    const m = line.match(/^([A-Z_][A-Z0-9_]*)=/);
    if (!m) continue;
    const key = m[1];
    if (remaining.has(key)) {
      lines[i] = `${key}=${vars[key]}`;
      patched.push(key);
      remaining.delete(key);
    }
  }

  // Append any keys that didn't exist in the file
  const added: string[] = [];
  if (remaining.size > 0) {
    // Trim trailing blank lines to avoid accumulating empties on repeated patches
    while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
    lines.push('', '# Added by CCP env-patch');
    for (const k of remaining) {
      lines.push(`${k}=${vars[k]}`);
      added.push(k);
    }
    lines.push(''); // trailing newline
  }

  await fs.writeFile(envPath, lines.join('\n'), 'utf-8');
  logger.info(`[files] env-patch ${envPath}: patched=${patched.length} added=${added.length}`);
  return { patched, added };
}

export async function mkdirp(basePath: string, relativePath: string): Promise<void> {
  const dirPath = path.join(basePath, relativePath);
  assertWithin(dirPath, basePath);
  await fs.mkdir(dirPath, { recursive: true });
}

/** Validate git repo URL and branch name to prevent shell injection. */
const SAFE_BRANCH = /^[a-zA-Z0-9][a-zA-Z0-9_.\/-]{0,99}$/;
const SAFE_REPO = /^[a-zA-Z0-9@:._\/-]+$/;
const SAFE_EXCLUDE = /^[a-zA-Z0-9_.\/-]+$/;

export async function cloneSource(
  basePath: string,
  gitRepo: string,
  gitBranch: string,
  excludes?: string[]
): Promise<void> {
  // SECURITY: Validate inputs before any shell execution
  if (!SAFE_REPO.test(gitRepo)) {
    throw new AgentError(400, 'Invalid git repository URL', 'VALIDATION');
  }
  if (!SAFE_BRANCH.test(gitBranch)) {
    throw new AgentError(400, 'Invalid git branch name', 'VALIDATION');
  }

  const { execFile } = await import('child_process');
  const { promisify } = await import('util');
  const execFileAsync = promisify(execFile);

  // Ensure base directory exists
  await fs.mkdir(basePath, { recursive: true });

  // Clone into a temp directory first, then move contents
  const tmpDir = `${basePath}.tmp-${Date.now()}`;
  try {
    // SECURITY: Use execFile with args array — no shell interpolation
    await execFileAsync('git', ['clone', '--branch', gitBranch, '--depth', '1', gitRepo, tmpDir], {
      timeout: 300_000,
    });

    // Remove git metadata and excluded directories
    const defaultExcludes = excludes || [
      '.git', 'node_modules', 'changemaker-control-panel', '.claude',
      'api/dist', 'admin/dist',
    ];
    for (const exclude of defaultExcludes) {
      // SECURITY: Validate each exclude entry
      if (!SAFE_EXCLUDE.test(exclude)) continue;
      const excludePath = path.join(tmpDir, exclude);
      // SECURITY: Verify exclude path is within tmpDir
      if (!path.resolve(excludePath).startsWith(path.resolve(tmpDir) + '/')) continue;
      try {
        await fs.rm(excludePath, { recursive: true, force: true });
      } catch { /* ignore if doesn't exist */ }
    }

    // Move contents to basePath using execFile (no shell)
    await execFileAsync('rsync', ['-a', `${tmpDir}/`, `${basePath}/`], { timeout: 120_000 });
    await fs.rm(tmpDir, { recursive: true, force: true });

    logger.info(`[files] Cloned ${gitRepo}@${gitBranch} → ${basePath}`);
  } catch (err) {
    // Clean up temp dir on failure
    try { await fs.rm(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
    throw err;
  }
}
