Source: bin/commands/stats.js

import fs from 'fs';
import path from 'path';
import ComponentParser from '../../lib/compiler/ComponentParser.js';
import StyleProcessor from '../../lib/compiler/StyleProcessor.js';
import { AtlasNodeKind } from '../../lib/compiler/atlas/AppModel.js';
import { buildModel } from './atlas.js';
import { bold, cyan, green, yellow, gray } from '../colors.js';

/**
 * Formats byte counts into human readable strings (e.g. 500 B, 1.25 KB, 2.10 MB).
 * @param {number} bytes - Size in bytes.
 * @returns {string} Formatted size string.
 */
export function formatBytes(bytes) {
  if (bytes === 0 || isNaN(bytes)) return '0 B';
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}

/**
 * Converts a string to PascalCase.
 * @param {string} str
 * @returns {string}
 */
function toPascalCase(str) {
  if (!str) return '';
  return str
    .replace(/[-_.]+/g, ' ')
    .split(' ')
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join('');
}

/**
 * Recursively gets all files in a directory.
 * @param {string} dir
 * @returns {string[]}
 */
function getAllFiles(dir) {
  if (!fs.existsSync(dir)) return [];
  const results = [];
  const entries = fs.readdirSync(dir, { withFileTypes: true });
  for (const entry of entries) {
    const fullPath = path.join(dir, entry.name);
    if (entry.isDirectory()) {
      results.push(...getAllFiles(fullPath));
    } else if (entry.isFile()) {
      results.push(fullPath);
    }
  }
  return results;
}

/**
 * Extracts raw template content from component source code.
 * @param {string} content
 * @returns {string}
 */
function extractRawTemplate(content) {
  if (!content) return '';
  return content
    .replace(/<!--[\s\S]*?-->/g, '')
    .replace(/<state.*? \/>/g, '')
    .replace(/<computed.*? \/>/g, '')
    .replace(/<action.*?>[\s\S]*?<\/action>/g, '')
    .replace(/<resource.*?>[\s\S]*?<\/resource>/g, '')
    .replace(/<resource\s+[\s\S]*?\/>/gi, '')
    .trim();
}

/**
 * Reads what each unit declares from the compiler's semantic model.
 *
 * Keyed by file path rather than by name: `stats` derives a display name from
 * whatever `class X` a file contains, while the compiler names a unit after
 * its file. Those disagree often enough that matching on them would silently
 * report zero. The path is the same fact on both sides.
 *
 * Falls back to per-file parsing when the model cannot be built — `stats` is a
 * footprint report and should still produce byte sizes for a project that does
 * not currently compile.
 * @param {object} cli - The AvenxCLI instance.
 * @returns {{available: boolean, stateCounts: Map<string, number>}} Declared
 *   state counts by root-relative file path.
 */
function collectSemantics(cli) {
  /** @type {Map<string, number>} */
  const stateCounts = new Map();
  try {
    const model = buildModel(cli);
    if (model.errors.length > 0) {
      return { available: false, stateCounts };
    }
    for (const node of model.nodes.values()) {
      if (node.kind !== AtlasNodeKind.STATE || !node.owner) continue;
      const owner = model.getNode(node.owner);
      if (!owner || !owner.file) continue;
      stateCounts.set(owner.file, (stateCounts.get(owner.file) || 0) + 1);
    }
    return { available: true, stateCounts };
  } catch {
    return { available: false, stateCounts };
  }
}

/**
 * Scans the project source directory and collects component, page, bridge, and guard metrics.
 * @param {object} cli - AvenxCLI instance containing baseDir and config.
 * @returns {object} Analysis result containing summary and item metrics.
 */
export function analyzeStats(cli) {
  const srcRel = (cli.config && cli.config.srcDir) || 'src';
  const rootDir = cli.baseDir || process.cwd();
  const srcDir = path.join(rootDir, srcRel);

  const allFiles = getAllFiles(srcDir);
  // ComponentParser's first argument is a StyleProcessor. It used to be handed
  // the config object, so every styleProcessor.process() call threw into the
  // catch below and scoped CSS was silently reported as zero bytes.
  const parser = new ComponentParser(new StyleProcessor((cli.config && cli.config.style) || {}, cli.config), (cli.config && cli.config.voidTags) || [], cli.config);

  // Byte sizes have to be measured from the files. What each file *is*, and
  // what it declares, is something the compiler already knows, so those come
  // from the model rather than from a second set of regexes.
  const semantics = collectSemantics(cli);

  const items = [];
  let totalFiles = 0;
  let totalComponents = 0;
  let totalPages = 0;
  let totalBridges = 0;
  let totalGuards = 0;

  let totalFileSizeBytes = 0;
  let totalRawTemplateBytes = 0;
  let totalCompiledTemplateBytes = 0;
  let totalScopedCssBytes = 0;
  let totalStateProps = 0;

  for (const file of allFiles) {
    if (!file.endsWith('.js') && !file.endsWith('.ts')) continue;

    const relPath = path.relative(rootDir, file).split(path.sep).join('/');
    const filename = path.basename(file);
    const stat = fs.statSync(file);
    const fileSizeBytes = stat.size;

    const isPage = file.includes(`${path.sep}pages${path.sep}`) || filename.endsWith('.page.js');
    const isBridge =
      file.includes(`${path.sep}bridges${path.sep}`) ||
      file.includes(`${path.sep}global${path.sep}`) ||
      filename.endsWith('.bridge.js');
    const isGuard = file.includes(`${path.sep}guards${path.sep}`) || filename.endsWith('.guard.js');
    const isComponent =
      !isPage &&
      !isBridge &&
      !isGuard &&
      (file.includes(`${path.sep}components${path.sep}`) || filename.endsWith('.component.js'));

    let type = 'other';
    if (isComponent) type = 'component';
    else if (isPage) type = 'page';
    else if (isBridge) type = 'bridge';
    else if (isGuard) type = 'guard';

    const content = fs.readFileSync(file, 'utf-8');
    const classMatch = content.match(/(?:export\s+(?:default\s+)?)?class\s+([A-Za-z0-9_$]+)/);
    let name;
    if (classMatch) {
      name = classMatch[1];
    } else {
      const base = filename
        .replace(/\.component\.js$/, '')
        .replace(/\.page\.js$/, '')
        .replace(/\.bridge\.js$/, '')
        .replace(/\.guard\.js$/, '')
        .replace(/\.js$/, '');
      name = toPascalCase(base);
    }

    let statePropsCount = semantics.stateCounts.get(relPath) || 0;
    let rawTemplateBytes = 0;
    let compiledTemplateBytes = 0;
    let scopedCssBytes = 0;

    try {
      const state = parser.expressionParser.parseState(content);
      if (!semantics.available) {
        statePropsCount = Object.keys(state || {}).length;
      }

      if (isComponent || isPage) {
        // 2. Raw template extraction & byte size
        const rawTpl = extractRawTemplate(content);
        rawTemplateBytes = Buffer.byteLength(rawTpl, 'utf8');

        // 3. Compiled template extraction & byte size.
        //
        // The declarations have to be read before the template is transformed,
        // even though only its size is wanted here. `extractTemplate` validates
        // every identifier a binding names against the state, computed values
        // and actions it is given, so calling it with empty maps reported an
        // action the component plainly declares as undeclared -- `avenx stats`
        // printed AVX_W03 for a file `avenx build` and `avenx check` both
        // accepted. Passing what the file actually declares costs one extra
        // parse of a string already in memory.
        const computed = parser.extractComputed(content);
        const methods = parser.extractMethods(content, name, file);
        const resources = parser.expressionParser.parseResources(content);
        const compiledTpl = parser.extractTemplate(content, {}, name, file, state, computed, methods, resources);
        compiledTemplateBytes = Buffer.byteLength(compiledTpl || '', 'utf8');

        // 4. Scoped CSS extraction & byte size
        let rawCss = '';
        const styleMatch = content.match(/<style[\s\S]*?>([\s\S]*?)<\/style>/i);
        if (styleMatch) {
          rawCss = styleMatch[1];
        }
        const cssFile = file.replace(/\.(component|page)\.js$/, '.$1.css');
        if (fs.existsSync(cssFile)) {
          rawCss += '\n' + fs.readFileSync(cssFile, 'utf-8');
        }
        if (rawCss.trim()) {
          const scopedCss = parser.styleProcessor.process(rawCss, {}, name, '');
          scopedCssBytes = Buffer.byteLength(scopedCss || '', 'utf8');
        }
      }
    } catch {
      // Fallback on parse failure
    }

    totalFiles++;
    if (isComponent) totalComponents++;
    else if (isPage) totalPages++;
    else if (isBridge) totalBridges++;
    else if (isGuard) totalGuards++;

    totalFileSizeBytes += fileSizeBytes;
    totalRawTemplateBytes += rawTemplateBytes;
    totalCompiledTemplateBytes += compiledTemplateBytes;
    totalScopedCssBytes += scopedCssBytes;
    totalStateProps += statePropsCount;

    items.push({
      name,
      type,
      file: relPath,
      fileSizeBytes,
      rawTemplateBytes,
      compiledTemplateBytes,
      scopedCssBytes,
      statePropsCount,
    });
  }

  items.sort((a, b) => a.file.localeCompare(b.file));

  let templateReductionPercent = 0;
  if (totalRawTemplateBytes > 0) {
    const diff = totalRawTemplateBytes - totalCompiledTemplateBytes;
    templateReductionPercent = Number(((diff / totalRawTemplateBytes) * 100).toFixed(1));
  }

  return {
    summary: {
      totalFiles,
      totalComponents,
      totalPages,
      totalBridges,
      totalGuards,
      totalFileSizeBytes,
      totalRawTemplateBytes,
      totalCompiledTemplateBytes,
      totalScopedCssBytes,
      totalStateProps,
      templateReductionPercent,
    },
    items,
  };
}

/**
 * Runs the `avenx stats` CLI command to output footprint metrics in text or JSON format.
 * @param {object} cli - AvenxCLI instance.
 * @param {string[]} [args] - Command arguments.
 */
export function runStats(cli, args = []) {
  const isJson = args.includes('--json') || args.includes('-j');
  const statsData = analyzeStats(cli);

  if (isJson) {
    console.log(JSON.stringify(statsData, null, 2));
    return;
  }

  const { summary, items } = statsData;
  const srcRel = (cli.config && cli.config.srcDir) || 'src';

  console.log(bold(cyan(`📊 Avenx Component & Bundle Footprint Metrics (${srcRel}/)`)));
  console.log(gray(`Project: ${cli.baseDir}\n`));

  if (items.length === 0) {
    console.log(gray('No source files found in ') + cyan(`${srcRel}/`) + gray('.'));
    return;
  }

  // Print Items Table
  console.log(
    bold(
      [
        'Name'.padEnd(25),
        'Type'.padEnd(12),
        'File Size'.padStart(10),
        'Raw Tpl'.padStart(10),
        'Comp Tpl'.padStart(10),
        'CSS Size'.padStart(10),
        'State'.padStart(7),
      ].join('  '),
    ),
  );
  console.log(gray('─'.repeat(85)));

  for (const item of items) {
    const fileStr = formatBytes(item.fileSizeBytes).padStart(10);
    const rawStr = item.rawTemplateBytes > 0 ? formatBytes(item.rawTemplateBytes).padStart(10) : gray('-'.padStart(10));
    const compStr =
      item.compiledTemplateBytes > 0 ? formatBytes(item.compiledTemplateBytes).padStart(10) : gray('-'.padStart(10));
    const cssStr = item.scopedCssBytes > 0 ? formatBytes(item.scopedCssBytes).padStart(10) : gray('-'.padStart(10));
    const stateStr = String(item.statePropsCount).padStart(7);

    const typeStr =
      item.type === 'component'
        ? green('Component'.padEnd(12))
        : item.type === 'page'
          ? cyan('Page'.padEnd(12))
          : item.type === 'bridge'
            ? yellow('Bridge'.padEnd(12))
            : gray(item.type.padEnd(12));

    console.log(
      `${item.name.padEnd(25)}  ${typeStr}  ${fileStr}  ${rawStr}  ${compStr}  ${cssStr}  ${stateStr}`,
    );
  }

  console.log(gray('─'.repeat(85)));

  // Summary Totals
  console.log(`\n${bold(cyan('Summary Totals:'))}`);
  console.log(`  ${gray('Total Files:')}            ${bold(String(summary.totalFiles))}`);
  console.log(
    `  ${gray('  Components:')}         ${green(String(summary.totalComponents))}  |  ${gray('Pages:')} ${cyan(String(summary.totalPages))}  |  ${gray('Bridges:')} ${yellow(String(summary.totalBridges))}`,
  );
  console.log(`  ${gray('Total Source Size:')}      ${bold(formatBytes(summary.totalFileSizeBytes))}`);
  console.log(`  ${gray('Raw Template Payload:')}   ${formatBytes(summary.totalRawTemplateBytes)}`);
  console.log(
    `  ${gray('Compiled Template:')}      ${formatBytes(summary.totalCompiledTemplateBytes)} (${summary.templateReductionPercent >= 0 ? `-${summary.templateReductionPercent}%` : `+${Math.abs(summary.templateReductionPercent)}%`})`,
  );
  console.log(`  ${gray('Scoped CSS Payload:')}     ${formatBytes(summary.totalScopedCssBytes)}`);
  console.log(`  ${gray('State Properties:')}       ${summary.totalStateProps}\n`);
}