// src/core/require/loader.ts
// Deterministic package loader with dependency DAG and idempotent require semantics.

import * as fs from "fs";
import * as path from "path";
import { PackageRegistry, parseManifestFile, type PackageManifest } from "./manifest";
import { RequireResolver, type ResolvedPackage } from "./resolver";

export interface LoadedPackage {
  name: string;
  rootDir: string;
  lispEntry: string | null;
  manifestPath: string;
  manifest: PackageManifest;
  resolved: ResolvedPackage | null;
  dependencies: string[];
}

export interface PackageLoaderHooks {
  onManifest?: (manifest: PackageManifest, context: { rootDir: string; resolved: ResolvedPackage }) => Promise<void> | void;
  onPackageLoaded?: (loaded: LoadedPackage) => Promise<void> | void;
}

export interface PackageLoaderOptions {
  resolver?: RequireResolver;
  registry?: PackageRegistry;
  hooks?: PackageLoaderHooks;
}

export class PackageLoader {
  readonly resolver: RequireResolver;
  readonly registry: PackageRegistry;

  private readonly hooks: PackageLoaderHooks;
  private readonly loaded = new Map<string, LoadedPackage>();
  private readonly loading = new Set<string>();

  constructor(options: PackageLoaderOptions = {}) {
    this.resolver = options.resolver ?? new RequireResolver();
    this.registry = options.registry ?? new PackageRegistry();
    this.hooks = options.hooks ?? {};
  }

  async load(packageName: string): Promise<LoadedPackage> {
    const cleaned = packageName.replace(/'/g, "").trim();
    return this.loadRecursive(cleaned, []);
  }

  isLoaded(packageName: string): boolean {
    const cleaned = packageName.replace(/'/g, "").trim();
    return this.loaded.has(cleaned);
  }

  getLoaded(packageName: string): LoadedPackage | undefined {
    const cleaned = packageName.replace(/'/g, "").trim();
    return this.loaded.get(cleaned);
  }

  getLoadedPackages(): LoadedPackage[] {
    return Array.from(this.loaded.values()).sort((a, b) => a.name.localeCompare(b.name));
  }

  getLoadedInOrder(): LoadedPackage[] {
    return Array.from(this.loaded.values());
  }

  clear(): void {
    this.loaded.clear();
    this.loading.clear();
  }

  private async loadRecursive(packageName: string, stack: string[]): Promise<LoadedPackage> {
    const alreadyLoaded = this.loaded.get(packageName);
    if (alreadyLoaded) {
      return alreadyLoaded;
    }

    if (this.loading.has(packageName)) {
      const cycleStart = stack.indexOf(packageName);
      const cycle = (cycleStart >= 0 ? stack.slice(cycleStart) : stack).concat([packageName]);
      throw new Error(`Circular package dependency: ${cycle.join(" -> ")}`);
    }

    let resolved = this.resolver.resolve(packageName);

    const inferredRootDir = resolved ? inferPackageRoot(resolved.filePath) : null;
    const manifestPath =
      (resolved && inferredRootDir
        ? resolveManifestPath(packageName, inferredRootDir, resolved)
        : null) ??
      resolveManifestPathFromSearch(packageName, this.resolver);

    if (!manifestPath) {
      throw new Error(`Package not found: ${packageName}`);
    }
    const rootDir = path.dirname(manifestPath);

    if (!resolved) {
      resolved = createSyntheticResolution(packageName, rootDir);
    }

    this.loading.add(packageName);

    try {
      const manifest = parseManifestFile(manifestPath);

      const manifestOwnsRequest =
        manifest.name === packageName ||
        packageName.startsWith(`${manifest.name}/`);

      if (!manifestOwnsRequest) {
        throw new Error(
          `Manifest name mismatch for ${packageName}: found ${manifest.name} in ${manifestPath}`,
        );
      }

      await this.hooks.onManifest?.(manifest, { rootDir, resolved });

      for (const dependency of manifest.requires) {
        await this.loadRecursive(dependency, [...stack, packageName]);
      }

      this.registry.register(manifest);

      const loaded: LoadedPackage = {
        name: packageName,
        rootDir,
        lispEntry: fs.existsSync(resolved.filePath) ? resolved.filePath : null,
        manifestPath,
        manifest,
        resolved,
        dependencies: [...manifest.requires],
      };

      this.loaded.set(packageName, loaded);
      await this.hooks.onPackageLoaded?.(loaded);
      return loaded;
    } finally {
      this.loading.delete(packageName);
    }
  }
}

function inferPackageRoot(filePath: string): string {
  const normalized = path.normalize(filePath);
  const parts = normalized.split(path.sep);
  const libIndex = parts.lastIndexOf("lib");

  if (libIndex > 0) {
    return parts.slice(0, libIndex).join(path.sep);
  }

  return path.dirname(normalized);
}

function resolveManifestPath(
  packageName: string,
  inferredRootDir: string,
  resolved: ResolvedPackage,
): string | null {
  const direct = path.join(inferredRootDir, "manifest.json");
  if (fs.existsSync(direct)) {
    return direct;
  }

  // Legacy lisp loading may resolve from <project>/lib/<pkg>.lisp while
  // manifests already live under <project> + packages + <pkg> + manifest.json.
  const projectRoot = inferProjectRoot(resolved);
  if (projectRoot) {
    const packageManifest = path.join(projectRoot, "packages", packageName, "manifest.json");
    if (fs.existsSync(packageManifest)) {
      return packageManifest;
    }
  }

  return null;
}

function inferProjectRoot(resolved: ResolvedPackage): string | null {
  const normalizedSearch = path.normalize(resolved.searchPath);
  const parts = normalizedSearch.split(path.sep);

  const libIndex = parts.lastIndexOf("lib");
  if (libIndex > 0) {
    return parts.slice(0, libIndex).join(path.sep);
  }

  const packagesIndex = parts.lastIndexOf("packages");
  if (packagesIndex > 0) {
    return parts.slice(0, packagesIndex).join(path.sep);
  }

  if (resolved.source === "local") {
    return path.dirname(normalizedSearch);
  }

  return null;
}

function resolveManifestPathFromSearch(packageName: string, resolver: RequireResolver): string | null {
  const searchPaths = resolver.getSearchPaths();

  for (const search of searchPaths) {
    const candidates = [
      path.join(search.path, packageName, "manifest.json"),
      path.join(search.path, "packages", packageName, "manifest.json"),
    ];

    for (const candidate of candidates) {
      if (fs.existsSync(candidate)) {
        return candidate;
      }
    }
  }

  return null;
}

function createSyntheticResolution(packageName: string, rootDir: string): ResolvedPackage {
  const leaf = packageName.split("/").filter(Boolean).pop() ?? packageName;
  return {
    packageName,
    filePath: path.join(rootDir, "lib", `${leaf}.lisp`),
    source: "local",
    searchPath: rootDir,
  };
}
