import fs from 'fs';
import os from 'os';
import path from 'path';

import { DATA_DIR } from './config.js';
import { getEnv } from './env.js';
import type { OutboundAttachment } from './types.js';

export interface ValidatedOutboundAttachment {
  attachment: string;
  name: string;
}

export interface ValidateOutboundAttachmentsResult {
  files: ValidatedOutboundAttachment[];
  rejected: Array<{ path: string; reason: string }>;
}

const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i;
const SUPPORTED_ATTACHMENT_MIMES: Record<string, string> = {
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.gif': 'image/gif',
  '.webp': 'image/webp',
  '.bmp': 'image/bmp',
  '.mp4': 'video/mp4',
  '.mov': 'video/quicktime',
  '.webm': 'video/webm',
  '.mp3': 'audio/mpeg',
  '.wav': 'audio/wav',
  '.ogg': 'audio/ogg',
  '.m4a': 'audio/mp4',
  '.flac': 'audio/flac',
  '.pdf': 'application/pdf',
  '.zip': 'application/zip',
  '.txt': 'text/plain',
  '.md': 'text/markdown',
  '.csv': 'text/csv',
  '.json': 'application/json',
};

function unique(values: Array<string | null | undefined>): string[] {
  return [
    ...new Set(values.filter((value): value is string => Boolean(value))),
  ];
}

function resolveExistingDir(dir: string): string | null {
  try {
    if (!fs.existsSync(dir)) return null;
    return fs.realpathSync(dir);
  } catch {
    return null;
  }
}

function expandHomeDir(dir: string): string {
  const home = getEnv('HOME') || os.homedir();
  if (dir === '~') return home;
  if (dir.startsWith(`~${path.sep}`)) return path.join(home, dir.slice(2));
  return dir;
}

export function getConfiguredAttachmentBaseDirs(): string[] {
  const raw = getEnv('EJCLAW_ATTACHMENT_ALLOWED_DIRS') ?? '';
  return unique(
    raw
      .split(/[,\n]/)
      .flatMap((part) => part.split(path.delimiter))
      .map((value) => value.trim())
      .filter(Boolean)
      .map(expandHomeDir),
  );
}

export function getDefaultAttachmentBaseDirs(): string[] {
  const home = getEnv('HOME');
  const codexHome =
    getEnv('CODEX_HOME') || (home ? path.join(home, '.codex') : null);
  // Keep defaults narrow. Runtime-specific workspaces must be passed via
  // attachmentBaseDirs so one room cannot attach another room's files by path.
  // Ad-hoc generated files under os.tmpdir() are handled separately after
  // resolving symlinks, without allowlisting all of /tmp recursively.
  return unique([
    path.join(DATA_DIR, 'attachments'),
    codexHome ? path.join(codexHome, 'generated_images') : null,
    ...getConfiguredAttachmentBaseDirs(),
  ])
    .map(resolveExistingDir)
    .filter((dir): dir is string => Boolean(dir));
}

function resolveAllowedBaseDirs(baseDirs?: string[]): string[] {
  return unique([
    ...getDefaultAttachmentBaseDirs(),
    ...(baseDirs ?? []).map(resolveExistingDir),
  ]).filter((dir): dir is string => Boolean(dir));
}

function isWithinBaseDir(realPath: string, baseDir: string): boolean {
  const relative = path.relative(baseDir, realPath);
  return (
    relative === '' ||
    (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
  );
}

function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean {
  return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir));
}

function isWithinTempDir(realPath: string, tempDir: string | null): boolean {
  if (!tempDir) return false;
  const relative = path.relative(tempDir, realPath);
  if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
    return false;
  }
  return true;
}

function detectImageMime(filePath: string): string | null {
  const handle = fs.openSync(filePath, 'r');
  try {
    const buffer = Buffer.alloc(512);
    const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0);
    const header = buffer.subarray(0, bytesRead);
    if (
      header
        .subarray(0, 8)
        .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
    ) {
      return 'image/png';
    }
    if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) {
      return 'image/jpeg';
    }
    if (
      header.subarray(0, 6).toString('ascii') === 'GIF87a' ||
      header.subarray(0, 6).toString('ascii') === 'GIF89a'
    ) {
      return 'image/gif';
    }
    if (
      header.subarray(0, 4).toString('ascii') === 'RIFF' &&
      header.subarray(8, 12).toString('ascii') === 'WEBP'
    ) {
      return 'image/webp';
    }
    if (header[0] === 0x42 && header[1] === 0x4d) {
      return 'image/bmp';
    }
    return null;
  } finally {
    fs.closeSync(handle);
  }
}

function readHeader(filePath: string, byteCount = 512): Buffer {
  const handle = fs.openSync(filePath, 'r');
  try {
    const buffer = Buffer.alloc(byteCount);
    const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0);
    return buffer.subarray(0, bytesRead);
  } finally {
    fs.closeSync(handle);
  }
}

function isLikelyTextFile(header: Buffer): boolean {
  return !header.includes(0);
}

function detectSupportedMime(filePath: string): string | null {
  const ext = path.extname(filePath).toLowerCase();
  const expectedMime = SUPPORTED_ATTACHMENT_MIMES[ext];
  if (!expectedMime) return null;
  if (IMAGE_EXTS.test(filePath)) {
    return detectImageMime(filePath);
  }

  const header = readHeader(filePath);
  switch (ext) {
    case '.mp4':
    case '.mov':
    case '.m4a':
      return header.subarray(4, 8).toString('ascii') === 'ftyp'
        ? expectedMime
        : null;
    case '.webm':
      return header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3]))
        ? expectedMime
        : null;
    case '.mp3':
      return header.subarray(0, 3).toString('ascii') === 'ID3' ||
        (header[0] === 0xff && (header[1] & 0xe0) === 0xe0)
        ? expectedMime
        : null;
    case '.wav':
      return header.subarray(0, 4).toString('ascii') === 'RIFF' &&
        header.subarray(8, 12).toString('ascii') === 'WAVE'
        ? expectedMime
        : null;
    case '.ogg':
      return header.subarray(0, 4).toString('ascii') === 'OggS'
        ? expectedMime
        : null;
    case '.flac':
      return header.subarray(0, 4).toString('ascii') === 'fLaC'
        ? expectedMime
        : null;
    case '.pdf':
      return header.subarray(0, 5).toString('ascii') === '%PDF-'
        ? expectedMime
        : null;
    case '.zip':
      return header
        .subarray(0, 4)
        .equals(Buffer.from([0x50, 0x4b, 0x03, 0x04])) ||
        header.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x05, 0x06])) ||
        header.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x07, 0x08]))
        ? expectedMime
        : null;
    case '.txt':
    case '.md':
    case '.csv':
    case '.json':
      return isLikelyTextFile(header) ? expectedMime : null;
    default:
      return null;
  }
}

function normalizeAttachmentName(
  attachment: OutboundAttachment,
  realPath: string,
): string {
  const candidate = attachment.name
    ? path.basename(attachment.name)
    : path.basename(realPath);
  return candidate || 'attachment';
}

const REJECTION_REASON_LABELS: Record<string, string> = {
  'outside-allowed-dirs': '허용된 폴더 밖의 경로',
  'not-found': '파일을 찾을 수 없음',
  'not-absolute': '절대경로가 아님',
  'not-file': '파일이 아님',
  'too-large': '8MB 용량 초과',
  'unsupported-extension': '지원하지 않는 형식',
  'invalid-image-signature': '이미지 형식 손상 또는 불일치',
  'invalid-file-signature': '파일 형식 손상 또는 불일치',
  'mime-mismatch': '형식 불일치',
  'validation-error': '검증 오류',
};

/**
 * Builds a concise, user-facing note describing attachments that failed to
 * send. Returning a non-empty string lets the delivery layer surface the
 * failure in the visible message instead of dropping it silently — otherwise a
 * stripped MEDIA: directive leaves the user with a message that claims a file
 * was attached when none was delivered.
 */
export function describeRejectedAttachments(
  rejected: Array<{ path: string; reason: string }>,
): string {
  if (rejected.length === 0) return '';
  const lines = rejected.map(({ path: rejectedPath, reason }) => {
    const label = REJECTION_REASON_LABELS[reason] ?? reason;
    return `• ${path.basename(rejectedPath)} (${label})`;
  });
  return `⚠️ 첨부 ${rejected.length}건을 전송하지 못했습니다:\n${lines.join('\n')}`;
}

/**
 * Folds a rejected-attachment notice into the visible outbound text so the
 * delivery layer never sends a message that silently dropped its attachments.
 * Returns the notice on its own when the body would otherwise be empty.
 */
export function appendRejectionNotice(
  cleanText: string,
  rejected: Array<{ path: string; reason: string }>,
): string {
  const note = describeRejectedAttachments(rejected);
  if (!note) return cleanText;
  return cleanText ? `${cleanText}\n\n${note}` : note;
}

export function validateOutboundAttachments(
  attachments: OutboundAttachment[] | undefined,
  options: { baseDirs?: string[] } = {},
): ValidateOutboundAttachmentsResult {
  const baseDirs = resolveAllowedBaseDirs(options.baseDirs);
  const defaultTempDir = resolveExistingDir(os.tmpdir());
  const files: ValidatedOutboundAttachment[] = [];
  const rejected: Array<{ path: string; reason: string }> = [];
  const seen = new Set<string>();

  for (const attachment of attachments ?? []) {
    const requestedPath = attachment.path;
    try {
      if (!path.isAbsolute(requestedPath)) {
        rejected.push({ path: requestedPath, reason: 'not-absolute' });
        continue;
      }
      const expectedMime =
        SUPPORTED_ATTACHMENT_MIMES[path.extname(requestedPath).toLowerCase()];
      if (!expectedMime) {
        rejected.push({ path: requestedPath, reason: 'unsupported-extension' });
        continue;
      }
      if (!fs.existsSync(requestedPath)) {
        rejected.push({ path: requestedPath, reason: 'not-found' });
        continue;
      }
      const realPath = fs.realpathSync(requestedPath);
      if (seen.has(realPath)) continue;
      const stat = fs.statSync(realPath);
      if (!stat.isFile()) {
        rejected.push({ path: requestedPath, reason: 'not-file' });
        continue;
      }
      if (stat.size > MAX_ATTACHMENT_BYTES) {
        rejected.push({ path: requestedPath, reason: 'too-large' });
        continue;
      }
      if (
        !matchesAllowedBaseDir(realPath, baseDirs) &&
        !isWithinTempDir(realPath, defaultTempDir)
      ) {
        rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' });
        continue;
      }
      const detectedMime = detectSupportedMime(realPath);
      if (!detectedMime) {
        rejected.push({
          path: requestedPath,
          reason: IMAGE_EXTS.test(realPath)
            ? 'invalid-image-signature'
            : 'invalid-file-signature',
        });
        continue;
      }
      if (attachment.mime && attachment.mime !== detectedMime) {
        rejected.push({ path: requestedPath, reason: 'mime-mismatch' });
        continue;
      }
      files.push({
        attachment: realPath,
        name: normalizeAttachmentName(attachment, realPath),
      });
      seen.add(realPath);
    } catch {
      rejected.push({ path: requestedPath, reason: 'validation-error' });
    }
  }

  return { files, rejected };
}
