#!/usr/bin/env node

import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';

const CONTROLLED_ROOTS = new Map([
  ['changelogs-v2', '管理后台'],
  ['changelogs-v2-mp', '小程序端'],
]);
const CHANGE_TYPES = new Set(['新增接口', '修改接口', '删除接口', '修复', '前端缺陷', '前端优化', '前端修复']);
const PATH_ALIAS_MANIFEST = 'changelog-path-aliases.json';

function ruleError(code, path, message, expected) {
  return { code, path, message, expected };
}

export function getShanghaiDate(now = new Date()) {
  if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
    throw new TypeError('The validation clock must be a valid Date.');
  }

  const parts = new Intl.DateTimeFormat('en-CA', {
    timeZone: 'Asia/Shanghai',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
  }).formatToParts(now);
  const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
  if (!/^\d{4}$/.test(values.year) || !/^\d{2}$/.test(values.month) || !/^\d{2}$/.test(values.day)) {
    throw new Error('Unable to derive the current Asia/Shanghai calendar date.');
  }

  return {
    yearMonth: `${values.year}-${values.month}`,
    day: values.day,
    isoDate: `${values.year}-${values.month}-${values.day}`,
  };
}

export function parseNameStatusZ(raw) {
  const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw ?? '');
  const tokens = text.split('\0');
  if (tokens.at(-1) === '') {
    tokens.pop();
  }

  const records = [];
  for (let index = 0; index < tokens.length;) {
    const status = tokens[index++];
    if (!status) {
      throw new Error('Malformed git diff: an empty status token was found.');
    }

    if (/^[RC]\d{1,3}$/.test(status)) {
      const sourcePath = tokens[index++];
      const targetPath = tokens[index++];
      if (sourcePath === undefined || targetPath === undefined) {
        throw new Error(`Malformed git diff: ${status} must contain source and target paths.`);
      }
      records.push({ status, sourcePath, targetPath });
      continue;
    }

    const targetPath = tokens[index++];
    if (targetPath === undefined) {
      throw new Error(`Malformed git diff: ${status} is missing its path.`);
    }
    records.push({ status, targetPath });
  }
  return records;
}

export function collectNewTargetPaths(records) {
  return records
    .filter(({ status }) => status === 'A' || /^C\d{1,3}$/.test(status) || /^R\d{1,3}$/.test(status))
    .map(({ targetPath }) => targetPath);
}

export function pathAliasMapFromManifest(value) {
  if (value?.schema !== 'hl-changelog-path-aliases/v1' || !Array.isArray(value.aliases)) {
    throw new Error('changelog-path-aliases.json schema 或 aliases 非法');
  }
  const aliases = new Map();
  for (const [index, entry] of value.aliases.entries()) {
    if (
      !entry
      || typeof entry.alias !== 'string'
      || typeof entry.canonical !== 'string'
      || entry.alias.length === 0
      || entry.canonical.length === 0
    ) {
      throw new Error(`changelog-path-aliases.json aliases[${index}] 缺少 alias/canonical`);
    }
    if (aliases.has(entry.alias)) {
      throw new Error(`changelog-path-aliases.json alias 重复: ${entry.alias}`);
    }
    aliases.set(entry.alias, entry.canonical);
  }
  return aliases;
}

export function loadPathAliasMap(root = process.cwd()) {
  try {
    return pathAliasMapFromManifest(JSON.parse(readFileSync(`${root}/${PATH_ALIAS_MANIFEST}`, 'utf8')));
  } catch (error) {
    if (error?.code === 'ENOENT') {
      return new Map();
    }
    throw error;
  }
}

export function controlledRootForPath(inputPath) {
  const candidate = String(inputPath);
  for (const root of CONTROLLED_ROOTS.keys()) {
    if (candidate === root || candidate.startsWith(`${root}/`) || candidate.startsWith(`${root}\\`)) {
      return root;
    }
  }
  return undefined;
}

export function validateChangelogPath(inputPath, now = new Date()) {
  const changelogPath = String(inputPath);
  const root = controlledRootForPath(changelogPath);
  if (!root) {
    return [];
  }
  const expectedClient = CONTROLLED_ROOTS.get(root);
  if (changelogPath.includes('\\')) {
    return [ruleError(
      'E_STRUCTURE',
      changelogPath,
      '受控 Git 路径包含反斜杠；反斜杠是文件名字节，不是路径分隔符',
      `${root}/YYYY-MM/DD_issue_标题-{新增接口|修改接口|删除接口}-${expectedClient}.md`,
    )];
  }

  const expectedDate = getShanghaiDate(now);
  const errors = [];
  const segments = changelogPath.split('/');
  if (segments.length !== 3 || !segments[2].endsWith('.md')) {
    return [ruleError(
      'E_STRUCTURE',
      changelogPath,
      '路径层级或扩展名不符合规则',
      `${root}/YYYY-MM/DD_issue_标题-{新增接口|修改接口|删除接口}-${expectedClient}.md`,
    )];
  }

  const [, yearMonth, filename] = segments;
  if (yearMonth !== expectedDate.yearMonth) {
    errors.push(ruleError(
      'E_MONTH',
      changelogPath,
      `月目录为 ${yearMonth}`,
      `Asia/Shanghai 当天的 ${expectedDate.yearMonth}`,
    ));
  }

  const stem = filename.slice(0, -3);
  const clientSeparator = stem.lastIndexOf('-');
  const actualClient = clientSeparator >= 0 ? stem.slice(clientSeparator + 1) : '';
  const beforeClient = clientSeparator >= 0 ? stem.slice(0, clientSeparator) : stem;
  if (actualClient !== expectedClient) {
    errors.push(ruleError(
      'E_CLIENT',
      changelogPath,
      `端类型为 ${actualClient || '(缺失)'}`,
      expectedClient,
    ));
  }

  const typeSeparator = beforeClient.lastIndexOf('-');
  const actualType = typeSeparator >= 0 ? beforeClient.slice(typeSeparator + 1) : '';
  const prefix = typeSeparator >= 0 ? beforeClient.slice(0, typeSeparator) : beforeClient;
  if (!CHANGE_TYPES.has(actualType)) {
    errors.push(ruleError(
      'E_CHANGE_TYPE',
      changelogPath,
      `变更类型为 ${actualType || '(缺失)'}`,
      [...CHANGE_TYPES].join(' / '),
    ));
  }

  const prefixMatch = /^(\d{2})_([^_]*)_(.*)$/.exec(prefix);
  if (!prefixMatch) {
    const dayMatch = /^(\d{2})_/.exec(prefix);
    if (dayMatch && dayMatch[1] !== expectedDate.day) {
      errors.push(ruleError('E_DAY', changelogPath, `日前缀为 ${dayMatch[1]}`, expectedDate.day));
    }
    errors.push(ruleError(
      'E_ISSUE',
      changelogPath,
      '文件名缺少可识别的正整数 Issue 号',
      'DD_[1-9][0-9]*_标题',
    ));
    return errors;
  }

  const [, day, issue, title] = prefixMatch;
  if (day !== expectedDate.day) {
    errors.push(ruleError('E_DAY', changelogPath, `日前缀为 ${day}`, expectedDate.day));
  }
  if (!/^[1-9]\d*[a-z]?$/.test(issue) && issue !== 'frontend') {
    errors.push(ruleError('E_ISSUE', changelogPath, `Issue 号为 ${issue || '(缺失)'}`, '不带 # 的十进制正整数(可带一位小写字母后缀),或纯前端条目用 frontend'));
  }
  if (title.trim() === '') {
    errors.push(ruleError('E_TITLE', changelogPath, '业务标题为空', '非空业务标题'));
  }

  return errors;
}

export function runValidation(records, now = new Date(), { pathAliases = new Map() } = {}) {
  const targetPaths = collectNewTargetPaths(records);
  const controlledPaths = targetPaths.filter((targetPath) => controlledRootForPath(targetPath));
  const errors = controlledPaths.flatMap((targetPath) => (
    pathAliases.has(targetPath) ? [] : validateChangelogPath(targetPath, now)
  ));
  for (const record of records) {
    const sourcePath = /^R\d{1,3}$/.test(record.status)
      ? record.sourcePath
      : record.status === 'D' ? record.targetPath : undefined;
    if (!sourcePath || !controlledRootForPath(sourcePath) || !sourcePath.endsWith('.md')) {
      continue;
    }
    const expectedTarget = pathAliases.get(sourcePath);
    const actualTarget = /^R\d{1,3}$/.test(record.status) ? record.targetPath : undefined;
    if (!expectedTarget || (actualTarget && expectedTarget !== actualTarget)) {
      errors.push(ruleError(
        'E_PATH_STABILITY',
        sourcePath,
        '已发布 changelog 不得在缺少精确 alias 关系时删除或重命名',
        actualTarget
          ? `在 ${PATH_ALIAS_MANIFEST} 登记 ${sourcePath} -> ${actualTarget} 并保留兼容入口`
          : `在 ${PATH_ALIAS_MANIFEST} 登记迁移关系并保留兼容入口`,
      ));
    }
  }
  return {
    targetCount: targetPaths.length,
    checkedCount: controlledPaths.length,
    errors,
    expectedDate: getShanghaiDate(now),
  };
}

export function parseArguments(argv) {
  const options = {};
  for (let index = 0; index < argv.length; index += 2) {
    const flag = argv[index];
    const value = argv[index + 1];
    if (!['--event', '--base', '--head'].includes(flag) || value === undefined) {
      throw new Error(`Unsupported or incomplete argument: ${flag ?? '(missing)'}`);
    }
    options[flag.slice(2)] = value;
  }
  if (options.event && (options.base || options.head)) {
    throw new Error('--event cannot be combined with --base/--head.');
  }
  if (!options.event && (!options.base || !options.head)) {
    throw new Error('Use --event <path> or both --base <ref> --head <ref>.');
  }
  return options;
}

function runGit(args, options = {}) {
  return execFileSync('git', args, {
    ...options,
    stdio: ['pipe', 'pipe', 'pipe'],
  });
}

function resolveCommitRef(ref, label) {
  if (typeof ref !== 'string' || ref.length === 0 || /[\0\r\n]/.test(ref)) {
    throw new Error(`${label} must be a non-empty Git ref without control characters.`);
  }
  let resolved;
  try {
    resolved = runGit(['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], { encoding: 'utf8' }).trim();
  } catch {
    throw new Error(`${label} does not resolve to an existing commit.`);
  }
  if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(resolved)) {
    throw new Error(`${label} resolved to an unexpected object ID.`);
  }
  return resolved;
}

function resolveEventCommitSha(sha, label) {
  if (typeof sha !== 'string' || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(sha)) {
    throw new Error(`${label} must be a full 40- or 64-character hexadecimal commit SHA.`);
  }
  return resolveCommitRef(sha.toLowerCase(), label);
}

function emptyTreeObjectId() {
  const objectId = runGit(['hash-object', '-t', 'tree', '--stdin'], {
    input: Buffer.alloc(0),
    encoding: 'utf8',
  }).trim();
  if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(objectId)) {
    throw new Error('Git returned an unexpected empty-tree object ID.');
  }
  return objectId;
}

function gitDiff(revisions) {
  return runGit(['diff', '--name-status', '-z', '--find-renames', ...revisions, '--'], {
    encoding: 'buffer',
    maxBuffer: 64 * 1024 * 1024,
  });
}

export function diffFromOptions(options) {
  if (!options.event) {
    const base = resolveCommitRef(options.base, 'base ref');
    const head = resolveCommitRef(options.head, 'head ref');
    return gitDiff([`${base}...${head}`]);
  }

  const event = JSON.parse(readFileSync(options.event, 'utf8'));
  if (event.pull_request) {
    const base = event.pull_request.base?.sha;
    const head = event.pull_request.head?.sha;
    if (!base || !head) {
      throw new Error('The pull_request event is missing base/head SHA values.');
    }
    const resolvedBase = resolveEventCommitSha(base, 'pull_request base SHA');
    const resolvedHead = resolveEventCommitSha(head, 'pull_request head SHA');
    return gitDiff([`${resolvedBase}...${resolvedHead}`]);
  }

  if (event.before && event.after) {
    if (typeof event.before !== 'string' || !/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/.test(event.before)) {
      throw new Error('push before SHA must be a full 40- or 64-character hexadecimal value.');
    }
    const before = /^0+$/.test(event.before)
      ? emptyTreeObjectId()
      : resolveEventCommitSha(event.before, 'push before SHA');
    const after = resolveEventCommitSha(event.after, 'push after SHA');
    return gitDiff([before, after]);
  }

  throw new Error('Unsupported event payload: expected pull_request or push before/after SHA values.');
}

export function main(argv = process.argv.slice(2)) {
  try {
    const options = parseArguments(argv);
    const records = parseNameStatusZ(diffFromOptions(options));
    const result = runValidation(records, new Date(), {
      pathAliases: loadPathAliasMap(),
    });
    if (result.errors.length > 0) {
      for (const error of result.errors) {
        console.error(`[${error.code}] ${error.path}: ${error.message}; expected ${error.expected}`);
      }
      console.error(`FAIL: found ${new Set(result.errors.map(({ path }) => path)).size} invalid changelog path(s) among ${result.checkedCount} controlled changelog path(s).`);
      return 1;
    }
    console.log(`PASS: validated ${result.checkedCount} controlled changelog path(s) from ${result.targetCount} new target path(s); expected Shanghai date ${result.expectedDate.isoDate}.`);
    return 0;
  } catch (error) {
    console.error(`ERROR: ${error instanceof Error ? error.message : String(error)}`);
    return 2;
  }
}

const isCli = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
if (isCli) {
  process.exitCode = main();
}
