#!/usr/bin/env node

import { Command } from "commander";
import chalk from "chalk";
import { execa } from "execa";
import fs from "fs-extra";
import inquirer from "inquirer";
import ora from "ora";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TEMPLATES_DIR = path.join(__dirname, "template");

const IGNORED_NAMES = new Set([
  ".git",
  ".next",
  ".nuxt",
  ".turbo",
  ".vite",
  "__pycache__",
  "build",
  "coverage",
  "dist",
  "node_modules",
  "venv",
  ".venv"
]);

const IGNORED_FILES = new Set([
  "npm-debug.log",
  "pnpm-debug.log",
  "yarn-error.log",
  "server.log",
  "server.error.log",
  "vite.log",
  "vite.error.log"
]);

const FRONTEND_HINTS = new Set([
  "react",
  "react-dom",
  "next",
  "vite",
  "vue",
  "@vitejs/plugin-react",
  "@vitejs/plugin-vue",
  "nuxt"
]);

const BACKEND_HINTS = new Set([
  "@nestjs/core",
  "@nestjs/cli",
  "express",
  "fastify",
  "koa",
  "hapi",
  "mongoose",
  "sequelize",
  "typeorm",
  "prisma",
  "nodemon"
]);

function title(text) {
  return chalk.bold.cyan(text);
}

function formatName(name) {
  return name
    .replace(/[-_]+/g, " ")
    .replace(/\s+/g, " ")
    .trim()
    .replace(/\b\w/g, (letter) => letter.toUpperCase());
}

function toPackageName(name) {
  const normalized = name
    .toLowerCase()
    .replace(/[^a-z0-9._~-]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .replace(/^[._]+/, "");

  return normalized || "alvie-app";
}

async function listTemplates() {
  if (!(await fs.pathExists(TEMPLATES_DIR))) {
    return [];
  }

  const entries = await fs.readdir(TEMPLATES_DIR, { withFileTypes: true });
  return entries
    .filter((entry) => entry.isDirectory() && !IGNORED_NAMES.has(entry.name))
    .map((entry) => ({
      id: entry.name,
      name: formatName(entry.name),
      path: path.join(TEMPLATES_DIR, entry.name)
    }))
    .sort((a, b) => a.name.localeCompare(b.name));
}

function shouldCopy(src) {
  const basename = path.basename(src);
  return !IGNORED_NAMES.has(basename) && !IGNORED_FILES.has(basename);
}

async function readJson(file) {
  try {
    return await fs.readJson(file);
  } catch {
    return null;
  }
}

function dependencyNames(pkg) {
  return new Set([
    ...Object.keys(pkg?.dependencies ?? {}),
    ...Object.keys(pkg?.devDependencies ?? {})
  ]);
}

function hasAnyDependency(pkg, hints) {
  const names = dependencyNames(pkg);
  return [...hints].some((hint) => names.has(hint));
}

function getPackageScripts(pkg) {
  return pkg?.scripts ?? {};
}

function chooseScript(pkg, candidates, fallback) {
  const scripts = getPackageScripts(pkg);
  return candidates.find((script) => scripts[script]) ?? fallback;
}

async function firstExistingFile(dir, files) {
  for (const file of files) {
    if (await fs.pathExists(path.join(dir, file))) {
      return file;
    }
  }

  return null;
}

async function detectNodeApp(dir, relativeDir) {
  const pkg = await readJson(path.join(dir, "package.json"));
  if (!pkg) return null;

  const deps = dependencyNames(pkg);
  const scripts = getPackageScripts(pkg);
  const lowerPath = relativeDir.toLowerCase();
  const hasVite = deps.has("vite") || (await fs.pathExists(path.join(dir, "vite.config.js"))) || (await fs.pathExists(path.join(dir, "vite.config.ts")));
  const hasNext = deps.has("next") || (await fs.pathExists(path.join(dir, "next.config.js"))) || (await fs.pathExists(path.join(dir, "next.config.mjs")));
  const hasVue = deps.has("vue");
  const hasReact = deps.has("react") || deps.has("react-dom");
  const hasNest = deps.has("@nestjs/core") || (await fs.pathExists(path.join(dir, "nest-cli.json")));
  const hasExpress = deps.has("express") || (await fs.pathExists(path.join(dir, "server.js"))) || (await fs.pathExists(path.join(dir, "app.js")));
  const existingScript = chooseScript(pkg, ["dev", "start", "serve"], null);
  const entryFile = await firstExistingFile(dir, [pkg.main, "server.js", "app.js", "index.js"].filter(Boolean));
  const frontendScore = Number(lowerPath.includes("front")) + Number(hasAnyDependency(pkg, FRONTEND_HINTS)) + Number(hasVite || hasNext || hasVue || hasReact);
  const backendScore = Number(lowerPath.includes("back") || lowerPath.includes("api") || lowerPath.includes("server")) + Number(hasAnyDependency(pkg, BACKEND_HINTS)) + Number(hasNest || hasExpress);

  let kind = "node";
  if (frontendScore > backendScore) kind = "frontend";
  if (backendScore > frontendScore) kind = "backend";

  const framework = [
    hasNext && "Next.js",
    hasVite && "Vite",
    hasReact && "React",
    hasVue && "Vue",
    hasNest && "NestJS",
    hasExpress && "Express"
  ].filter(Boolean);

  return {
    kind,
    type: "node",
    dir,
    relativeDir,
    framework: framework.join(", ") || "Node.js",
    installCommand: ["npm", ["install"]],
    devScript: existingScript ?? "dev",
    fallbackCommand: existingScript
      ? null
      : hasNext
        ? "next dev"
        : hasVite
          ? "vite"
          : hasNest
            ? "nest start --watch"
            : entryFile
              ? `node ${entryFile}`
              : "node index.js"
  };
}

async function detectPythonApp(dir, relativeDir) {
  const hasManagePy = await fs.pathExists(path.join(dir, "manage.py"));
  const hasRequirements = await fs.pathExists(path.join(dir, "requirements.txt"));
  const hasPyproject = await fs.pathExists(path.join(dir, "pyproject.toml"));
  if (!hasManagePy && !hasRequirements && !hasPyproject) return null;

  return {
    kind: hasManagePy ? "backend" : "python",
    type: "python",
    dir,
    relativeDir,
    framework: hasManagePy ? "Django" : "Python",
    installCommand: hasRequirements ? ["python", ["-m", "pip", "install", "-r", "requirements.txt"]] : null,
    devScript: hasManagePy ? "python manage.py runserver" : null
  };
}

async function walkApps(root, current = root, depth = 0) {
  if (depth > 4) return [];

  const basename = path.basename(current);
  if (IGNORED_NAMES.has(basename)) return [];

  const relativeDir = path.relative(root, current) || ".";
  const nodeApp = await detectNodeApp(current, relativeDir);
  const pythonApp = await detectPythonApp(current, relativeDir);
  const apps = [nodeApp, pythonApp].filter(Boolean);

  if (apps.length > 0 && relativeDir !== ".") {
    return apps;
  }

  const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
  const childApps = [];
  for (const entry of entries) {
    if (!entry.isDirectory() || IGNORED_NAMES.has(entry.name)) continue;
    childApps.push(...(await walkApps(root, path.join(current, entry.name), depth + 1)));
  }

  return [...apps, ...childApps];
}

function commandForApp(app) {
  if (app.type === "python") {
    return app.devScript ?? "python -m http.server";
  }

  return `npm run ${app.devScript}`;
}

function createRootPackage(projectName, apps) {
  const nodeApps = apps.filter((app) => app.type === "node");
  const frontend = apps.find((app) => app.kind === "frontend") ?? nodeApps[0];
  const backend = apps.find((app) => app.kind === "backend") ?? nodeApps.find((app) => app !== frontend);

  const scripts = {};
  if (backend) scripts.server = `npm --prefix ${JSON.stringify(backend.relativeDir)} run ${backend.devScript}`;
  if (frontend) scripts.client = `npm --prefix ${JSON.stringify(frontend.relativeDir)} run ${frontend.devScript}`;

  if (frontend && backend && frontend.relativeDir !== backend.relativeDir) {
    scripts.dev = 'concurrently -n server,client -c blue,green "npm run server" "npm run client"';
  } else if (frontend) {
    scripts.dev = `npm run client`;
  } else if (backend) {
    scripts.dev = `npm run server`;
  } else if (nodeApps.length === 1) {
    scripts.dev = `npm --prefix ${JSON.stringify(nodeApps[0].relativeDir)} run ${nodeApps[0].devScript}`;
  }

  return {
    name: toPackageName(projectName),
    version: "1.0.0",
    private: true,
    scripts,
    devDependencies: frontend && backend && frontend.relativeDir !== backend.relativeDir ? { concurrently: "^9.2.1" } : {}
  };
}

async function writeProjectFiles(targetDir, projectName, apps) {
  for (const app of apps.filter((candidate) => candidate.type === "node")) {
    const packagePath = path.join(app.dir, "package.json");
    const pkg = await readJson(packagePath);
    if (!pkg) continue;

    pkg.scripts = pkg.scripts ?? {};
    if (!pkg.scripts[app.devScript]) {
      pkg.scripts[app.devScript] = app.fallbackCommand;
      await fs.writeJson(packagePath, pkg, { spaces: 2 });
    }
  }

  const rootPackagePath = path.join(targetDir, "package.json");
  if (!(await fs.pathExists(rootPackagePath)) || apps.length > 1) {
    await fs.writeJson(rootPackagePath, createRootPackage(projectName, apps), { spaces: 2 });
  }

  const gitignorePath = path.join(targetDir, ".gitignore");
  const gitignore = [
    "node_modules/",
    "dist/",
    "build/",
    ".next/",
    ".nuxt/",
    "coverage/",
    "venv/",
    ".venv/",
    "__pycache__/",
    "*.pyc",
    ".env.local",
    "npm-debug.log*",
    "yarn-debug.log*",
    "yarn-error.log*",
    "pnpm-debug.log*"
  ].join("\n");
  await fs.writeFile(gitignorePath, `${gitignore}\n`);
}

async function runInstall(cwd, label, command = "npm", args = ["install"]) {
  const executable = command === "npm" && process.platform === "win32" ? "npm.cmd" : command;
  const spinner = ora(`Installing ${label} dependencies`).start();
  try {
    await execa(executable, args, { cwd, stdio: "ignore" });
    spinner.succeed(`Installed ${label} dependencies`);
  } catch (error) {
    spinner.warn(`Retrying ${label} dependency install with npm output`);
    try {
      await execa(executable, args, { cwd, stdio: "inherit" });
      ora().succeed(`Installed ${label} dependencies`);
    } catch (retryError) {
      ora().fail(`Failed to install ${label} dependencies`);
      throw retryError;
    }
  }
}

async function installDependencies(targetDir, apps) {
  const rootPkg = await readJson(path.join(targetDir, "package.json"));
  if (rootPkg?.devDependencies?.concurrently) {
    await runInstall(targetDir, "workspace");
  }

  for (const app of apps) {
    if (!app.installCommand) continue;
    const [command, args] = app.installCommand;
    await runInstall(app.dir, app.relativeDir, command, args);
  }
}

async function createProject(options) {
  console.log(title("\ncreate-alvie-app"));
  console.log(chalk.gray("Build a fullstack project from an Alvie template.\n"));

  const templates = await listTemplates();
  if (templates.length === 0) {
    throw new Error(`No templates found in ${path.relative(process.cwd(), TEMPLATES_DIR)}.`);
  }

  let selectedTemplate = templates.find((template) => template.id === options.template || template.name === options.template);
  let projectName = options.name;

  if (!selectedTemplate) {
    const answers = await inquirer.prompt([
      {
        type: "list",
        name: "templateId",
        message: "Choose a template",
        choices: templates.map((template, index) => ({
          name: `${index + 1}. ${template.name}`,
          value: template.id
        }))
      }
    ]);
    selectedTemplate = templates.find((template) => template.id === answers.templateId);
  }

  if (!projectName) {
    const answers = await inquirer.prompt([
      {
        type: "input",
        name: "projectName",
        message: "New project name",
        default: selectedTemplate.id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""),
        validate(value) {
          if (!value.trim()) return "Project name is required.";
          if (/[<>:"|?*]/.test(value)) return "Project name contains invalid filesystem characters.";
          return true;
        }
      }
    ]);
    projectName = answers.projectName.trim();
  }

  const targetDir = path.resolve(process.cwd(), projectName);
  if (await fs.pathExists(targetDir)) {
    const files = await fs.readdir(targetDir);
    if (files.length > 0) {
      throw new Error(`Target folder already exists and is not empty: ${targetDir}`);
    }
  }

  const copySpinner = ora(`Creating ${chalk.cyan(projectName)} from ${chalk.cyan(selectedTemplate.name)}`).start();
  await fs.copy(selectedTemplate.path, targetDir, {
    filter: shouldCopy,
    dereference: true
  });
  copySpinner.succeed(`Created ${projectName}`);

  const apps = await walkApps(targetDir);
  apps.forEach((app) => {
    app.dir = path.join(targetDir, app.relativeDir);
  });

  await writeProjectFiles(targetDir, projectName, apps);

  if (apps.length > 0) {
    console.log(chalk.gray("\nDetected apps:"));
    for (const app of apps) {
      console.log(`  ${chalk.green("•")} ${app.relativeDir} ${chalk.gray(`(${app.framework})`)}`);
    }
  }

  if (!options.skipInstall) {
    await installDependencies(targetDir, apps);
  }

  console.log(chalk.green.bold("\nSuccess!"));
  console.log(`Project ready at ${chalk.cyan(targetDir)}`);
  console.log(chalk.white("\nNext steps:"));
  console.log(`  cd ${projectName}`);
  console.log("  npm run dev");

  if (options.start && !options.skipInstall) {
    console.log(chalk.gray("\nStarting development server...\n"));
    await execa("npm", ["run", "dev"], { cwd: targetDir, stdio: "inherit" });
  }
}

const program = new Command();

program
  .name("create-alvie-app")
  .description("Create a fullstack Alvie project from bundled templates.")
  .version("1.0.0")
  .option("-t, --template <name>", "template folder name to use")
  .option("-n, --name <name>", "new project folder name")
  .option("--skip-install", "copy files without installing dependencies")
  .option("--start", "start npm run dev after creation")
  .action(async (options) => {
    try {
      await createProject(options);
    } catch (error) {
      console.error(chalk.red.bold("\nError"));
      console.error(chalk.red(error.message));
      if (error.shortMessage) console.error(chalk.gray(error.shortMessage));
      process.exitCode = 1;
    }
  });

program.parse(process.argv);
