import { readFile } from "node:fs/promises";
import path from "node:path";

import { type ASTNode, parseModule } from "magicast";

import type { FrameworkBuildType } from "./config/frameworks.ts";
import {
  type PackageManifest,
  readBuildScript,
  readPackageManifest,
  resolvePackageManager,
} from "./workspace.ts";

/**
 * The build command and output directory for a framework build, with a
 * human-readable source for each value (for display by a consumer). Resolving
 * settings is location-agnostic: package manager and binaries come from the
 * app's workspace, not the current working directory.
 */
export interface BuildSettings {
  buildCommand: string | null;
  buildCommandSource: string | null;
  outputDirectory: string;
  outputDirectorySource: string | null;
  entrypoint?: string;
  entrypointSource?: string | null;
}

/**
 * Resolves build settings from framework inference: the user's
 * `package.json` build script (run via the detected package manager) over the
 * framework default, plus the framework's output directory (reading
 * `next.config` `distDir` for Next.js).
 */
export async function resolveBuildSettings(options: {
  appPath: string;
  buildType: FrameworkBuildType;
  signal?: AbortSignal;
}): Promise<BuildSettings> {
  switch (options.buildType) {
    // Nuxt and Astro run their framework CLI and stage fixed output; these
    // settings only describe that for display.
    case "nuxt":
      return {
        buildCommand: "nuxt build",
        buildCommandSource: "Nuxt default",
        outputDirectory: ".output",
        outputDirectorySource: "Nuxt output",
      };
    case "astro":
      return {
        buildCommand: "astro build",
        buildCommandSource: "Astro default",
        outputDirectory: "dist",
        outputDirectorySource: "Astro output",
      };
    case "nextjs": {
      const manifest = await readPackageManifest(
        options.appPath,
        options.signal,
      );
      const buildCommand = await resolveFrameworkBuildCommand(
        options.appPath,
        manifest,
        { command: "next build", source: "Next.js default" },
        options.signal,
      );
      const outputRoot = await resolveNextOutputRoot(
        options.appPath,
        options.signal,
      );
      return {
        buildCommand: buildCommand.command,
        buildCommandSource: buildCommand.source,
        outputDirectory: joinPosix(outputRoot, "standalone"),
        outputDirectorySource:
          outputRoot === ".next" ? "Next.js output" : "next.config distDir",
      };
    }
    case "nestjs": {
      const manifest = await readPackageManifest(
        options.appPath,
        options.signal,
      );
      const buildCommand = await resolveFrameworkBuildCommand(
        options.appPath,
        manifest,
        { command: "nest build", source: "NestJS default" },
        options.signal,
      );
      return {
        buildCommand: buildCommand.command,
        buildCommandSource: buildCommand.source,
        outputDirectory: "dist",
        outputDirectorySource: "NestJS output",
      };
    }
    case "tanstack-start": {
      const manifest = await readPackageManifest(
        options.appPath,
        options.signal,
      );
      const buildCommand = await resolveFrameworkBuildCommand(
        options.appPath,
        manifest,
        { command: "vite build", source: "TanStack Start default" },
        options.signal,
      );
      return {
        buildCommand: buildCommand.command,
        buildCommandSource: buildCommand.source,
        outputDirectory: ".output",
        outputDirectorySource: "TanStack Start output",
      };
    }
    case "custom":
      return {
        buildCommand: null,
        buildCommandSource: null,
        outputDirectory: ".",
        outputDirectorySource: "app root",
      };
    case "bun": {
      const manifest = await readPackageManifest(
        options.appPath,
        options.signal,
      );
      const buildCommand = await resolveFrameworkBuildCommand(
        options.appPath,
        manifest,
        { command: null, source: null },
        options.signal,
      );
      return {
        buildCommand: buildCommand.command,
        buildCommandSource: buildCommand.source,
        outputDirectory: ".",
        outputDirectorySource: "app root",
      };
    }
  }
}

/**
 * Build settings when committed config owns them: configured fields win,
 * omitted fields fall back to framework inference. `source` labels configured
 * values with the config filename so a consumer can show provenance.
 */
export async function resolveConfiguredBuildSettings(options: {
  appPath: string;
  buildType: FrameworkBuildType;
  configured: {
    command: string | null | undefined;
    outputDirectory: string | undefined;
    entrypoint?: string | undefined;
  };
  /** Label for configured values, e.g. the config file basename. */
  source: string;
  signal?: AbortSignal;
}): Promise<BuildSettings> {
  const needsFallback =
    options.configured.command === undefined ||
    options.configured.outputDirectory === undefined;
  const fallback = needsFallback ? await resolveBuildSettings(options) : null;

  return {
    buildCommand:
      options.configured.command !== undefined
        ? options.configured.command
        : (fallback as BuildSettings).buildCommand,
    buildCommandSource:
      options.configured.command !== undefined
        ? options.source
        : (fallback as BuildSettings).buildCommandSource,
    outputDirectory:
      options.configured.outputDirectory ??
      (fallback as BuildSettings).outputDirectory,
    outputDirectorySource:
      options.configured.outputDirectory !== undefined
        ? options.source
        : (fallback as BuildSettings).outputDirectorySource,
    ...(options.configured.entrypoint !== undefined
      ? {
          entrypoint: options.configured.entrypoint,
          entrypointSource: options.source,
        }
      : {}),
  };
}

async function resolveFrameworkBuildCommand(
  appPath: string,
  manifest: PackageManifest | null,
  fallback: { command: string | null; source: string | null },
  signal?: AbortSignal,
): Promise<{ command: string | null; source: string | null }> {
  const buildScript = readBuildScript(manifest);
  if (buildScript) {
    const packageManager = await resolvePackageManager(appPath, signal);
    return {
      command: packageManager ? `${packageManager} run build` : buildScript,
      source: "package.json scripts.build",
    };
  }
  return fallback;
}

/** Joins posix-style path segments, collapsing duplicate separators. */
export function joinPosix(...parts: string[]): string {
  return parts.join("/").replace(/\/+/g, "/");
}

/**
 * Maps a Next.js standalone output directory (e.g. ".next/standalone") back to
 * its output root (".next"), so static assets can be staged alongside it.
 */
export function nextOutputRootFromStandaloneDirectory(
  outputDirectory: string,
): string {
  const normalized = outputDirectory.replace(/\/+$/g, "");
  if (normalized === "standalone") {
    return ".";
  }
  if (normalized.endsWith("/standalone")) {
    const outputRoot = normalized.slice(0, -"/standalone".length);
    return outputRoot.length > 0 ? outputRoot : ".";
  }
  const dirname = path.posix.dirname(normalized);
  return dirname === "." ? "." : dirname;
}

interface StaticNextConfig {
  distDir?: string;
  output?: "standalone" | "export";
}

const NEXT_CONFIG_FILENAMES = [
  "next.config.js",
  "next.config.mjs",
  "next.config.ts",
  "next.config.mts",
] as const;

async function resolveNextOutputRoot(
  appPath: string,
  signal?: AbortSignal,
): Promise<string> {
  const config = await readNextConfig(appPath, signal);
  return config.distDir ?? ".next";
}

async function readNextConfig(
  appPath: string,
  signal?: AbortSignal,
): Promise<StaticNextConfig> {
  for (const fileName of NEXT_CONFIG_FILENAMES) {
    signal?.throwIfAborted();
    let content: string;
    try {
      content = await readFile(path.join(appPath, fileName), {
        encoding: "utf8",
        signal,
      });
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code === "ENOENT") {
        continue;
      }
      throw error;
    }
    return readStaticNextConfig(content);
  }
  return {};
}

/** Best-effort static read of `distDir`/`output` from a next.config source. */
export function readStaticNextConfig(content: string): StaticNextConfig {
  try {
    const module = parseModule(content);
    const program = asAstNode(module.$ast);
    const bindings = program
      ? collectStaticBindings(program)
      : new Map<string, AstNode>();
    const configObject = program
      ? findExportedConfigObject(program, bindings)
      : null;
    if (!configObject) {
      return {};
    }

    const rawDistDir = readStaticStringProperty(configObject, "distDir");
    const output = readStaticStringProperty(configObject, "output");
    return {
      distDir: rawDistDir ? normalizeRelativePath(rawDistDir) : undefined,
      output:
        output === "standalone" || output === "export" ? output : undefined,
    };
  } catch {
    return {};
  }
}

function normalizeRelativePath(value: string): string | undefined {
  const raw = value.trim().replace(/\\/g, "/");
  if (raw.length === 0 || raw.split("/").includes("..")) {
    return undefined;
  }
  if (/^[A-Za-z]:/.test(raw)) {
    return undefined;
  }
  const normalized = path.posix.normalize(raw);
  if (
    path.win32.isAbsolute(value) ||
    path.posix.isAbsolute(normalized) ||
    normalized.split("/").includes("..")
  ) {
    return undefined;
  }
  return normalized === "." ? "." : normalized;
}

type AstNode = ASTNode & { type: string; [key: string]: unknown };

function asAstNode(value: unknown): AstNode | null {
  if (!value || typeof value !== "object") {
    return null;
  }
  const type = (value as { type?: unknown }).type;
  return typeof type === "string" ? (value as AstNode) : null;
}

function astNodes(value: unknown): AstNode[] {
  if (!Array.isArray(value)) {
    return [];
  }
  return value.map(asAstNode).filter((node): node is AstNode => Boolean(node));
}

function collectStaticBindings(program: AstNode): Map<string, AstNode> {
  const bindings = new Map<string, AstNode>();
  for (const statement of astNodes(program.body)) {
    if (statement.type !== "VariableDeclaration") {
      continue;
    }
    for (const declaration of astNodes(statement.declarations)) {
      const id = asAstNode(declaration.id);
      const init = asAstNode(declaration.init);
      if (id?.type === "Identifier" && typeof id.name === "string" && init) {
        bindings.set(id.name, init);
      }
    }
  }
  return bindings;
}

function findExportedConfigObject(
  program: AstNode,
  bindings: Map<string, AstNode>,
): AstNode | null {
  for (const statement of astNodes(program.body)) {
    if (statement.type === "ExportDefaultDeclaration") {
      return resolveConfigObject(statement.declaration, bindings);
    }
    if (statement.type !== "ExpressionStatement") {
      continue;
    }
    const expression = asAstNode(statement.expression);
    if (
      expression?.type !== "AssignmentExpression" ||
      expression.operator !== "="
    ) {
      continue;
    }
    if (isModuleExports(expression.left)) {
      return resolveConfigObject(expression.right, bindings);
    }
  }
  return null;
}

function resolveConfigObject(
  value: unknown,
  bindings: Map<string, AstNode>,
  depth = 0,
): AstNode | null {
  if (depth > 4) {
    return null;
  }
  const node = unwrapStaticExpression(asAstNode(value));
  if (!node) {
    return null;
  }
  if (node.type === "ObjectExpression") {
    return node;
  }
  if (node.type === "Identifier" && typeof node.name === "string") {
    return resolveConfigObject(bindings.get(node.name), bindings, depth + 1);
  }
  if (node.type === "CallExpression") {
    return resolveConfigObject(
      astNodes(node.arguments)[0],
      bindings,
      depth + 1,
    );
  }
  return null;
}

function unwrapStaticExpression(node: AstNode | null): AstNode | null {
  let current = node;
  while (
    current?.type === "TSAsExpression" ||
    current?.type === "TSSatisfiesExpression" ||
    current?.type === "TSNonNullExpression"
  ) {
    current = asAstNode(current.expression);
  }
  return current;
}

function isModuleExports(value: unknown): boolean {
  const node = asAstNode(value);
  if (node?.type !== "MemberExpression" || node.computed === true) {
    return false;
  }
  const object = asAstNode(node.object);
  const property = asAstNode(node.property);
  return (
    object?.type === "Identifier" &&
    object.name === "module" &&
    property?.type === "Identifier" &&
    property.name === "exports"
  );
}

function readStaticStringProperty(
  objectExpression: AstNode,
  propertyName: string,
): string | undefined {
  for (const property of astNodes(objectExpression.properties)) {
    if (property.type !== "ObjectProperty" || property.computed === true) {
      continue;
    }
    if (propertyKeyName(property.key) !== propertyName) {
      continue;
    }
    const value = unwrapStaticExpression(asAstNode(property.value));
    if (value?.type === "StringLiteral" && typeof value.value === "string") {
      const trimmed = value.value.trim();
      return trimmed.length > 0 ? trimmed : undefined;
    }
  }
  return undefined;
}

function propertyKeyName(value: unknown): string | undefined {
  const key = asAstNode(value);
  if (key?.type === "Identifier" && typeof key.name === "string") {
    return key.name;
  }
  if (key?.type === "StringLiteral" && typeof key.value === "string") {
    return key.value;
  }
  return undefined;
}
