import crypto from 'node:crypto';
import path from 'node:path';
import {
  mkdir,
  link,
  open,
  readFile,
  readdir,
  rename,
  stat,
  unlink,
  utimes
} from 'node:fs/promises';

import { decodeModifiedUtf7, encodeModifiedUtf7 } from './imap-utf7.js';

const standardFolders = ['INBOX', 'Sent', 'Drafts', 'Trash', 'Junk', 'Archive'];
const storageKeyPattern = /^[a-z0-9][a-z0-9._-]{0,191}$/;
const managedStorageKeyPattern = /^((?:mhdb|mhsmtp|mhappend|mhvesta)-[a-z0-9-]+)\./;
const imapFlagByMaildirFlag = new Map([
  ['D', '\\Draft'],
  ['F', '\\Flagged'],
  ['P', '\\Passed'],
  ['R', '\\Answered'],
  ['S', '\\Seen'],
  ['T', '\\Deleted']
]);
const maildirFlagByImapFlag = new Map(
  [...imapFlagByMaildirFlag].map(([maildirFlag, imapFlag]) => [imapFlag.toLowerCase(), maildirFlag])
);

export function maildirRootFromEnvironment(env = process.env) {
  return path.resolve(env.MAILDIR_ROOT || path.join(env.DATA_DIR || path.join(process.cwd(), 'data'), 'maildir'));
}

export function maildirHomePath(root, address) {
  const cleanRoot = path.resolve(String(root || ''));
  const cleanAddress = normalizeStorageAddress(address);
  const home = path.resolve(cleanRoot, cleanAddress);
  if (path.dirname(home) !== cleanRoot) throw new Error('Maildir 邮箱路径不正确。');
  return home;
}

export function maildirFolderPath(root, address, folder = 'INBOX') {
  const mailRoot = path.join(maildirHomePath(root, address), 'mail');
  const cleanFolder = normalizeFolder(folder);
  if (cleanFolder === 'INBOX') return mailRoot;
  const encoded = cleanFolder
    .split('/')
    // Maildir++ uses a literal dot as its hierarchy separator. Dovecot's
    // official storage escape keeps literal dots and the escape character
    // distinct from hierarchy separators.
    .map((segment, index) => encodeMaildirFolderSegment(segment, index === 0))
    .join('.');
  return path.join(mailRoot, `.${encoded}`);
}

export async function ensureMaildirMailbox(root, address, folders = standardFolders, { durable = false } = {}) {
  const uniqueFolders = new Set(['INBOX', ...folders.map(normalizeFolder)]);
  const directories = [];
  for (const folder of uniqueFolders) {
    const folderPath = maildirFolderPath(root, address, folder);
    for (const bucket of ['tmp', 'new', 'cur']) {
      const directory = path.join(folderPath, bucket);
      await mkdir(directory, { recursive: true, mode: 0o700 });
      directories.push(directory);
    }
  }
  if (durable) await flushMaildirFiles([], { root, directories });
  return maildirHomePath(root, address);
}

export async function writeMaildirMessage({
  root,
  address,
  rawMessageBytes,
  folder = 'INBOX',
  flags = [],
  keywords = [],
  read = undefined,
  receivedAt = new Date(),
  storageKey = '',
  durable = true
}) {
  const bytes = Buffer.from(rawMessageBytes || Buffer.alloc(0));
  const cleanFolder = normalizeFolder(folder);
  const cleanStorageKey = normalizeStorageKey(storageKey || newMaildirStorageKey('smtp'));
  const timestamp = normalizeDate(receivedAt);
  const folderPath = maildirFolderPath(root, address, cleanFolder);
  await ensureMaildirMailbox(root, address, [cleanFolder]);

  const keywordFlags = await ensureDovecotKeywords(folderPath, keywords);
  const maildirFlags = `${encodeMaildirFlags(flags, read)}${keywordFlags}`
    .split('')
    .sort()
    .join('');
  const baseName = createMaildirFilename(cleanStorageKey, bytes);
  // Maildir flags and Dovecot keyword letters are valid only in cur/. Keep
  // truly unflagged unread deliveries in new/ so Dovecot can announce them as new mail.
  const destinationBucket = maildirFlags ? 'cur' : 'new';
  const destinationName = maildirFlags ? `${baseName}:2,${maildirFlags}` : baseName;
  const tmpPath = path.join(folderPath, 'tmp', `${baseName}.${crypto.randomBytes(6).toString('hex')}`);
  const destinationPath = path.join(folderPath, destinationBucket, destinationName);
  const handle = await open(tmpPath, 'wx', 0o600);
  try {
    await handle.writeFile(bytes);
    if (durable) await handle.sync();
  } finally {
    await handle.close();
  }
  await utimes(tmpPath, timestamp, timestamp);
  try {
    await link(tmpPath, destinationPath);
  } catch (error) {
    await unlink(tmpPath).catch(() => null);
    if (error?.code === 'EEXIST') throw new Error(`Maildir 存储标识冲突：${cleanStorageKey}`);
    throw error;
  }
  await unlink(tmpPath);
  if (durable) await syncDirectoryChain(path.dirname(destinationPath), path.resolve(root));
  return storageMetadata({
    root,
    address,
    filePath: destinationPath,
    storageKey: cleanStorageKey,
    bytes,
    timestamp
  });
}

export async function scanMaildirMailbox({ root, address }) {
  const folderDirectories = await listMaildirFolderDirectories(root, address);
  if (!folderDirectories.length) return [];

  const messages = [];
  for (const folder of folderDirectories) {
    const keywordMap = await readDovecotKeywords(folder.path);
    for (const bucket of ['new', 'cur']) {
      let entries = [];
      try {
        entries = await readdir(path.join(folder.path, bucket), { withFileTypes: true });
      } catch (error) {
        if (error?.code === 'ENOENT') continue;
        throw error;
      }
      const files = entries.filter((entry) => entry.isFile() && !entry.name.startsWith('.'));
      const scanned = await mapWithConcurrency(files, 32, async (entry) => {
        const filePath = path.join(folder.path, bucket, entry.name);
        const fileStat = await stat(filePath);
        const rawFlags = parseRawMaildirFlags(entry.name);
        const { flags, keywords } = decodeMaildirFlags(rawFlags, keywordMap);
        const baseName = entry.name.replace(/:2,[^/]*$/, '');
        return {
          storageKey: storageKeyFromFilename(baseName),
          filePath,
          relpath: relativeMaildirPath(root, address, filePath),
          folder: folder.folder,
          flags,
          keywords,
          read: flags.some((flag) => flag.toLowerCase() === '\\seen'),
          size: Number(fileStat.size),
          mtimeMs: Math.max(0, Math.trunc(fileStat.mtimeMs)),
          receivedAt: fileStat.mtime.toISOString(),
          baseName
        };
      });
      messages.push(...scanned);
    }
  }
  return messages.sort((left, right) => left.relpath.localeCompare(right.relpath));
}

export async function listMaildirFolders({ root, address }) {
  return (await listMaildirFolderDirectories(root, address)).map((folder) => folder.folder);
}

export async function readMaildirMessage(entry) {
  const bytes = await readFile(entry.filePath);
  return {
    bytes,
    sha256: crypto.createHash('sha256').update(bytes).digest('hex')
  };
}

export async function flushMaildirFiles(filePaths, { root = '', directories = [] } = {}) {
  const cleanRoot = root ? path.resolve(String(root)) : '';
  const files = [...new Set((filePaths || [])
    .map((filePath) => String(filePath || '').trim())
    .filter(Boolean)
    .map((filePath) => path.resolve(filePath)))];
  if (cleanRoot && files.some((filePath) => !isPathInside(cleanRoot, filePath))) {
    throw new Error('Maildir 刷盘路径不正确。');
  }
  await mapWithConcurrency(files, 16, async (filePath) => {
    const handle = await open(filePath, 'r');
    try {
      await handle.sync();
    } finally {
      await handle.close();
    }
  });
  const directorySet = new Set([
    ...files.map((filePath) => path.dirname(filePath)),
    ...(directories || []).map((directory) => path.resolve(String(directory || '')))
  ]);
  for (const directory of [...directorySet]) {
    if (cleanRoot && !isPathInside(cleanRoot, directory, { allowRoot: true })) {
      throw new Error('Maildir 刷盘目录不正确。');
    }
    if (cleanRoot) addDirectoryChain(directorySet, directory, cleanRoot);
  }
  const orderedDirectories = [...directorySet].sort((left, right) => right.length - left.length);
  for (const directory of orderedDirectories) await syncDirectory(directory);
}

export async function setMaildirMessageSeen({ root, address, storageKey, relpath = '', seen = true }) {
  const cleanStorageKey = normalizeStorageKey(storageKey);
  const entries = await scanMaildirMailbox({ root, address });
  const entry = entries.find((candidate) => (
    candidate.storageKey === cleanStorageKey
    && (!relpath || candidate.relpath === relpath)
  )) || entries.find((candidate) => candidate.storageKey === cleanStorageKey);
  if (!entry) return null;
  const rawFlags = new Set(parseRawMaildirFlags(path.basename(entry.filePath)).split(''));
  if (seen) rawFlags.add('S');
  else rawFlags.delete('S');
  const flags = [...rawFlags].sort().join('');
  const baseName = path.basename(entry.filePath).replace(/:2,[^/]*$/, '');
  const folderPath = maildirFolderPath(root, address, entry.folder);
  const nextPath = path.join(folderPath, 'cur', `${baseName}:2,${flags}`);
  if (nextPath !== entry.filePath) {
    const sourceDirectory = path.dirname(entry.filePath);
    await mkdir(path.dirname(nextPath), { recursive: true, mode: 0o700 });
    await rename(entry.filePath, nextPath);
    await syncDirectoryChain(path.dirname(nextPath), path.resolve(root));
    if (sourceDirectory !== path.dirname(nextPath)) {
      await syncDirectoryChain(sourceDirectory, path.resolve(root));
    }
  }
  const fileStat = await stat(nextPath);
  const bytes = await readFile(nextPath);
  return storageMetadata({
    root,
    address,
    filePath: nextPath,
    storageKey: cleanStorageKey,
    bytes,
    timestamp: fileStat.mtime
  });
}

export function newMaildirStorageKey(origin = 'smtp') {
  const cleanOrigin = String(origin || 'smtp').toLowerCase().replace(/[^a-z0-9]/g, '') || 'smtp';
  return `mh${cleanOrigin}-${crypto.randomBytes(16).toString('hex')}`;
}

export function sqliteMaildirStorageKey(messageId) {
  const id = Number(messageId);
  if (!Number.isSafeInteger(id) || id <= 0) throw new Error('邮件 ID 不正确。');
  return `mhdb-${id}`;
}

function normalizeStorageAddress(value) {
  const address = String(value || '').trim().toLowerCase();
  if (
    address.length > 320
    || !/^[^\s@/\\]+@[^\s@/\\]+\.[^\s@/\\]+$/.test(address)
    || /[\r\n\u0000]/.test(address)
  ) throw new Error('Maildir 邮箱地址不正确。');
  return address;
}

function normalizeStorageKey(value) {
  const key = String(value || '').trim().toLowerCase();
  if (!storageKeyPattern.test(key)) throw new Error('Maildir 存储标识不正确。');
  return key;
}

function normalizeFolder(value) {
  const raw = String(value || 'INBOX').trim().replace(/\\/g, '/');
  if (!raw || /[\r\n\u0000]/.test(raw)) throw new Error('Maildir 文件夹名称不正确。');
  if (raw.toUpperCase() === 'INBOX') return 'INBOX';
  const standard = standardFolders.find((folder) => folder.toLowerCase() === raw.toLowerCase());
  if (standard) return standard;
  const parts = raw.split('/').map((part) => part.trim()).filter(Boolean);
  if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
    throw new Error('Maildir 文件夹名称不正确。');
  }
  return parts.join('/');
}

function decodeMaildirFolderName(value) {
  return String(value || '')
    .split('.')
    .map((segment) => decodeModifiedUtf7(decodeMaildirStorageEscapes(segment)))
    .join('/');
}

function encodeMaildirFolderSegment(value, firstPart) {
  const raw = encodeModifiedUtf7(value);
  let encoded = '';
  for (let index = 0; index < raw.length; index += 1) {
    const character = raw[index];
    if (
      character === '.'
      || character === '^'
      || character === '/'
      || (firstPart && index === 0 && character === '~')
    ) {
      encoded += `^${character.charCodeAt(0).toString(16).padStart(2, '0')}`;
    } else {
      encoded += character;
    }
  }
  return encoded;
}

function decodeMaildirStorageEscapes(value) {
  return String(value || '').replace(/\^([0-9a-fA-F]{2})/g, (_match, hex) => (
    String.fromCharCode(Number.parseInt(hex, 16))
  ));
}

async function listMaildirFolderDirectories(root, address) {
  const mailRoot = path.join(maildirHomePath(root, address), 'mail');
  let rootEntries = [];
  try {
    rootEntries = await readdir(mailRoot, { withFileTypes: true });
  } catch (error) {
    if (error?.code === 'ENOENT') return [];
    throw error;
  }
  const folders = [];
  if (hasMaildirBuckets(rootEntries)) folders.push({ folder: 'INBOX', path: mailRoot });
  for (const entry of rootEntries) {
    if (!entry.isDirectory() || !entry.name.startsWith('.') || entry.name.length < 2) continue;
    const folderPath = path.join(mailRoot, entry.name);
    let folderEntries;
    try {
      folderEntries = await readdir(folderPath, { withFileTypes: true });
    } catch (error) {
      if (error?.code === 'ENOENT') continue;
      throw error;
    }
    if (!hasMaildirBuckets(folderEntries)) continue;
    folders.push({
      folder: decodeMaildirFolderName(entry.name.slice(1)),
      path: folderPath
    });
  }
  return folders;
}

function hasMaildirBuckets(entries) {
  const directories = new Set(entries
    .filter((entry) => entry.isDirectory())
    .map((entry) => entry.name));
  return ['cur', 'new', 'tmp'].every((bucket) => directories.has(bucket));
}

function encodeMaildirFlags(flags, read) {
  const output = new Set();
  for (const flag of Array.isArray(flags) ? flags : [flags]) {
    const maildirFlag = maildirFlagByImapFlag.get(String(flag || '').trim().toLowerCase());
    if (maildirFlag) output.add(maildirFlag);
  }
  if (read === true) output.add('S');
  if (read === false) output.delete('S');
  return [...output].sort().join('');
}

function parseRawMaildirFlags(fileName) {
  return String(fileName || '').match(/:2,([A-Za-z]*)/)?.[1] || '';
}

function decodeMaildirFlags(rawFlags, keywordMap) {
  const flags = [];
  const keywords = [];
  for (const flag of String(rawFlags || '')) {
    // Maildir system flags are uppercase. Dovecot reserves lowercase a-z for
    // keyword slots, so normalizing case would turn keyword "d" into \Draft.
    const imapFlag = imapFlagByMaildirFlag.get(flag);
    if (imapFlag) flags.push(imapFlag);
    if (/[a-z]/.test(flag) && keywordMap.has(flag.charCodeAt(0) - 97)) {
      keywords.push(keywordMap.get(flag.charCodeAt(0) - 97));
    }
  }
  return {
    flags: [...new Set(flags)],
    keywords: [...new Set(keywords)]
  };
}

async function readDovecotKeywords(folderPath) {
  const output = new Map();
  try {
    const content = await readFile(path.join(folderPath, 'dovecot-keywords'), 'utf8');
    for (const line of content.split(/\r?\n/)) {
      const match = line.match(/^([0-9]|1[0-9]|2[0-5])\s+(.+)$/);
      if (match) output.set(Number(match[1]), match[2].trim());
    }
  } catch (error) {
    if (error?.code !== 'ENOENT') throw error;
  }
  return output;
}

async function ensureDovecotKeywords(folderPath, keywords) {
  const cleanKeywords = [...new Set((Array.isArray(keywords) ? keywords : [keywords])
    .map((keyword) => String(keyword || '').trim())
    .filter((keyword) => keyword && keyword.length <= 255 && !/[\r\n\u0000]/.test(keyword)))];
  if (!cleanKeywords.length) return '';
  const existing = await readDovecotKeywords(folderPath);
  const indexByKeyword = new Map([...existing].map(([index, keyword]) => [keyword, index]));
  let changed = false;
  for (const keyword of cleanKeywords) {
    if (indexByKeyword.has(keyword)) continue;
    const freeIndex = Array.from({ length: 26 }, (_, index) => index)
      .find((index) => !existing.has(index));
    if (freeIndex === undefined) continue;
    existing.set(freeIndex, keyword);
    indexByKeyword.set(keyword, freeIndex);
    changed = true;
  }
  if (changed) {
    const content = [...existing]
      .sort(([left], [right]) => left - right)
      .map(([index, keyword]) => `${index} ${keyword}\n`)
      .join('');
    const target = path.join(folderPath, 'dovecot-keywords');
    const temporary = path.join(folderPath, `.dovecot-keywords.${crypto.randomBytes(8).toString('hex')}`);
    const handle = await open(temporary, 'wx', 0o600);
    try {
      await handle.writeFile(content, 'utf8');
      await handle.sync();
    } finally {
      await handle.close();
    }
    await rename(temporary, target);
    await syncDirectory(folderPath);
  }
  return cleanKeywords
    .map((keyword) => indexByKeyword.get(keyword))
    .filter((index) => Number.isInteger(index))
    .map((index) => String.fromCharCode(97 + index))
    .join('');
}

function storageKeyFromFilename(baseName) {
  const managed = String(baseName || '').match(managedStorageKeyPattern)?.[1];
  if (managed) return managed;
  return `mhext-${crypto.createHash('sha256').update(String(baseName || '')).digest('hex').slice(0, 40)}`;
}

function createMaildirFilename(storageKey) {
  // The key is already cryptographically unique for new mail and deterministic
  // for migrations. Keeping the basename stable lets Dovecot move or rename
  // flags without changing MailHub's storage identity.
  return `${storageKey}.mailhub`;
}

function normalizeDate(value) {
  const timestamp = value instanceof Date ? value : new Date(value);
  if (!Number.isFinite(timestamp.getTime())) throw new Error('邮件时间不正确。');
  return timestamp;
}

function relativeMaildirPath(root, address, filePath) {
  const home = maildirHomePath(root, address);
  const relative = path.relative(home, filePath).split(path.sep).join('/');
  if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) {
    throw new Error('Maildir 存储路径不正确。');
  }
  return relative;
}

function storageMetadata({ root, address, filePath, storageKey, bytes, timestamp }) {
  return {
    backend: 'maildir',
    key: storageKey,
    relpath: relativeMaildirPath(root, address, filePath),
    sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
    size: bytes.length,
    mtimeMs: Math.max(0, Math.trunc(timestamp.getTime())),
    indexedAt: new Date().toISOString()
  };
}

async function syncDirectory(directory) {
  let handle;
  try {
    handle = await open(directory, 'r');
    await handle.sync();
  } catch (error) {
    if (!['EINVAL', 'ENOTSUP', 'EISDIR', 'EPERM'].includes(error?.code)) throw error;
  } finally {
    await handle?.close();
  }
}

async function syncDirectoryChain(directory, root) {
  const directories = new Set();
  addDirectoryChain(directories, path.resolve(directory), path.resolve(root));
  for (const current of [...directories].sort((left, right) => right.length - left.length)) {
    await syncDirectory(current);
  }
}

function addDirectoryChain(output, directory, root) {
  let current = path.resolve(directory);
  const cleanRoot = path.resolve(root);
  if (!isPathInside(cleanRoot, current, { allowRoot: true })) {
    throw new Error('Maildir 目录不正确。');
  }
  while (true) {
    output.add(current);
    if (current === cleanRoot) return;
    current = path.dirname(current);
  }
}

function isPathInside(root, candidate, { allowRoot = false } = {}) {
  const relative = path.relative(root, candidate);
  if (!relative) return allowRoot;
  return !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
}

async function mapWithConcurrency(values, concurrency, mapper) {
  const output = new Array(values.length);
  let cursor = 0;
  const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
    while (cursor < values.length) {
      const index = cursor;
      cursor += 1;
      output[index] = await mapper(values[index], index);
    }
  });
  await Promise.all(workers);
  return output;
}
