import path from "node:path";

import { Result, TaggedError } from "better-result";

import { frameworkByKey } from "./frameworks.ts";
import {
  COMPUTE_FRAMEWORKS,
  COMPUTE_REGIONS,
  type ComputeFramework,
  type ComputeRegion,
} from "./types.ts";

export interface ComputeDeployTargetBuild {
  /** Build command, null to skip the build step, undefined when not configured. */
  command: string | null | undefined;
  /** Normalized output path relative to the app root, undefined when not configured. */
  outputDirectory: string | undefined;
  /**
   * Normalized configured entrypoint. With outputDirectory it is relative to
   * that output directory; otherwise it is relative to the app root.
   */
  entrypoint: string | undefined;
}

export interface ComputeDeployTarget {
  /** `apps` map key, or null for a single-app `app` config. */
  key: string | null;
  name: string | null;
  /** Region used when creating a new app, or null to use the consumer default. */
  region: ComputeRegion | null;
  /** Normalized app directory relative to the config file, or null for the config directory. */
  root: string | null;
  framework: ComputeFramework | null;
  /** Runtime/source entrypoint normalized from `entry` or an entrypoint-backed `build.entrypoint`. */
  entry: string | null;
  httpPort: number | null;
  /** Env inputs in deploy order: dotenv file paths first, then NAME=VALUE assignments. */
  envInputs: string[];
  /** Build settings; non-null means the config owns build configuration. */
  build: ComputeDeployTargetBuild | null;
}

export interface LoadedComputeConfig {
  configPath: string;
  /** Directory containing the config file. Config-relative paths resolve from here. */
  configDir: string;
  relativeConfigPath: string;
  kind: "single" | "multi";
  targets: ComputeDeployTarget[];
}

export class ComputeConfigInvalidError extends TaggedError(
  "ComputeConfigInvalidError",
)<{
  message: string;
  configPath: string;
  issues: string[];
}>() {
  constructor(configPath: string, issues: string[]) {
    super({
      message: `${path.basename(configPath)} is invalid: ${issues.join(" ")}`,
      configPath,
      issues,
    });
  }
}

export class ComputeConfigTargetRequiredError extends TaggedError(
  "ComputeConfigTargetRequiredError",
)<{
  message: string;
  configPath: string;
  availableTargets: string[];
}>() {
  constructor(configPath: string, availableTargets: string[]) {
    super({
      message: `${path.basename(configPath)} defines multiple apps. Pass a target: ${availableTargets.join(", ")}.`,
      configPath,
      availableTargets,
    });
  }
}

export class ComputeConfigTargetUnknownError extends TaggedError(
  "ComputeConfigTargetUnknownError",
)<{
  message: string;
  configPath: string;
  requestedTarget: string;
  availableTargets: string[];
}>() {
  constructor(
    configPath: string,
    requestedTarget: string,
    availableTargets: string[],
  ) {
    super({
      message: `${path.basename(configPath)} does not define an app named "${requestedTarget}". Available: ${availableTargets.join(", ")}.`,
      configPath,
      requestedTarget,
      availableTargets,
    });
  }
}

export type ComputeConfigTargetError =
  | ComputeConfigTargetRequiredError
  | ComputeConfigTargetUnknownError;

// Exported so the JSON Schema (and its tests) provably cover the same keys.
export const KNOWN_APP_KEYS = [
  "name",
  "region",
  "root",
  "framework",
  "entry",
  "httpPort",
  "env",
  "build",
] as const;
export const KNOWN_TOP_LEVEL_KEYS = ["app", "apps", "region"] as const;
export const KNOWN_ENV_KEYS = ["file", "vars"] as const;
export const KNOWN_BUILD_KEYS = [
  "command",
  "outputDirectory",
  "entrypoint",
] as const;

/**
 * Validates and normalizes a config module's default export. Reports every
 * issue at once so authors fix the file in one pass.
 */
export function normalizeComputeConfig(
  exported: unknown,
  configPath: string,
): Result<LoadedComputeConfig, ComputeConfigInvalidError> {
  const issues: string[] = [];
  const targets: ComputeDeployTarget[] = [];
  let kind: LoadedComputeConfig["kind"] = "single";
  let projectRegion: ComputeRegion | null = null;

  if (!isPlainObject(exported)) {
    issues.push(
      "The config must `export default defineComputeConfig({ ... })` with an object value.",
    );
  } else {
    const hasApp = exported.app !== undefined;
    const hasApps = exported.apps !== undefined;
    projectRegion = readOptionalComputeRegion(
      exported.region,
      "region",
      issues,
    );

    for (const key of Object.keys(exported)) {
      if (!(KNOWN_TOP_LEVEL_KEYS as readonly string[]).includes(key)) {
        issues.push(
          `Unknown top-level key "${key}". Expected one of: ${KNOWN_TOP_LEVEL_KEYS.join(", ")}.`,
        );
      }
    }

    if (hasApp && hasApps) {
      issues.push(
        "Use either `app` (single app) or `apps` (multi-app), not both.",
      );
    } else if (hasApp) {
      const target = normalizeAppEntry(
        exported.app,
        "app",
        null,
        projectRegion,
        issues,
      );
      if (target) {
        targets.push(target);
      }
    } else if (hasApps) {
      kind = "multi";
      if (!isPlainObject(exported.apps)) {
        issues.push("`apps` must be an object keyed by deploy target name.");
      } else {
        const entries = Object.entries(exported.apps);
        if (entries.length === 0) {
          issues.push("`apps` must define at least one app.");
        }
        for (const [key, value] of entries) {
          if (key.trim().length === 0) {
            issues.push("`apps` keys must be non-empty target names.");
            continue;
          }
          const target = normalizeAppEntry(
            value,
            `apps.${key}`,
            key,
            projectRegion,
            issues,
          );
          if (target) {
            targets.push(target);
          }
        }
      }
    } else {
      issues.push(
        "Define `app` for a single-app repository or `apps` for a multi-app repository.",
      );
    }
  }

  if (issues.length > 0) {
    return Result.err(new ComputeConfigInvalidError(configPath, issues));
  }

  return Result.ok({
    configPath,
    configDir: path.dirname(configPath),
    relativeConfigPath: path.basename(configPath),
    kind,
    targets,
  });
}

/** Absolute app directory of a config target. */
export function computeTargetAppDir(
  config: LoadedComputeConfig,
  target: ComputeDeployTarget,
): string {
  return path.resolve(config.configDir, target.root ?? ".");
}

/**
 * Selects a deploy target. Single-app configs return their app (a requested
 * target must then match the configured `name`); multi-app configs require a
 * matching key unless they hold exactly one target.
 */
export function selectComputeDeployTarget(
  config: LoadedComputeConfig,
  requestedTarget: string | undefined,
): Result<ComputeDeployTarget, ComputeConfigTargetError> {
  if (config.kind === "single") {
    const target = config.targets[0];
    if (!target) {
      return Result.err(
        new ComputeConfigTargetRequiredError(config.configPath, []),
      );
    }
    if (requestedTarget && requestedTarget !== target.name) {
      return Result.err(
        new ComputeConfigTargetUnknownError(
          config.configPath,
          requestedTarget,
          target.name ? [target.name] : [],
        ),
      );
    }
    return Result.ok(target);
  }

  const availableTargets = config.targets.map((target) => target.key ?? "");
  if (!requestedTarget) {
    const only = config.targets[0];
    if (config.targets.length === 1 && only) {
      return Result.ok(only);
    }
    return Result.err(
      new ComputeConfigTargetRequiredError(config.configPath, availableTargets),
    );
  }

  const matched = config.targets.find(
    (target) => target.key === requestedTarget,
  );
  if (!matched) {
    return Result.err(
      new ComputeConfigTargetUnknownError(
        config.configPath,
        requestedTarget,
        availableTargets,
      ),
    );
  }

  return Result.ok(matched);
}

/**
 * Infers the deploy target whose app directory contains `cwd`, so commands
 * run from inside a target's root select it without a target argument. The
 * deepest matching root wins; an ambiguous tie infers nothing.
 */
export function inferComputeTargetFromCwd(
  config: LoadedComputeConfig,
  cwd: string,
): string | undefined {
  if (config.kind !== "multi") {
    return undefined;
  }

  const resolvedCwd = path.resolve(cwd);
  let bestKey: string | undefined;
  let bestDepth = -1;
  let bestIsTied = false;

  for (const target of config.targets) {
    const appDir = computeTargetAppDir(config, target);
    const relative = path.relative(appDir, resolvedCwd);
    if (relative.startsWith("..") || path.isAbsolute(relative)) {
      continue;
    }

    const depth = appDir.split(path.sep).length;
    if (depth > bestDepth) {
      bestKey = target.key ?? undefined;
      bestDepth = depth;
      bestIsTied = false;
    } else if (depth === bestDepth) {
      bestIsTied = true;
    }
  }

  return bestIsTied ? undefined : bestKey;
}

function normalizeAppEntry(
  value: unknown,
  label: string,
  key: string | null,
  projectRegion: ComputeRegion | null,
  issues: string[],
): ComputeDeployTarget | null {
  if (!isPlainObject(value)) {
    issues.push(`\`${label}\` must be an object.`);
    return null;
  }

  for (const entryKey of Object.keys(value)) {
    if (!(KNOWN_APP_KEYS as readonly string[]).includes(entryKey)) {
      issues.push(
        `Unknown key "${entryKey}" in \`${label}\`. Expected one of: ${KNOWN_APP_KEYS.join(", ")}.`,
      );
    }
  }

  const name = readOptionalNonEmptyString(value.name, `${label}.name`, issues);
  const appRegion = readOptionalComputeRegion(
    value.region,
    `${label}.region`,
    issues,
  );
  const region = appRegion ?? projectRegion;
  const entry = readOptionalNonEmptyString(
    value.entry,
    `${label}.entry`,
    issues,
  );

  let root: string | null = null;
  if (value.root !== undefined) {
    const rawRoot = readOptionalNonEmptyString(
      value.root,
      `${label}.root`,
      issues,
    );
    if (rawRoot) {
      const normalized = normalizeRelativePath(rawRoot)?.replace(/\/+$/, "");
      if (!normalized) {
        issues.push(
          `\`${label}.root\` must be a relative path inside the repository.`,
        );
      } else if (normalized !== ".") {
        root = normalized;
      }
    }
  }

  let framework: ComputeFramework | null = null;
  if (value.framework !== undefined) {
    if (
      typeof value.framework === "string" &&
      (COMPUTE_FRAMEWORKS as readonly string[]).includes(value.framework)
    ) {
      framework = value.framework as ComputeFramework;
    } else {
      issues.push(
        `\`${label}.framework\` must be one of: ${COMPUTE_FRAMEWORKS.join(", ")}.`,
      );
    }
  }

  const frameworkDescriptor = framework ? frameworkByKey(framework) : null;
  if (entry && frameworkDescriptor && !frameworkDescriptor.usesEntrypoint) {
    issues.push(
      `\`${label}.entry\` is not supported with the ${framework} framework; it derives its entrypoint from build output.`,
    );
  }

  let httpPort: number | null = null;
  if (value.httpPort !== undefined) {
    if (
      typeof value.httpPort === "number" &&
      Number.isInteger(value.httpPort) &&
      value.httpPort > 0 &&
      value.httpPort <= 65535
    ) {
      httpPort = value.httpPort;
    } else {
      issues.push(
        `\`${label}.httpPort\` must be an integer between 1 and 65535.`,
      );
    }
  }

  const envInputs = normalizeEnvConfig(value.env, `${label}.env`, issues);
  const build = normalizeBuildConfig(value.build, `${label}.build`, issues);
  if (framework === "custom") {
    if (!build) {
      issues.push(
        `\`${label}.build\` is required with the custom framework and must set \`outputDirectory\` and \`entrypoint\`.`,
      );
    } else {
      if (!build.outputDirectory) {
        issues.push(
          `\`${label}.build.outputDirectory\` is required with the custom framework.`,
        );
      }
      if (!build.entrypoint) {
        issues.push(
          `\`${label}.build.entrypoint\` is required with the custom framework.`,
        );
      }
    }
  } else if (
    build?.outputDirectory &&
    build.outputDirectory !== "." &&
    !build.entrypoint &&
    frameworkDescriptor?.usesEntrypoint
  ) {
    issues.push(
      `\`${label}.build.entrypoint\` is required when \`${label}.build.outputDirectory\` is set with the ${framework} framework.`,
    );
  }
  if (
    entry &&
    build?.entrypoint &&
    frameworkDescriptor?.usesEntrypoint &&
    entry !== build.entrypoint
  ) {
    issues.push(
      `\`${label}.entry\` and \`${label}.build.entrypoint\` both configure the runtime entrypoint; set only one, or make them match.`,
    );
  }
  return {
    key,
    name,
    region,
    root,
    framework,
    entry:
      entry ??
      (frameworkDescriptor?.usesEntrypoint || !frameworkDescriptor
        ? (build?.entrypoint ?? null)
        : null),
    httpPort,
    envInputs,
    build,
  };
}

function normalizeBuildConfig(
  value: unknown,
  label: string,
  issues: string[],
): ComputeDeployTargetBuild | null {
  if (value === undefined) {
    return null;
  }

  if (!isPlainObject(value)) {
    issues.push(
      `\`${label}\` must be an object with \`command\`, \`outputDirectory\`, and/or \`entrypoint\`.`,
    );
    return null;
  }

  for (const buildKey of Object.keys(value)) {
    if (!(KNOWN_BUILD_KEYS as readonly string[]).includes(buildKey)) {
      issues.push(
        `Unknown key "${buildKey}" in \`${label}\`. Expected one of: ${KNOWN_BUILD_KEYS.join(", ")}.`,
      );
    }
  }

  let command: string | null | undefined;
  if (value.command !== undefined) {
    if (value.command === null) {
      command = null;
    } else if (
      typeof value.command === "string" &&
      value.command.trim().length > 0
    ) {
      command = value.command.trim();
    } else {
      issues.push(
        `\`${label}.command\` must be a non-empty string, or null to skip the build step.`,
      );
    }
  }

  let outputDirectory: string | undefined;
  if (value.outputDirectory !== undefined) {
    const normalized =
      typeof value.outputDirectory === "string"
        ? normalizeRelativePath(value.outputDirectory)?.replace(/\/+$/, "")
        : undefined;
    if (!normalized) {
      issues.push(
        `\`${label}.outputDirectory\` must be a relative path inside the app root.`,
      );
    } else {
      outputDirectory = normalized;
    }
  }

  let entrypoint: string | undefined;
  if (value.entrypoint !== undefined) {
    const normalized =
      typeof value.entrypoint === "string"
        ? normalizeRelativePath(value.entrypoint)
        : undefined;
    if (!normalized) {
      issues.push(
        `\`${label}.entrypoint\` must be a relative path that stays inside its base directory.`,
      );
    } else if (normalized === ".") {
      issues.push(
        `\`${label}.entrypoint\` must be a relative file path inside its base directory.`,
      );
    } else {
      entrypoint = normalized;
    }
  }

  if (
    command === undefined &&
    outputDirectory === undefined &&
    entrypoint === undefined
  ) {
    issues.push(
      `\`${label}\` must set \`command\`, \`outputDirectory\`, and/or \`entrypoint\`.`,
    );
    return null;
  }

  return { command, outputDirectory, entrypoint };
}

function normalizeEnvConfig(
  value: unknown,
  label: string,
  issues: string[],
): string[] {
  if (value === undefined) {
    return [];
  }

  if (typeof value === "string") {
    const file = value.trim();
    if (file.length === 0) {
      issues.push(
        `\`${label}\` must be a non-empty dotenv file path when given as a string.`,
      );
      return [];
    }
    return [file];
  }

  if (!isPlainObject(value)) {
    issues.push(
      `\`${label}\` must be a dotenv file path or an object with \`file\` and/or \`vars\`.`,
    );
    return [];
  }

  for (const envKey of Object.keys(value)) {
    if (!(KNOWN_ENV_KEYS as readonly string[]).includes(envKey)) {
      issues.push(
        `Unknown key "${envKey}" in \`${label}\`. Expected one of: ${KNOWN_ENV_KEYS.join(", ")}.`,
      );
    }
  }

  const envInputs: string[] = [];

  if (value.file !== undefined) {
    const files = Array.isArray(value.file) ? value.file : [value.file];
    for (const file of files) {
      if (typeof file !== "string" || file.trim().length === 0) {
        issues.push(
          `\`${label}.file\` must be a non-empty dotenv file path or an array of them.`,
        );
        continue;
      }
      envInputs.push(file.trim());
    }
  }

  if (value.vars !== undefined) {
    if (!isPlainObject(value.vars)) {
      issues.push(`\`${label}.vars\` must be an object of NAME: value pairs.`);
    } else {
      for (const [varName, varValue] of Object.entries(value.vars)) {
        if (typeof varValue !== "string" || varValue.length === 0) {
          issues.push(
            `\`${label}.vars.${varName}\` must be a non-empty string.`,
          );
          continue;
        }
        envInputs.push(`${varName}=${varValue}`);
      }
    }
  }

  return envInputs;
}

function readOptionalNonEmptyString(
  value: unknown,
  label: string,
  issues: string[],
): string | null {
  if (value === undefined) {
    return null;
  }

  if (typeof value !== "string" || value.trim().length === 0) {
    issues.push(`\`${label}\` must be a non-empty string.`);
    return null;
  }

  return value.trim();
}

function readOptionalComputeRegion(
  value: unknown,
  label: string,
  issues: string[],
): ComputeRegion | null {
  if (value === undefined) {
    return null;
  }

  const region = typeof value === "string" ? value.trim() : "";
  if (!(COMPUTE_REGIONS as readonly string[]).includes(region)) {
    issues.push(`\`${label}\` must be one of: ${COMPUTE_REGIONS.join(", ")}.`);
    return null;
  }

  return region as ComputeRegion;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

function normalizeRelativePath(value: string): string | undefined {
  const raw = value.trim().replace(/\\/g, "/");
  if (raw.length === 0 || raw.split("/").includes("..")) {
    return undefined;
  }
  // Windows drive-relative paths ("C:dir") escape the config directory but
  // are not absolute under either path.win32 or path.posix.
  if (/^[A-Za-z]:/.test(raw)) {
    return undefined;
  }

  const normalized = path.posix.normalize(raw);
  const segments = normalized.split("/");
  if (
    path.win32.isAbsolute(value) ||
    path.posix.isAbsolute(normalized) ||
    segments.includes("..")
  ) {
    return undefined;
  }

  return normalized === "." ? "." : normalized;
}
