/// <reference types="node" />

import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, relative, sep } from 'node:path';
import { dirname as projectDirname, normalize as normalizeProjectPath } from 'node:path/posix';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

type Layer = 'api' | 'business' | 'view' | 'other';

interface SourceFile {
  readonly absolutePath: string;
  readonly projectPath: string;
  readonly layer: Layer;
  readonly source: string;
}

interface ImportReference {
  readonly specifier: string;
  readonly projectPath: string | null;
}

const sourceRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const forbiddenDataSources = ['lib/db.ts', 'lib/db', 'lib/backendOnlyDataSource.ts', 'lib/backendOnlyDataSource'];
const sourceFileExtensions = ['.ts', '.tsx'];
const importPattern =
  /(?:import|export)\s+(?:type\s+)?(?:[\s\S]*?\sfrom\s+)?['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g;
const directFetchPattern = /\bfetch\s*\(/;

function toProjectPath(absolutePath: string): string {
  return relative(sourceRoot, absolutePath).split(sep).join('/');
}

function isSourceFile(fileName: string): boolean {
  return sourceFileExtensions.some((extension) => fileName.endsWith(extension));
}

function isTestFile(projectPath: string): boolean {
  return projectPath.endsWith('.test.ts') || projectPath.endsWith('.test.tsx');
}

function getLayer(projectPath: string): Layer {
  if (projectPath.startsWith('shared/api/')) {
    return 'api';
  }

  if (projectPath.startsWith('business/') || projectPath.startsWith('shared/business/')) {
    return 'business';
  }

  if (
    projectPath.startsWith('components/') ||
    projectPath.startsWith('pages/') ||
    projectPath.startsWith('contexts/') ||
    projectPath.startsWith('hooks/') ||
    projectPath === 'App.tsx' ||
    projectPath === 'main.tsx'
  ) {
    return 'view';
  }

  return 'other';
}

function listSourceFiles(directory: string): readonly SourceFile[] {
  return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
    const absolutePath = join(directory, entry.name);

    if (entry.isDirectory()) {
      return listSourceFiles(absolutePath);
    }

    if (!entry.isFile() || !isSourceFile(entry.name)) {
      return [];
    }

    const projectPath = toProjectPath(absolutePath);

    if (isTestFile(projectPath)) {
      return [];
    }

    return [
      {
        absolutePath,
        projectPath,
        layer: getLayer(projectPath),
        source: readFileSync(absolutePath, 'utf8'),
      },
    ];
  });
}

function resolveProjectImport(importerProjectPath: string, specifier: string): string | null {
  if (specifier.startsWith('@/')) {
    return specifier.slice(2);
  }

  if (!specifier.startsWith('.')) {
    return null;
  }

  return normalizeProjectPath(`${projectDirname(importerProjectPath)}/${specifier}`);
}

function readImports(sourceFile: SourceFile): readonly ImportReference[] {
  const imports: ImportReference[] = [];
  const matches = sourceFile.source.matchAll(importPattern);

  for (const match of matches) {
    const specifier = match[1] ?? match[2];

    if (specifier === undefined) {
      continue;
    }

    imports.push({
      specifier,
      projectPath: resolveProjectImport(sourceFile.projectPath, specifier),
    });
  }

  return imports;
}

function formatViolation(sourceFile: SourceFile, importReference: ImportReference): string {
  return `${sourceFile.projectPath} -> ${importReference.specifier}`;
}

function matchesProjectPrefix(projectPath: string, prefix: string): boolean {
  return projectPath === prefix || projectPath.startsWith(`${prefix}/`);
}

function collectViolations(
  sourceFiles: readonly SourceFile[],
  isSourceInScope: (sourceFile: SourceFile) => boolean,
  isForbiddenImport: (importReference: ImportReference) => boolean,
): readonly string[] {
  return sourceFiles
    .filter(isSourceInScope)
    .flatMap((sourceFile) =>
      readImports(sourceFile)
        .filter(isForbiddenImport)
        .map((importReference) => formatViolation(sourceFile, importReference)),
    )
    .sort();
}

function listProjectPaths(directory: string): readonly string[] {
  return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
    const absolutePath = join(directory, entry.name);

    if (entry.isDirectory()) {
      return listProjectPaths(absolutePath);
    }

    if (!entry.isFile()) {
      return [];
    }

    return [toProjectPath(absolutePath)];
  });
}

describe('frontend import boundaries', () => {
  const sourceFiles = listSourceFiles(sourceRoot);

  it('keeps API modules independent from business and view layers', () => {
    const violations = collectViolations(
      sourceFiles,
      (sourceFile) => sourceFile.layer === 'api',
      (importReference) => {
        if (importReference.projectPath === null) {
          return false;
        }

        const importedLayer = getLayer(importReference.projectPath);
        return importedLayer === 'business' || importedLayer === 'view';
      },
    );

    expect(violations).toEqual([]);
  });

  it('keeps business modules independent from view modules', () => {
    const violations = collectViolations(
      sourceFiles,
      (sourceFile) => sourceFile.layer === 'business',
      (importReference) => {
        if (importReference.projectPath === null) {
          return false;
        }

        return getLayer(importReference.projectPath) === 'view';
      },
    );

    expect(violations).toEqual([]);
  });

  it('keeps view modules behind business hooks instead of direct API calls', () => {
    const violations = collectViolations(
      sourceFiles,
      (sourceFile) => sourceFile.layer === 'view',
      (importReference) =>
        importReference.projectPath !== null && matchesProjectPrefix(importReference.projectPath, 'shared/api'),
    );

    expect(violations).toEqual([]);
  });

  it('keeps removed temporary data-source modules out of the active frontend', () => {
    const existingForbiddenFiles = forbiddenDataSources
      .filter((projectPath) => projectPath.endsWith('.ts'))
      .filter((projectPath) => existsSync(join(sourceRoot, projectPath)));
    const importViolations = collectViolations(
      sourceFiles,
      () => true,
      (importReference) => {
        if (importReference.projectPath === null) {
          return false;
        }

        const importedProjectPath = importReference.projectPath;

        return forbiddenDataSources.some((projectPath) => matchesProjectPrefix(importedProjectPath, projectPath));
      },
    );

    expect([...existingForbiddenFiles, ...importViolations].sort()).toEqual([]);
  });

  it('keeps source imports on the configured @ alias instead of relative paths', () => {
    const violations = sourceFiles
      .flatMap((sourceFile) =>
        readImports(sourceFile)
          .filter((importReference) => importReference.specifier.startsWith('.'))
          .map((importReference) => formatViolation(sourceFile, importReference)),
      )
      .sort();

    expect(violations).toEqual([]);
  });

  it('keeps every shared API wrapper covered by a colocated contract test', () => {
    const sharedApiPaths = listProjectPaths(join(sourceRoot, 'shared', 'api'));
    const missingTestFiles = sharedApiPaths
      .filter((projectPath) => projectPath.endsWith('.ts'))
      .filter((projectPath) => !isTestFile(projectPath))
      .map((projectPath) => projectPath.replace(/\.ts$/, '.test.ts'))
      .filter((testProjectPath) => !sharedApiPaths.includes(testProjectPath));

    expect(missingTestFiles).toEqual([]);
  });

  it('keeps direct network calls centralized in the shared HTTP client', () => {
    const allowedFetchFiles = ['shared/api/httpClient.ts'];
    const violations = sourceFiles
      .filter((sourceFile) => directFetchPattern.test(sourceFile.source))
      .map((sourceFile) => sourceFile.projectPath)
      .filter((projectPath) => !allowedFetchFiles.includes(projectPath))
      .sort();

    expect(violations).toEqual([]);
  });
});
