/**
 * Monorepo support:
 *   - `wrnexus workspace <name>`  scaffolds a multi-app workspace (apps/* + shared packages/*)
 *   - `wrnexus workspace add <name> --domain=<host>` adds an app to the current workspace
 *   - `wrnexus gateway [--port]`  serves every app behind one port, routed by domain
 *
 * A workspace holds several WrNexus apps under `apps/*` and shared libraries under
 * `packages/*`. Apps share code by importing a workspace package (e.g. `@app/shared`),
 * share databases, and talk at runtime via @wrnexus/pubsub (Redis driver for
 * cross-process). `wrnexus.workspace.ts` maps each app to the domains it serves.
 */

import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { scaffoldApp } from "./create.ts";
import { currentCliVersion } from "./update-notifier.ts";
import type { GatewayAuth, GatewaySecurity } from "@wrnexus/dev-server";

const frameworkVersion = currentCliVersion();

export type WorkspaceEnvironment = "development" | "staging" | "production" | (string & {});

export interface WorkspaceEnvironmentConfig {
  protocol?: "http" | "https";
  rootDomain?: string;
  port?: number;
  hostname?: string;
  runtime?: "development" | "production";
  hmr?: boolean;
  build?: boolean;
  migrate?: boolean;
  profile?: string;
}

export interface WorkspaceApp {
  name: string;
  dir: string;

  /**
   * Generates <subdomain>.<environment.rootDomain>.
   * Example: subdomain "sso" + rootDomain "local.wrnexus.test".
   */
  subdomain?: string;

  /**
   * Explicit hostname overrides by environment.
   */
  domains?: string[] | Partial<Record<WorkspaceEnvironment, string | string[]>>;

  /**
   * Complete public URL override by environment.
   */
  urls?: Partial<Record<WorkspaceEnvironment, string>>;

  port?: number;
  auth?: GatewayAuth;
}

export interface WorkspaceConfig {
  defaultEnvironment?: WorkspaceEnvironment;

  environments?: Partial<Record<WorkspaceEnvironment, WorkspaceEnvironmentConfig>>;

  apps: WorkspaceApp[];
  security?: GatewaySecurity;
}

export const workspaceFiles = (name: string): Record<string, string> => ({
  "package.json": `{
  "name": "${name}",
  "private": true,
  "type": "module",
  "workspaces": ["apps/*", "packages/*"],
  "scripts": {
    "dev": "wrnexus gateway",
    "gateway": "wrnexus gateway",
    "staging": "wrnexus staging",
    "production": "wrnexus production",
    "typecheck": "tsc --noEmit && bun run --filter './apps/*' typecheck",
    "test": "bun run --filter './apps/*' test",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier . --write",
    "format:check": "prettier . --check",
    "doctor": "bun run --filter './apps/*' doctor",
    "check": "bun run typecheck && bun run lint && bun run test && bun run format:check"
  },
  "devDependencies": {
    "@wrnexus/cli": "${frameworkVersion}",
    "@eslint/js": "^9.0.0",
    "@types/bun": "latest",
    "eslint": "^9.0.0",
    "prettier": "latest",
    "typescript": "^5.5.0",
    "typescript-eslint": "latest"
  }
}
`,
  "wrnexus.workspace.ts": `import type { WorkspaceConfig } from "@wrnexus/cli/workspace";

// Map each app to the domains it serves. \`wrnexus gateway\` runs them all behind
// one port and routes by Host header (add these hosts to your /etc/hosts).
const config: WorkspaceConfig = {
  defaultEnvironment: "development",
  environments: {
    development: { protocol: "http", rootDomain: "localhost", port: 3000, runtime: "development", hmr: true, build: false, migrate: false },
    staging: { protocol: "https", rootDomain: "staging.example.com", port: 443, runtime: "production", hmr: false, build: true, migrate: true },
    production: { protocol: "https", rootDomain: "example.com", port: 443, runtime: "production", hmr: false, build: true, migrate: true },
  },
  // Gateway-wide security (all optional):
  security: {
    trustedHostsOnly: true, // reject requests for unknown domains
    rateLimit: { max: 300, windowMs: 60_000 }, // per client IP
    headers: true, // baseline security headers at the edge
    accessLog: true, // log host → app, method, path, status
  },
  apps: [
    { name: "web", dir: "apps/web", domains: ["localhost", "web.localhost"] },
    {
      name: "admin",
      dir: "apps/admin",
      domains: ["admin.localhost"],
      // Lock the admin app down at the edge (pick one):
      auth: { basic: { user: "admin", pass: "change-me" } },
      // auth: { allowIps: ["127.0.0.1", "::1"] },
      // auth: { forward: { url: "http://localhost:4001/api/verify" } }, // SSO
    },
  ],
};

export default config;
`,
  ".gitignore": `node_modules/
dist/
.wrnexus/
**/.wrnexus/
coverage/
.env
.env.*
!.env.example
!.env.*.example
*.log
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
uploads/
mobile/android/
mobile/ios/
mobile/.expo/
.idea/
.vscode/*
!.vscode/settings.json
!.vscode/extensions.json
*.tsbuildinfo
.eslintcache
`,
  ".env.example": `REDIS_URL=redis://localhost:6379
AUTH_SECRET=replace-with-at-least-32-random-characters
`,
  ".prettierrc.json": `{
  "printWidth": 100,
  "tabWidth": 2,
  "useTabs": false,
  "semi": true,
  "singleQuote": false,
  "trailingComma": "all",
  "endOfLine": "lf"
}
`,
  ".prettierignore": `node_modules/
dist/
.wrnexus/
**/.wrnexus/
*.log
**/CLAUDE.md
`,
  ".editorconfig": `root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
`,
  "eslint.config.js": `import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import tseslint from "typescript-eslint";

const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));

export default tseslint.config(
  { ignores: ["node_modules/**", "dist/**", "**/dist/**", ".wrnexus/**", "**/.wrnexus/**"] },
  { languageOptions: { parserOptions: { tsconfigRootDir } } },
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    files: ["**/*.{ts,tsx}"],
    rules: {
      "no-undef": "off",
      "no-console": "off",
      "@typescript-eslint/no-explicit-any": "off",
      "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
    },
  },
);
`,
  "tsconfig.json": `{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ESNext", "DOM"],
    "types": ["bun"],
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true,
    "allowImportingTsExtensions": true
  },
  "include": ["wrnexus.workspace.ts", "packages/**/*.ts"],
  "exclude": ["node_modules", "dist", "apps"]
}
`,
  ".vscode/settings.json": `{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
  "prettier.requireConfig": true,
  "[wrn]": { "editor.defaultFormatter": "wrnexus.wrnexus", "editor.formatOnSave": true }
}
`,
  ".vscode/extensions.json": `{
  "recommendations": ["wrnexus.wrnexus", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
}
`,
  "packages/shared/package.json": `{
  "name": "@app/shared",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "main": "src/index.ts",
  "exports": { ".": "./src/index.ts" },
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test": "bun test"
  },
  "dependencies": {
    "@wrnexus/pubsub": "${frameworkVersion}"
  }
}
`,
  "packages/shared/src/index.ts": `/**
 * Shared code for every app in this workspace. Import it anywhere: \`@app/shared\`.
 * The cross-app event bus uses Redis so messages reach every app process/domain.
 */
import { createPubSub } from "@wrnexus/pubsub";
import { redisDriver } from "@wrnexus/pubsub/redis";

// One bus per process, backed by Redis (set REDIS_URL, defaults to localhost:6379).
export const bus = createPubSub(redisDriver(process.env.REDIS_URL));

// Shared domain types can live here and be imported by every app.
export interface Tenant {
  id: string;
  name: string;
}
`,
  "README.md": `# ${name}

A WrNexus **workspace** — multiple apps, one gateway, interconnected.

\`\`\`
${name}/
  wrnexus.workspace.ts   # apps ↔ domains map (used by \`wrnexus gateway\`)
  apps/
    web/                # a WrNexus app  → localhost, web.localhost
    admin/              # a WrNexus app  → admin.localhost
  packages/
    shared/             # @app/shared — shared code + cross-app pubsub bus
\`\`\`

## Run everything (one port, routed by domain)

\`\`\`bash
bun install
bun run dev            # = wrnexus gateway  → http://127.0.0.1:3000
\`\`\`

Add the hosts to your machine (e.g. /etc/hosts):

\`\`\`
127.0.0.1 web.localhost admin.localhost
\`\`\`

Open \`http://localhost:3000\` for the web app or
\`http://admin.localhost:3000\` for the admin app. The ports printed for individual
apps are internal gateway targets, not public workspace URLs.

## Interconnect

- **Shared code:** import \`@app/shared\` in any app.
- **Runtime messaging:** \`import { bus } from "@app/shared"\` then
  \`bus.publish("tenant:created", {...})\` in one app and
  \`bus.subscribe("tenant:*", fn)\` in another (needs Redis).
- **Databases:** point apps at the same \`db\`/\`databases\` in their config.

## Add another app

\`\`\`bash
wrnexus workspace add reports --domain=reports.localhost
\`\`\`
`,
});

const workspaceConfigNames = [
  "wrnexus.workspace.ts",
  "wrnexus.workspace.js",
  "wrnexus.workspace.mjs",
] as const;

function workspaceConfigPath(root: string): string | null {
  for (const file of workspaceConfigNames) {
    const path = join(root, file);
    if (existsSync(path)) return path;
  }
  return null;
}

/** Insert an app entry into the top-level `apps: [...]` array. */
export function insertWorkspaceApp(source: string, app: WorkspaceApp): string {
  const declaration = /\bapps\s*:\s*\[/.exec(source);
  if (!declaration) throw new Error("Workspace config has no `apps: [...]` array.");
  const start = source.indexOf("[", declaration.index);
  let depth = 0;
  let quote = "";
  let escaped = false;
  let lineComment = false;
  let blockComment = false;
  let end = -1;

  for (let i = start; i < source.length; i++) {
    const char = source[i]!;
    const next = source[i + 1] ?? "";
    if (lineComment) {
      if (char === "\n") lineComment = false;
      continue;
    }
    if (blockComment) {
      if (char === "*" && next === "/") {
        blockComment = false;
        i++;
      }
      continue;
    }
    if (quote) {
      if (escaped) escaped = false;
      else if (char === "\\") escaped = true;
      else if (char === quote) quote = "";
      continue;
    }
    if (char === "/" && next === "/") {
      lineComment = true;
      i++;
      continue;
    }
    if (char === "/" && next === "*") {
      blockComment = true;
      i++;
      continue;
    }
    if (char === '"' || char === "'" || char === "`") {
      quote = char;
      continue;
    }
    if (char === "[") depth++;
    if (char === "]" && --depth === 0) {
      end = i;
      break;
    }
  }
  if (end < 0) throw new Error("Workspace `apps` array is not closed.");

  const serialized = `{ name: ${JSON.stringify(app.name)}, dir: ${JSON.stringify(app.dir)}, domains: ${JSON.stringify(app.domains)}${app.port ? `, port: ${app.port}` : ""} }`;
  if (!source.slice(start, end).includes("\n")) {
    const contents = source.slice(start + 1, end).trimEnd();
    const separator = contents && !contents.endsWith(",") ? "," : "";
    return source.slice(0, end) + `${separator} ${serialized}` + source.slice(end);
  }

  const lineStart = source.lastIndexOf("\n", end - 1) + 1;
  const closeIndent = /^\s*/.exec(source.slice(lineStart, end))?.[0] ?? "";
  const entry = `${closeIndent}  ${serialized},\n`;
  return source.slice(0, lineStart) + entry + source.slice(lineStart);
}

/** Add a scaffolded app and register its domain in the current workspace. */
export async function addWorkspaceApp(root: string, name: string, args: string[]): Promise<void> {
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
    throw new Error(
      "App name must start with a letter and contain only lowercase letters, digits, and hyphens.",
    );
  }
  const resolvedRoot = resolve(root);
  const configPath = workspaceConfigPath(resolvedRoot);
  if (!configPath) throw new Error("No wrnexus.workspace.ts found in the current directory.");

  const domain =
    args.find((arg) => arg.startsWith("--domain="))?.split("=")[1] || `${name}.localhost`;
  if (!/^[a-z0-9.-]+$/.test(domain)) throw new Error(`Invalid workspace domain: ${domain}`);
  const portValue = args.find((arg) => arg.startsWith("--port="))?.split("=")[1];
  const port = portValue ? Number(portValue) : undefined;
  if (portValue && (!Number.isInteger(port) || port! < 1 || port! > 65535)) {
    throw new Error(`Invalid app port: ${portValue}`);
  }

  const config = await loadWorkspaceConfig(resolvedRoot);
  const dir = `apps/${name}`;
  if (config.apps.some((app) => app.name === name || app.dir.replace(/\\/g, "/") === dir)) {
    throw new Error(`Workspace app '${name}' already exists.`);
  }
  const normalizedDomain = domain.toLowerCase();

  if (
    config.apps.some((app) =>
      configuredDomains(app).some((configured) => configured.toLowerCase() === normalizedDomain),
    )
  ) {
    throw new Error(`Workspace domain '${domain}' is already assigned.`);
  }
  const appRoot = join(resolvedRoot, "apps", name);
  if (existsSync(appRoot)) throw new Error(`Refusing to overwrite existing directory: ${appRoot}`);

  const original = readFileSync(configPath, "utf8");
  const next = insertWorkspaceApp(original, { name, dir, domains: [domain], port });
  scaffoldApp(appRoot, name);
  try {
    writeFileSync(configPath, next, "utf8");
  } catch (error) {
    rmSync(appRoot, { recursive: true, force: true });
    throw error;
  }

  console.log(`✓ Added app ${name}`);
  console.log(`  directory: ${dir}`);
  console.log(`  domain:    http://${domain}:3000`);
  console.log("\nRun `bun install`, then `bun run dev`.");
}

function configuredDomains(app: WorkspaceApp): string[] {
  if (!app.domains) {
    return [];
  }

  if (Array.isArray(app.domains)) {
    return app.domains;
  }

  return Object.values(app.domains).flatMap((value) => {
    if (!value) {
      return [];
    }

    return Array.isArray(value) ? value : [value];
  });
}

/** Scaffold a monorepo workspace with two starter apps + a shared package. */
export function createWorkspace(name: string): void {
  if (!name) {
    console.error("Usage: wrnexus workspace <name>");
    process.exit(1);
  }
  const root = resolve(process.cwd(), name);
  if (existsSync(root)) {
    console.error(`Refusing to overwrite existing directory: ${root}`);
    process.exit(1);
  }

  for (const [rel, contents] of Object.entries(workspaceFiles(name))) {
    const target = join(root, rel);
    mkdirSync(dirname(target), { recursive: true });
    writeFileSync(target, contents, "utf8");
  }
  // Two starter apps under apps/.
  scaffoldApp(join(root, "apps", "web"), "web");
  scaffoldApp(join(root, "apps", "admin"), "admin");

  console.log(`✓ Created workspace ${name}`);
  console.log(`\nNext steps:`);
  console.log(`  cd ${name}`);
  console.log(`  bun install`);
  console.log(`  bun run dev            # wrnexus gateway (web + admin, routed by domain)`);
}

/** Load `wrnexus.workspace.ts` from a directory. */
export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig> {
  const path = workspaceConfigPath(root);
  if (path) {
    const mod = (await import(pathToFileURL(path).href)) as { default?: WorkspaceConfig };
    if (!mod.default?.apps?.length) {
      throw new Error(`${path.split(/[\\/]/).at(-1)} must default-export { apps: [...] }`);
    }
    return mod.default;
  }
  throw new Error("No wrnexus.workspace.ts found. Run `wrnexus workspace <name>` to scaffold one.");
}

/** Run the multi-app gateway from `wrnexus.workspace.ts`. */
export async function runGateway(root: string, args: string[]): Promise<void> {
  const config = await loadWorkspaceConfig(resolve(root));

  const portArg = args.find((arg) => arg.startsWith("--port="));

  const hostArg = args.find((arg) => arg.startsWith("--host="));

  const environmentArg = args.find((arg) => arg.startsWith("--environment="));

  const profileArg = args.find((arg) => arg.startsWith("--profile="));

  const environment =
    environmentArg?.split("=")[1] ||
    profileArg?.split("=")[1] ||
    process.env.WRNEXUS_ENV ||
    process.env.WRNEXUS_PROFILE ||
    (args.includes("--prod") ? "production" : config.defaultEnvironment || "development");

  const resolved = resolveWorkspaceConfig(config, environment);
  const environmentConfig = resolved.config;
  const runtime =
    environmentConfig.runtime ?? (environment === "development" ? "development" : "production");

  const port = portArg
    ? Number(portArg.split("=")[1])
    : (environmentConfig.port ?? (runtime === "development" ? 3000 : 80));

  const hostname = hostArg?.split("=")[1] ?? environmentConfig.hostname;

  const { startGateway } = await import("@wrnexus/dev-server");

  await startGateway({
    port,
    hostname,
    mode: args.includes("--prod") || process.env.NODE_ENV === "production" ? "production" : runtime,
    hmr: runtime === "development" ? (environmentConfig.hmr ?? true) : false,
    environment,
    security: resolved.security,
    apps: resolved.apps.map((app) => ({
      name: app.name,
      dir: resolve(root, app.dir),
      domains: app.domains,
      publicOrigin: app.publicOrigin,
      port: app.port,
      auth: app.auth,
    })),
  });
}

/** Return default/named database targets that contain SQL migrations. */
export function workspaceMigrationTargets(appRoot: string): Array<string | null> {
  const dbRoot = join(resolve(appRoot), "app", "db");
  if (!existsSync(dbRoot)) return [];
  const targets: Array<string | null> = [];
  const hasSql = (dir: string) =>
    existsSync(dir) &&
    readdirSync(dir, { withFileTypes: true }).some(
      (entry) => entry.isFile() && entry.name.endsWith(".sql"),
    );
  if (hasSql(join(dbRoot, "migrations"))) targets.push(null);
  for (const entry of readdirSync(dbRoot, { withFileTypes: true })) {
    if (
      entry.isDirectory() &&
      entry.name !== "migrations" &&
      hasSql(join(dbRoot, entry.name, "migrations"))
    ) {
      targets.push(entry.name);
    }
  }
  return targets;
}

/** Build, migrate, then serve every registered workspace app in production mode. */
export async function runProduction(root: string, args: string[]): Promise<void> {
  const workspaceRoot = resolve(root);
  const config = await loadWorkspaceConfig(workspaceRoot);
  const prepareOnly = args.includes("--prepare-only");
  const environmentArg = args.find((arg) => arg.startsWith("--environment="));
  const profileArg = args.find((arg) => arg.startsWith("--profile="));
  const environment = environmentArg?.split("=")[1] || profileArg?.split("=")[1] || "production";
  const environmentConfig = config.environments?.[environment] ?? {};
  const profile = environmentConfig.profile ?? environment;
  const shouldBuild =
    !args.includes("--no-build") && (args.includes("--build") || environmentConfig.build !== false);
  const shouldMigrate =
    !args.includes("--no-migrate") &&
    (args.includes("--migrate") || environmentConfig.migrate !== false);

  process.env.NODE_ENV = "production";
  process.env.WRNEXUS_ENV = environment;
  process.env.WRNEXUS_PROFILE = profile;
  console.log(`\n  ⚡ Preparing WRNexus workspace (${environment})\n`);

  if (shouldBuild) {
    const { runBuild } = await import("./build.ts");
    for (const app of config.apps) {
      const appRoot = resolve(workspaceRoot, app.dir);
      if (!existsSync(join(appRoot, "package.json"))) {
        throw new Error(`Workspace app '${app.name}' has no package.json at ${appRoot}.`);
      }
      console.log(`\n  ▸ Building ${app.name}`);
      await runBuild(appRoot);
    }
  } else {
    console.log("  = builds skipped (--no-build)");
  }

  if (shouldMigrate) {
    const { runDbCommand } = await import("./db.ts");
    for (const app of config.apps) {
      const appRoot = resolve(workspaceRoot, app.dir);
      const targets = workspaceMigrationTargets(appRoot);
      if (!targets.length) {
        console.log(`  = ${app.name}: no SQL migrations`);
        continue;
      }
      for (const target of targets) {
        console.log(`\n  ▸ Migrating ${app.name}${target ? ` (${target})` : ""}`);
        await runDbCommand(appRoot, "migrate", target ? [`--db=${target}`] : []);
      }
    }
  } else {
    console.log("  = migrations skipped (--no-migrate)");
  }

  console.log("\n  ✓ Workspace builds and migrations are ready.\n");
  if (prepareOnly) return;

  const gatewayArgs = args.filter(
    (arg) => arg !== "--prepare-only" && arg !== "--no-build" && arg !== "--no-migrate",
  );
  if (!gatewayArgs.some((arg) => arg.startsWith("--environment="))) {
    gatewayArgs.push(`--environment=${environment}`);
  }
  if (!gatewayArgs.includes("--prod")) gatewayArgs.push("--prod");
  if (
    !gatewayArgs.some((arg) => arg.startsWith("--host=")) &&
    environmentConfig.hostname === undefined
  ) {
    gatewayArgs.push("--host=0.0.0.0");
  }
  await runGateway(workspaceRoot, gatewayArgs);
}

function environmentVariableName(appName: string): string {
  return `WRNEXUS_APP_${appName.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}_URL`;
}

function normalizeOrigin(value: string): string {
  const url = new URL(value);

  if (url.protocol !== "http:" && url.protocol !== "https:") {
    throw new Error(`Application URL must use http or https: ${value}`);
  }

  return url.origin;
}

function valuesForEnvironment(
  value: WorkspaceApp["domains"],
  environment: WorkspaceEnvironment,
): string[] {
  if (!value) return [];

  if (Array.isArray(value)) {
    return value;
  }

  const selected = value[environment];

  if (!selected) return [];

  return Array.isArray(selected) ? selected : [selected];
}

export interface ResolvedWorkspaceApp extends WorkspaceApp {
  domains: string[];
  publicOrigin: string;
}

export interface ResolvedWorkspaceConfig {
  environment: WorkspaceEnvironment;
  config: WorkspaceEnvironmentConfig;
  apps: ResolvedWorkspaceApp[];
  security?: GatewaySecurity;
}

export function resolveWorkspaceConfig(
  config: WorkspaceConfig,
  environment: WorkspaceEnvironment,
): ResolvedWorkspaceConfig {
  const environmentConfig = config.environments?.[environment] ?? {};

  const protocol = environmentConfig.protocol ?? (environment === "production" ? "https" : "http");

  const configuredPort = environmentConfig.port;

  const apps = config.apps.map((app) => {
    const envOverride = process.env[environmentVariableName(app.name)];

    const configuredUrl = app.urls?.[environment];

    const explicitDomains = valuesForEnvironment(app.domains, environment);

    const generatedDomain =
      app.subdomain && environmentConfig.rootDomain
        ? `${app.subdomain}.${environmentConfig.rootDomain}`
        : undefined;

    const domains =
      explicitDomains.length > 0 ? explicitDomains : generatedDomain ? [generatedDomain] : [];

    let publicOrigin: string;

    if (envOverride) {
      publicOrigin = normalizeOrigin(envOverride);
    } else if (configuredUrl) {
      publicOrigin = normalizeOrigin(configuredUrl);
    } else if (domains[0]) {
      const port =
        configuredPort &&
        !(
          (protocol === "http" && configuredPort === 80) ||
          (protocol === "https" && configuredPort === 443)
        )
          ? `:${configuredPort}`
          : "";

      publicOrigin = `${protocol}://${domains[0]}${port}`;
    } else {
      throw new Error(
        `No domain or URL configured for workspace app "${app.name}" in environment "${environment}".`,
      );
    }

    const publicUrl = new URL(publicOrigin);

    return {
      ...app,
      domains: domains.length > 0 ? domains : [publicUrl.hostname],
      publicOrigin,
    };
  });

  const usedDomains = new Map<string, string>();

  for (const app of apps) {
    for (const domain of app.domains) {
      const normalized = domain.toLowerCase();
      const owner = usedDomains.get(normalized);

      if (owner) {
        throw new Error(
          `Workspace domain "${domain}" is assigned to both "${owner}" and "${app.name}".`,
        );
      }

      usedDomains.set(normalized, app.name);
    }
  }

  return {
    environment,
    config: environmentConfig,
    apps,
    security: config.security,
  };
}
