/**
 * Generate human-readable reports about missing contributors.
 * This module queries merged PRs via 'gh' and produces a markdown report.
 */
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { setupGracefulShutdown } from './utils/graceful-shutdown.mjs';

const DEFAULT_CMD_TIMEOUT = 30_000; // 30s

setupGracefulShutdown('contributor-report');

/**
 * Patterns that represent generated files; contributors should not be credited
 * for these files because they are not substantive authored content.
 */
export const AUTO_GENERATED_PATTERNS = [
  'README.md',
  'README.*.md',
  'docs/README.*.md',
  'docs/*.generated.md'
];

/**
 * File globs used to infer contribution types from file paths.
 */
export const TYPE_PATTERNS = {
  instructions: [
    'instructions/*.instructions.md'
  ],
  agents: [
    'chatmodes/*.chatmode.md',
    'agents/*.agent.md'
  ],
  skills: [
    'skills/'
  ],
  plugins: [
    'plugins/**/plugin.json'
  ],
  doc: [
    'docs/**/*.md',
    '.github/**/*.md',
    'CONTRIBUTING.md',
    'SECURITY.md',
    'SUPPORT.md',
    'LICENSE.md',
    'CHANGELOG.md',
    '*.md'
  ],
  infra: [
    '.github/workflows/**/*.yml',
    '.github/workflows/**/*.yaml',
    '**/*.yml',
    '**/*.yaml'
  ],
  maintenance: [
    'package*.json',
    '*config*',
    'tsconfig*.json'
  ],
  code: [
    '**/*.js',
    '**/*.ts',
    '**/*.mjs',
    '**/*.cjs',
    '**/*.py'
  ]
};

const globCache = new Map();

/**
 * Convert a simple glob (with *, **) to a RegExp.
 * This is intentionally small and deterministic for our repo patterns.
 * @param {string} pattern
 * @returns {RegExp}
 */
export const globToRegExp = (pattern) => {
  const DOUBLE_WILDCARD_PLACEHOLDER = '§§DOUBLE§§';

  // Escape all regex-special characters except glob wildcards (*, ?, /),
  // then translate glob syntax to regex.
  // Note: This function intentionally supports only a small subset of glob syntax.
  const regexSpecials = /[.+^${}()|[\]\\]/g;

  let normalized = String(pattern);

  // Normalize Windows-style separators to POSIX-style for matching.
  normalized = normalized.replaceAll('\\', '/');

  // Escape regex metacharacters so they are treated literally.
  normalized = normalized.replaceAll(regexSpecials, (match) => `\\${match}`);

  // Handle glob wildcards.
  normalized = normalized.replaceAll('**', DOUBLE_WILDCARD_PLACEHOLDER);
  normalized = normalized.replaceAll('*', '[^/]*');
  normalized = normalized.replaceAll(DOUBLE_WILDCARD_PLACEHOLDER, '.*');
  normalized = normalized.replaceAll('?', '.');

  return new RegExp(`^${normalized}$`);
};

/**
 * Test whether a file path matches a glob pattern.
 * @param {string} filePath
 * @param {string} pattern
 * @returns {boolean}
 */
export const matchGlob = (filePath, pattern) => {
  if (!globCache.has(pattern)) {
    try {
      globCache.set(pattern, globToRegExp(pattern));
    } catch {
      globCache.set(pattern, null);
    }
  }

  const regexp = globCache.get(pattern);
  if (!regexp) {
    return false;
  }

  const normalized = filePath.replaceAll('\\', '/');
  return regexp.test(normalized);
};

/**
 * Return true if the given path matches one of the known auto-generated patterns.
 * @param {string} filePath
 * @returns {boolean}
 */
export const isAutoGeneratedFile = (filePath) => {
  return AUTO_GENERATED_PATTERNS.some((pattern) => matchGlob(filePath, pattern));
};

/**
 * Infer a contribution type string (e.g. 'skills', 'agents', 'doc') for a file path.
 * Returns null if no specific type matched.
 * @param {string} filePath
 * @returns {string|null}
 */
export const getFileContributionType = (filePath) => {
  const normalized = filePath.replaceAll('\\', '/');

  for (const [type, patterns] of Object.entries(TYPE_PATTERNS)) {
    if (patterns.some((pattern) => matchGlob(normalized, pattern))) {
      return type;
    }
  }

  return null;
};

/**
 * Derive a comma-separated list of contribution type identifiers from a list of files.
 * Auto-generated files are ignored. Returns '' when no files to process.
 * @param {string[]} files
 * @returns {string}
 */
export const getContributionTypes = (files) => {
  const types = new Set();
  let processed = 0;

  for (const file of files) {
    if (isAutoGeneratedFile(file)) {
      continue;
    }

    processed += 1;
    const type = getFileContributionType(file);
    if (type) {
      types.add(type);
    }
  }

  if (processed === 0) {
    return '';
  }

  if (types.size === 0) {
    types.add('code');
  }

  return Array.from(types).sort((a, b) => a.localeCompare(b)).join(',');
};

/**
 * Check .all-contributors output to discover missing contributors.
 * This is the canonical implementation used by contributor tooling.
 * @returns {string[]}
 */
export const getMissingContributors = () => {
  try {
    console.log('🔍 Checking for missing contributors...');

    const configPath = path.join(process.cwd(), '.all-contributorsrc');
    const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
    const ignoreEntries = config.ignoreList || config.ignore || [];
    const ignoreSet = new Set(ignoreEntries.map((entry) => entry.toLowerCase()));

    if (ignoreSet.size > 0) {
      console.log(`📋 Loaded ignore list: ${Array.from(ignoreSet).join(', ')}`);
    }

    const output = execSync('npx all-contributors check', {
      encoding: 'utf8',
      stdio: ['pipe', 'pipe', 'pipe'],
      timeout: DEFAULT_CMD_TIMEOUT
    });

    const lines = output.split('\n');

    const headerLineIndex = lines.findIndex(line =>
      line.includes('Missing contributors in .all-contributorsrc:')
    );

    if (headerLineIndex === -1) {
      console.log('✅ No missing contributors found');
      return [];
    }

    let contributorsLine = '';
    for (let i = headerLineIndex + 1; i < lines.length; i++) {
      const line = lines[i].trim();

      if (line.includes('Unknown contributors') || line.includes('✨')) {
        break;
      }

      if (line && !line.startsWith('⠙') && !line.startsWith('✨')) {
        contributorsLine = line;
        break;
      }
    }

    if (!contributorsLine) {
      console.log('✅ No missing contributors found');
      return [];
    }

    const allUsernames = contributorsLine
      .split(',')
      .map(username => username.trim())
      .filter(username => username.length > 0);

    const filteredUsernames = allUsernames.filter(username => {
      const lowerUsername = username.toLowerCase();

      if (ignoreSet.has(lowerUsername)) {
        console.log(`⏭️  FILTERED: ${username} is in ignore list`);
        return false;
      }

      return true;
    });

    console.log(`📋 Found ${filteredUsernames.length} missing contributors after filtering: ${filteredUsernames.join(', ')}`);
    return filteredUsernames;

  } catch (error) {
    const stderr = String(error?.stderr ?? '');
    const stdout = String(error?.stdout ?? '');
    const details = [stderr, stdout, String(error?.message ?? '')].join('\n');

    // Never print token values. Just print actionable guidance.
    if (details.toLowerCase().includes('bad credentials') || details.includes('401')) {
      console.error('❌ all-contributors authentication failed (Bad credentials / 401).');
      console.error('💡 Set a valid token in PRIVATE_TOKEN (all-contributors-cli) and/or GH_TOKEN (gh CLI).');
      console.error('💡 In GitHub Actions, you can usually use: secrets.GITHUB_TOKEN');
      throw new Error('contributors:check failed due to invalid credentials');
    }

    console.error('❌ Error checking for missing contributors:', String(error?.message ?? error));
    if (details.trim()) {
      console.error('--- all-contributors output (truncated) ---');
      console.error(details.slice(0, 2000));
      console.error('--- end output ---');
    }

    if (String(error?.message ?? '').includes('command not found') || String(error?.message ?? '').includes('not recognized')) {
      console.error('💡 Make sure all-contributors-cli is installed: npm install all-contributors-cli');
    }

    throw error;
  }
};

// --- REPORT GENERATION LOGIC ---

/**
 * Get the current GitHub repository in owner/repo format.
 * Tries upstream first, then origin.
 * @returns {string}
 */
const getGitHubRepo = () => {
  const parseRepoFromRemoteUrl = (remoteUrl) => {
    const url = String(remoteUrl || '').trim();
    if (!url) return null;

    // Supports:
    // - git@github.com:owner/repo.git
    // - ssh://git@github.com/owner/repo.git
    // - https://github.com/owner/repo.git
    // - https://github.com/owner/repo
    const regex = /github\.com[/:]([^/]+)\/([^/?#]+?)(?:\.git)?(?:[/?#]|$)/;
    const match = regex.exec(url);
    if (!match) return null;

    return `${match[1]}/${match[2]}`;
  };

  try {
    const upstreamUrl = execSync('git config --get remote.upstream.url', {
      encoding: 'utf8',
      stdio: ['pipe', 'pipe', 'pipe']
    }).trim();
    if (upstreamUrl) {
      const repo = parseRepoFromRemoteUrl(upstreamUrl);
      if (repo) return repo;
    }
  } catch (e) {
    console.debug('upstream not found, trying origin', e?.message || e);
  }

  try {
    const originUrl = execSync('git config --get remote.origin.url', {
      encoding: 'utf8',
      stdio: ['pipe', 'pipe', 'pipe']
    }).trim();
    const repo = parseRepoFromRemoteUrl(originUrl);
    if (repo) return repo;
  } catch (e) {
    console.debug('origin not found, using default', e?.message || e);
  }

  return 'github/awesome-copilot';
};

/**
 * Fetch merged PRs for a GitHub username using the GH CLI and filter files.
 * @param {string} username
 * @param {{includeAllFiles?:boolean}} [opts]
 * @returns {Array<object>} Array of PR objects
 */
export const fetchContributorMergedPrs = (username, { includeAllFiles = false } = {}) => {
  try {
    const repo = getGitHubRepo();
    const result = execSync(
      `gh pr list --repo ${repo} --state merged --author ${username} --json number,title,mergedAt,files,url --limit 100`,
      { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: DEFAULT_CMD_TIMEOUT }
    );
    const prs = JSON.parse(result);

    if (includeAllFiles) {
      return prs;
    }

    return prs.filter(pr => {
      const hasNonConfigFiles = pr.files.some(file =>
        !isAutoGeneratedFile(file.path)
      );
      return hasNonConfigFiles;
    });
  } catch (error) {
    console.error(`Failed to fetch PRs for ${username}:`, error.message);
    return [];
  }
};

/**
 * Convert a PR object into a normalized report entry with types and file details.
 * @param {{login:string}} contributor
 * @param {object} pr
 * @param {{includeAllFiles?:boolean}} [opts]
 * @returns {object|null}
 */
const generatePRReport = (contributor, pr, { includeAllFiles = false } = {}) => {
  const types = new Set();
  const fileDetails = [];

  for (const file of pr.files) {
    if (!file?.path) {
      continue;
    }

    // Include generated files only if includeAllFiles is true
    if (!includeAllFiles && isAutoGeneratedFile(file.path)) {
      continue;
    }

    const type = getFileContributionType(file.path) || 'ideas';
    if (type) {
      types.add(type);
    }

    fileDetails.push({
      path: file.path,
      type: type || 'unknown',
      additions: file.additions,
      deletions: file.deletions
    });
  }

  // If no non-filtered files contributed to types, and we're not asked for all files, skip this PR
  if (types.size === 0 && !includeAllFiles) {
    return null;
  }

  // Fallback to 'code' if no types detected
  if (types.size === 0) {
    types.add('code');
  }

  const typeList = Array.from(types);

  return {
    prNumber: pr.number,
    prTitle: pr.title,
    prUrl: pr.url,
    mergedAt: pr.mergedAt,
    contributionTypes: typeList,
    files: fileDetails,
    commentSnippet: `@all-contributors please add @${contributor.login} for ${typeList.join(', ')}`
  };
};

/**
 * Build a contributor report by inspecting merged PRs and mapping files to types.
 * Returns null when no relevant PRs were found (unless includeAllFiles is true).
 * @param {string} username
 * @param {{includeAllFiles?:boolean}} [opts]
 * @returns {object|null}
 */
export const generateContributorReport = (username, { includeAllFiles = false } = {}) => {
  console.log(`Inspecting ${username}...`);

  const prs = fetchContributorMergedPrs(username, { includeAllFiles });
  const prReports = prs
    .map(pr => generatePRReport({ login: username }, pr, { includeAllFiles }))
    .filter(report => report !== null);

  // If no relevant PR reports and not explicitly including all files, skip the contributor entirely
  if (prReports.length === 0 && !includeAllFiles) {
    return null;
  }

  return {
    username,
    totalPRs: prs.length,
    prs: prReports
  };
};

/**
 * Render a set of contributor reports as markdown for human review.
 * @param {Array<object>} reports
 * @param {number} missingCount - number of missing contributors detected
 * @returns {string}
 */
export const generateMarkdownReport = (reports, missingCount = 0) => {
  if (!missingCount) {
    return 'No missing contributors detected.\n';
  }

  const nowIso = new Date().toISOString();

  const computeTypesArg = (report) => {
    const typeSet = new Set();
    for (const pr of report.prs || []) {
      for (const type of pr.contributionTypes || []) {
        if (type) {
          typeSet.add(type);
        }
      }
    }

    const types = Array.from(typeSet).sort((a, b) => a.localeCompare(b));
    return types.length > 0 ? types.join(',') : 'code';
  };

  const lines = [];

  lines.push(
    '# Missing Contributors Report',
    '',
    `Generated (ISO): ${nowIso}`,
    '',
    `Missing contributors: ${missingCount}`,
    ''
  );

  for (const report of reports) {
    lines.push(`## @${report.username}`);

    const prs = Array.from(report.prs || []).sort((a, b) => {
      // Prefer most recent PRs first.
      const aTime = a.mergedAt ? Date.parse(a.mergedAt) : 0;
      const bTime = b.mergedAt ? Date.parse(b.mergedAt) : 0;
      if (aTime !== bTime) return bTime - aTime;
      return (b.prNumber ?? 0) - (a.prNumber ?? 0);
    });

    if (prs.length === 0) {
      lines.push(
        '',
        '_No eligible PRs found._',
        '',
        `Alternate CLI: \`npx all-contributors add ${report.username} ${computeTypesArg(report)}\``,
        '',
        '---',
        ''
      );
      continue;
    }

    lines.push('');

    for (const pr of prs) {
      const prTypes = (pr.contributionTypes || []).filter(Boolean);
      const prTypesArg = prTypes.length > 0 ? prTypes.join(', ') : 'code';
      const title = String(pr.prTitle ?? '');
      const url = String(pr.prUrl ?? '');
      const comment = `@all-contributors please add @${report.username} for ${prTypesArg}`;

      lines.push(
        `[#${pr.prNumber}](${url}) ${title}`,
        '```plaintext',
        comment,
        '```'
      );
    }

    lines.push(
      '',
      '### Alternate CLI Command',
      '',
      '```bash',
      `npx all-contributors add ${report.username} ${computeTypesArg(report)}`,
      '```',
      '',
      '---',
      ''
    );
  }

  return `${lines.join('\n')}\n`;
};

const main = () => {
  try {
    // gh CLI can use either its own authenticated session or token env vars.
    // In CI, we commonly receive a token via PRIVATE_TOKEN.
    if (process.env.PRIVATE_TOKEN && !process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
      process.env.GITHUB_TOKEN = process.env.PRIVATE_TOKEN;
    }

    // gh prefers GH_TOKEN; if we only have GITHUB_TOKEN, make GH_TOKEN explicit.
    if (process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
      process.env.GH_TOKEN = process.env.GITHUB_TOKEN;
    }

    const args = new Set(process.argv.slice(2));
    const includeAllFiles = args.has('--include-all-pr-files');

    const contributors = getMissingContributors();
    console.log(`Inspecting ${contributors.length} missing contributors...\n`);

    const reports = [];
    for (const contributor of contributors) {
      const report = generateContributorReport(contributor, { includeAllFiles });
      reports.push(report || { username: contributor, totalPRs: 0, prs: [] });
    }

    const markdown = generateMarkdownReport(reports, contributors.length);
    const outputPath = path.join(process.cwd(), 'reports', 'contributor-report.md');
    fs.writeFileSync(outputPath, markdown);

    console.log(`Report saved to: ${outputPath}`);

  } catch (error) {
    console.error('Error generating report:', error);
    process.exit(1);
  }
};

if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
  main();
}
