import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
import { join, resolve, isAbsolute, parse } from 'node:path';
import { homedir } from 'node:os';
import { loadSettings, saveSettings } from './settings';
import { getDataDir } from './dirs';

export interface LocalProject {
  name: string;
  path: string;
  root: string;              // Which project root it came from
  hasGit: boolean;
  hasClaudeMd: boolean;
  language: string | null;
  lastModified: string;
}

/** Reserved name for the synthetic "scratch" project that lives under
 *  <dataDir>/scratch. Default workspace for tasks that don't need a real
 *  project (prompt schedules calling connectors / chat-launched temp tasks
 *  / anything that just talks to APIs and doesn't touch files). */
export const SCRATCH_PROJECT_NAME = 'scratch';

/** Materialize <dataDir>/scratch on first call. Idempotent. Returns the
 *  project path. Called from init.ts on startup so the dir exists before
 *  any task tries to cd into it. */
export function ensureScratchProject(): string {
  const dir = join(getDataDir(), 'scratch');
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
  const claudeMd = join(dir, 'CLAUDE.md');
  if (!existsSync(claudeMd)) {
    try {
      writeFileSync(claudeMd,
        '# Forge Scratch\n\n' +
        'Default workspace for tasks that do not target a specific project.\n' +
        'Files here are managed by Forge (per-instance, follows the data dir).\n' +
        'Safe to wipe by hand if it accumulates clutter.\n',
        'utf-8',
      );
    } catch { /* best-effort */ }
  }
  // Scratch is Forge-owned (no user hand-rolled .mcp.json to defy), so
  // unlike real projects we materialize the root .mcp.json directly here.
  // Without it, prompt-mode schedule tasks running in scratch can't see
  // the Forge MCP server (claude CLI only auto-discovers root .mcp.json,
  // not .forge/mcp.json). Always rewrite to keep mcpPort + user-managed
  // entries in sync.
  try {
    const mcpPort = Number(process.env.MCP_PORT) || 8406;
    let userServers: Record<string, unknown> = {};
    try { userServers = loadSettings().mcpServers || {}; } catch {}
    const cfg = {
      mcpServers: {
        forge: { type: 'sse', url: `http://localhost:${mcpPort}/sse` },
        ...userServers,
      },
    };
    writeFileSync(join(dir, '.mcp.json'), JSON.stringify(cfg, null, 2), 'utf-8');
  } catch { /* best-effort */ }
  return dir;
}

function scratchProject(): LocalProject {
  const path = ensureScratchProject();
  let mtime: string;
  try { mtime = statSync(path).mtime.toISOString(); }
  catch { mtime = new Date().toISOString(); }
  return {
    name: SCRATCH_PROJECT_NAME,
    path,
    root: getDataDir(),
    hasGit: false,
    hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
    language: null,
    lastModified: mtime,
  };
}

let scanCache: { at: number; key: string; result: LocalProject[] } | null = null;
const SCAN_TTL_MS = 5_000;

/** Drop the scanProjects cache (call after registering/unregistering a root). */
export function invalidateProjectScan(): void { scanCache = null; }

export function scanProjects(): LocalProject[] {
  const settings = loadSettings();
  const roots = settings.projectRoots || [];
  const cacheKey = roots.join('\n');
  const now = Date.now();
  if (scanCache && scanCache.key === cacheKey && now - scanCache.at < SCAN_TTL_MS) {
    return scanCache.result;
  }

  if (roots.length === 0) { scanCache = { at: now, key: cacheKey, result: [] }; return []; }

  const projects: LocalProject[] = [];

  for (const root of roots) {
    if (!existsSync(root)) continue;

    const entries = readdirSync(root, { withFileTypes: true });

    for (const entry of entries) {
      if (!entry.isDirectory()) continue;
      if (entry.name.startsWith('.')) continue;

      const projectPath = join(root, entry.name);

      try {
        const hasGit = existsSync(join(projectPath, '.git'));
        const hasClaudeMd = existsSync(join(projectPath, 'CLAUDE.md'));
        const language = detectLanguage(projectPath);
        const stat = statSync(projectPath);

        projects.push({
          name: entry.name,
          path: projectPath,
          root,
          hasGit,
          hasClaudeMd,
          language,
          lastModified: stat.mtime.toISOString(),
        });
      } catch {
        // Skip inaccessible directories
      }
    }
  }

  // Prepend the synthetic scratch project so the UI surfaces it as a
  // first-class option. It's stamped with the current date so it tends
  // to sort to the top — that's intentional: it's the default fallback.
  const result = [scratchProject(), ...projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified))];
  scanCache = { at: now, key: cacheKey, result };
  return result;
}

function detectLanguage(projectPath: string): string | null {
  const markers: [string, string][] = [
    ['pom.xml', 'java'],
    ['build.gradle', 'java'],
    ['build.gradle.kts', 'kotlin'],
    ['package.json', 'typescript'],
    ['tsconfig.json', 'typescript'],
    ['requirements.txt', 'python'],
    ['pyproject.toml', 'python'],
    ['go.mod', 'go'],
    ['Cargo.toml', 'rust'],
  ];

  for (const [file, lang] of markers) {
    if (existsSync(join(projectPath, file))) return lang;
  }
  return null;
}

export function getProjectInfo(name: string): LocalProject | null {
  const projects = scanProjects();
  return projects.find(p => p.name === name) || null;
}

/**
 * Resolve a project by name, with a best-effort fallback for the common
 * "user hasn't added any projects yet" case:
 *
 *   1. If `name` matches a scanned project — return it. (Same as getProjectInfo.)
 *   2. Else if the GitLab connector has `default_project_path` set, try to
 *      clone it under `projectRoots[0]` (or ~/IdeaProjects if none set), add
 *      that parent to projectRoots, and return the cloned project.
 *   3. Else return null — callers fall back to their existing failure path
 *      (which typically surfaces a clear "Project not found" error so the
 *      user can fix it via Settings → Project roots).
 *
 * Designed to be additive: never throws, never overwrites existing projects,
 * idempotent on re-entry (clone is skipped if the target dir already exists).
 *
 * Returns a `{ project, source }` so callers can log how the project was
 * resolved without re-scanning.
 */
export interface ResolveResult {
  project: LocalProject;
  source: 'existing' | 'cloned' | 'gitlab-cloned' | 'scratch';
  clone_url?: string;
}

/**
 * Pick a writable directory to clone into, in priority order:
 *   1. settings.projectRoots[0] — what the user already trusts
 *   2. ~/IdeaProjects — common convention; mkdir on demand
 *   3. <dataDir>/scratch — Forge-owned, always writable, last resort
 *
 * Each candidate is mkdir-tested before use so we never return a path the
 * caller will fail to clone into.
 */
export function getDefaultCloneRoot(): string {
  const s = loadSettings();
  const candidates: string[] = [];
  if (s.projectRoots.length > 0) candidates.push(s.projectRoots[0]);
  candidates.push(join(homedir(), 'IdeaProjects'));
  candidates.push(join(getDataDir(), 'scratch'));
  for (const c of candidates) {
    try { mkdirSync(c, { recursive: true }); return c; }
    catch { /* try next */ }
  }
  // mkdir of <dataDir>/scratch should always work (we own it); reaching here
  // means the dataDir itself is broken, which has bigger problems than this.
  return join(getDataDir(), 'scratch');
}

/** Read the gitlab connector config without pulling in the connectors
 *  module statically — projects.ts is imported very early by init/dirs;
 *  going through a dynamic require keeps the dependency graph one-way.
 *  Returns null when the connector isn't installed/configured. */
function readGitlabConnector(): { base_url: string; token: string; default_project_path?: string } | null {
  try {
    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const { getInstalledConnector } = require('./connectors/registry') as typeof import('./connectors/registry');
    const inst = getInstalledConnector('gitlab');
    if (!inst) return null;
    const cfg = (inst.config || {}) as { base_url?: string; token?: string; default_project_path?: string };
    if (!cfg.base_url || !cfg.token) return null;
    return {
      base_url: String(cfg.base_url).replace(/\/+$/, ''),
      token: String(cfg.token),
      default_project_path: cfg.default_project_path,
    };
  } catch { return null; }
}

/** Per-process freshness cache for cloned repos. Within this window the
 *  same repo path skips the `git fetch` step and reuses the on-disk
 *  state. Pipelines call resolveOrCloneProject once per node per
 *  for_each iteration — without this, a 5-node × 10-item run does 50
 *  fetches (~50s of network) before any real work starts. */
const CLONE_FRESH_WINDOW_MS = 5 * 60 * 1000;
const _cloneFreshAt = new Map<string, number>();

/** Try to clone (or fast-update) a gitlab project into a Forge-managed
 *  cache dir. Returns the cached LocalProject on success, null when the
 *  connector isn't configured or git fails. Used by resolveOrCloneProject
 *  when no real local project matches the requested name.
 *
 *  Cache layout: <dataDir>/cloned-projects/<owner>-<repo>/
 *  Within the per-process freshness window: skip network entirely.
 *  Past it (or on first call): `git fetch` to refresh refs. */
function tryGitlabClone(repoPath: string): LocalProject | null {
  const gl = readGitlabConnector();
  if (!gl) return null;
  const path = repoPath.replace(/^\/+|\/+$/g, '');
  if (!path || !path.includes('/')) return null;  // not a repo path shape
  const safe = path.replace(/[^A-Za-z0-9._-]+/g, '-');
  const cacheRoot = join(getDataDir(), 'cloned-projects');
  const target = join(cacheRoot, safe);
  try { mkdirSync(cacheRoot, { recursive: true }); } catch {}

  // Inject PAT into URL. gitlab accepts `oauth2:<token>` basic auth on
  // HTTPS. URL-encode the token in case it contains '@' or '/'.
  const cloneUrl = `${gl.base_url}/${path}.git`;
  const authedUrl = cloneUrl.replace(/^(https?:\/\/)/, `$1oauth2:${encodeURIComponent(gl.token)}@`);

  const alreadyCloned = existsSync(join(target, '.git'));
  const lastFreshAt = _cloneFreshAt.get(target) || 0;
  const stillFresh = alreadyCloned && (Date.now() - lastFreshAt) < CLONE_FRESH_WINDOW_MS;

  try {
    if (!alreadyCloned) {
      execSync(`git clone --quiet "${authedUrl}" "${target}"`, { stdio: 'pipe', timeout: 120000 });
      _cloneFreshAt.set(target, Date.now());
    } else if (!stillFresh) {
      // Best-effort refresh; failures (offline, network, auth drift) are
      // non-fatal — fall back to whatever's cached. Pipelines see stale
      // code, not "fatal: not a git repo".
      try {
        execSync(`git -C "${target}" remote set-url origin "${authedUrl}"`, { stdio: 'pipe', timeout: 5000 });
        execSync(`git -C "${target}" fetch --quiet origin`, { stdio: 'pipe', timeout: 30000 });
      } catch { /* keep cached state */ }
      _cloneFreshAt.set(target, Date.now());
    }
    // else: within freshness window — skip network entirely
  } catch (e) {
    console.warn(`[projects] gitlab clone of ${path} failed:`, (e as Error).message);
    return null;
  }

  let mtime: string;
  try { mtime = statSync(target).mtime.toISOString(); }
  catch { mtime = new Date().toISOString(); }
  return {
    name: path,
    path: target,
    root: cacheRoot,
    hasGit: existsSync(join(target, '.git')),
    hasClaudeMd: existsSync(join(target, 'CLAUDE.md')),
    language: null,
    lastModified: mtime,
  };
}

export function resolveOrCloneProject(name: string | undefined): ResolveResult {
  const trimmed = (name || '').trim();
  if (trimmed) {
    const hit = getProjectInfo(trimmed);
    if (hit) return { project: hit, source: 'existing' };
  }

  // Fallback to gitlab connector when no local match. Two cases worth
  // honouring:
  //   1. `name` itself looks like a gitlab repo path (e.g. "team/repo")
  //      — caller meant that path literally.
  //   2. `name` is empty / unknown — pull the gitlab connector's
  //      `default_project_path` (e.g. "fortinet/fortinac-dev") and use
  //      it as the fallback repo.
  // Either way we clone into <dataDir>/cloned-projects/ and reuse on
  // subsequent runs. The previous behaviour silently dropped to scratch
  // and code-fix pipelines exploded with "fatal: not a git repository".
  const gl = readGitlabConnector();
  if (gl) {
    const targetPath = trimmed && trimmed.includes('/')
      ? trimmed
      : (gl.default_project_path || '').trim();
    if (targetPath) {
      const cloned = tryGitlabClone(targetPath);
      if (cloned) return { project: cloned, source: 'gitlab-cloned', clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git` };
    }
  }

  // Last resort: scratch project. Most non-code pipelines (chat
  // dispatch, mantis-only, notifications) are fine here. Code-fix
  // pipelines that try to run git will surface their own errors —
  // pipeline.ts:1795 already skips framework auto-worktree, so the
  // noise is limited to the pipeline's own shell nodes (which need
  // a real project to make sense).
  return { project: scratchProject(), source: 'scratch' };
}

/**
 * Where pipeline worktrees go for a given project.
 *
 *   • Regular project (any `projectRoots` dir) → `<project>/.forge/worktrees/`
 *     — the dot-prefixed `.forge/` keeps Forge's scratch out of the way of
 *     the user's source tree.
 *   • Scratch project (`<dataDir>/scratch/`) → `<dataDir>/scratch/worktrees/`
 *     — scratch already lives under `.forge/data/`, so the inner `.forge/`
 *     would just be a redundant nesting. Flatter path here.
 *
 * Used by pipeline.ts (worktree creation + `{{run.tmp_dir}}`) and
 * pipeline-gc.ts (scan + cleanup).
 */
export function getProjectWorktreeRoot(project: LocalProject): string {
  if (project.name === SCRATCH_PROJECT_NAME) return join(project.path, 'worktrees');
  return join(project.path, '.forge', 'worktrees');
}

export function getProjectClaudeMd(projectPath: string): string | null {
  const claudeMdPath = join(projectPath, 'CLAUDE.md');
  if (!existsSync(claudeMdPath)) return null;
  return readFileSync(claudeMdPath, 'utf-8');
}

/** Register a project root — a directory whose immediate subdirectories Forge
 *  lists as projects (see scanProjects). Idempotent; validates the path is an
 *  existing directory. Returns the normalized root and whether it was already
 *  registered. */
const MAX_ROOT_SUBDIRS = 200;

export function addProjectRoot(rootPath: string): {
  ok: boolean; root: string; error?: string; alreadyRegistered?: boolean;
} {
  const trimmed = (rootPath || '').trim();
  if (!trimmed) return { ok: false, root: '', error: 'Path is required' };
  // Require an absolute path: resolve('') is process.cwd() and a relative path
  // resolves against the server's cwd — neither is a project root the caller meant.
  if (!isAbsolute(trimmed)) return { ok: false, root: trimmed, error: 'Path must be absolute' };
  const root = resolve(trimmed);
  if (!existsSync(root)) return { ok: false, root, error: 'Path does not exist' };
  try {
    if (!statSync(root).isDirectory()) return { ok: false, root, error: 'Path is not a directory' };
  } catch {
    return { ok: false, root, error: 'Path is not accessible' };
  }
  // A root is meant to CONTAIN projects, not be a giant tree. Refuse the
  // filesystem root and absurdly broad dirs so scanProjects stays cheap.
  if (root === parse(root).root) {
    return { ok: false, root, error: 'Refusing to register the filesystem root' };
  }
  let subdirs = 0;
  try {
    for (const e of readdirSync(root, { withFileTypes: true })) {
      if (e.isDirectory() && !e.name.startsWith('.') && ++subdirs > MAX_ROOT_SUBDIRS) break;
    }
  } catch {
    return { ok: false, root, error: 'Path is not accessible' };
  }
  if (subdirs > MAX_ROOT_SUBDIRS) {
    return { ok: false, root, error: `Too many subdirectories (>${MAX_ROOT_SUBDIRS}); point at the folder that directly contains your repos` };
  }
  const settings = loadSettings();
  const roots = settings.projectRoots || [];
  if (roots.includes(root)) return { ok: true, root, alreadyRegistered: true };
  settings.projectRoots = [...roots, root];
  saveSettings(settings);
  invalidateProjectScan();
  return { ok: true, root };
}

/** Unregister a project root. Does not touch files — only stops scanProjects
 *  from listing its subdirectories as projects. */
export function removeProjectRoot(rootPath: string): { ok: boolean; root: string; error?: string } {
  const root = resolve((rootPath || '').trim());
  const settings = loadSettings();
  const roots = settings.projectRoots || [];
  if (!roots.includes(root)) {
    return { ok: false, root, error: `Not a registered root. Registered: ${roots.join(', ') || 'none'}` };
  }
  settings.projectRoots = roots.filter((r) => r !== root);
  saveSettings(settings);
  invalidateProjectScan();
  return { ok: true, root };
}
