#!/usr/bin/env node
/**
 * Copyright (c) Microsoft Corporation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

// @ts-check

/**
 * Validates DEPS.list against DEPS.true (generated by build_deps_true.js).
 * Reports declared dependencies in DEPS.list that are not actually used by
 * any file, according to DEPS.true.
 *
 * Usage: node utils/build_deps_true.js && node utils/validate_deps.js
 */

const fs = require('fs');
const path = require('path').posix;
const packagesDir = path.resolve(path.join(__dirname, '..', 'packages'));

const packages = new Map();
packages.set('web', packagesDir + '/web/src/');
packages.set('injected', packagesDir + '/injected/src/');
packages.set('isomorphic', packagesDir + '/isomorphic/');
packages.set('utils', packagesDir + '/utils/');
packages.set('testIsomorphic', packagesDir + '/playwright/src/isomorphic/');

let hasErrors = false;

function main() {
  const depsListFiles = [];
  findFiles(packagesDir, 'DEPS.list', depsListFiles);

  for (const depsListPath of depsListFiles.sort()) {
    const depsDir = path.dirname(depsListPath);
    const depsTruePath = path.join(depsDir, 'DEPS.true');

    // Parse DEPS.list into groups.
    const declared = parseDepsFile(depsListPath, depsDir);
    // Parse DEPS.true (actual imports). Missing file means no cross-dir deps.
    const actual = fs.existsSync(depsTruePath) ? parseDepsTrue(depsTruePath) : new Map();

    validateDirectory(depsDir, declared, actual);
  }

  if (hasErrors)
    process.exit(1);
}

/**
 * Parse a DEPS.list file into a Map<group, entry[]>.
 * Each entry is { original, resolved } where resolved is the absolute path
 * (or node_modules/... or special token).
 */
function parseDepsFile(filePath, depsDir) {
  /** @type {Map<string, Array<{original: string, resolved: string}>>} */
  const groups = new Map();
  let currentGroup = '*';
  groups.set('*', []);

  for (const line of fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean).filter(l => !l.startsWith('#'))) {
    const groupMatch = line.match(/^\[(.*)\]$/);
    if (groupMatch) {
      currentGroup = groupMatch[1];
      if (!groups.has(currentGroup))
        groups.set(currentGroup, []);
      continue;
    }

    let resolved;
    if (line === '***' || line === '**' || line === '"strict"') {
      resolved = line;
    } else if (line.startsWith('node_modules/')) {
      resolved = line;
    } else if (line.startsWith('@')) {
      resolved = line.replace(/@([\w-]+)\/(.*)/, (_, arg1, arg2) => {
        const base = packages.get(arg1);
        return base ? base + arg2 : '<unknown>/' + arg1 + '/' + arg2;
      });
    } else {
      resolved = path.resolve(depsDir, line);
    }

    groups.get(currentGroup).push({ original: line, resolved });
  }
  return groups;
}

/**
 * Parse a DEPS.true file into a Map<fileName, Set<importPath>>.
 * Import paths are relative to the DEPS.true directory (same as they appear in the file).
 */
function parseDepsTrue(filePath) {
  /** @type {Map<string, Set<string>>} */
  const files = new Map();
  let currentFile = null;

  for (const line of fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean)) {
    const groupMatch = line.match(/^\[(.*)\]$/);
    if (groupMatch) {
      currentFile = groupMatch[1];
      files.set(currentFile, new Set());
      continue;
    }
    if (currentFile)
      files.get(currentFile).add(line);
  }
  return files;
}

/**
 * Check each entry in DEPS.list: is it matched by at least one actual import
 * from a file the entry governs?
 */
function validateDirectory(depsDir, declared, actual) {
  // Collect all actual import paths (resolved to absolute) for files governed by each group.
  // [*] group governs all files; [file.ts] governs only that file.
  const errors = [];

  for (const [group, entries] of declared) {
    // Skip groups with wildcard — everything is allowed.
    if (entries.some(e => e.resolved === '***' || e.resolved === '**'))
      continue;

    // Determine which files this group governs.
    /** @type {Set<string>} absolute import paths from governed files */
    const governedImports = new Set();
    if (group === '*') {
      // All files in the directory.
      for (const [, imports] of actual) {
        for (const imp of imports)
          governedImports.add(imp);
      }
    } else {
      // Specific file.
      const fileImports = actual.get(group);
      if (fileImports) {
        for (const imp of fileImports)
          governedImports.add(imp);
      }
    }

    for (const entry of entries) {
      if (entry.resolved === '"strict"')
        continue;
      if (!isEntryUsed(entry, governedImports, depsDir))
        errors.push(`  Unused: '${entry.original}' in [${group}]`);
    }
  }

  if (errors.length) {
    hasErrors = true;
    const rel = path.relative(packagesDir, path.join(depsDir, 'DEPS.list'));
    console.log(rel + ':');
    for (const error of errors)
      console.log(error);
  }
}

/**
 * Check if a DEPS.list entry is matched by at least one actual import.
 * The entry.resolved is an absolute path (possibly with ** glob suffix),
 * or a node_modules/ specifier.
 * governedImports are relative paths from DEPS.true (relative to depsDir).
 */
function isEntryUsed(entry, governedImports, depsDir) {
  const { resolved } = entry;

  for (const imp of governedImports) {
    const absImp = imp.startsWith('node_modules/') ? imp : path.resolve(depsDir, imp);

    if (resolved.startsWith('node_modules/')) {
      if (absImp === resolved || absImp.startsWith(resolved + '/'))
        return true;
      continue;
    }

    // Glob: foo/** matches anything under foo/.
    if (resolved.endsWith('**')) {
      const parent = resolved.substring(0, resolved.length - 2);
      if (absImp.startsWith(parent))
        return true;
      continue;
    }

    // Directory dep (e.g., ./codegen/ or ../protocol/): matches imports whose
    // directory is at or under the resolved path.
    if (isDirectory(resolved)) {
      if (absImp.startsWith(resolved + '/') || absImp === resolved)
        return true;
      continue;
    }

    // Exact file match.
    if (absImp === resolved)
      return true;
    // Match without extension.
    if (absImp === resolved + '.ts' || absImp === resolved + '.tsx' || absImp === resolved + '.d.ts')
      return true;
    // The dep might point to a directory (with index.ts) that we resolved as a file.
    const impDir = path.dirname(absImp);
    if (impDir === resolved)
      return true;
  }
  return false;
}

function isDirectory(p) {
  return fs.existsSync(p) && fs.statSync(p).isDirectory();
}

function findFiles(dir, name, result) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.resolve(dir, entry.name);
    if (entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== 'bundles')
      findFiles(full, name, result);
    else if (entry.name === name)
      result.push(full);
  }
}

main();
