import { access, lstat, readFile, readdir } from "node:fs/promises";
import { createHash } from "node:crypto";
import path from "node:path";

import { DEVFLOW_PLUGIN_NAMES, OMP_RUNTIME_VERSION, PRODUCT_VERSION } from "./constants.ts";
import { devflowAgentDir, devflowHome, ompSdkManifestPath, pluginRoots, projectSessionDir } from "./paths.ts";
import type { ProjectContext } from "./project-context.ts";

const RUNTIME_FILES = ["runner.spec.mjs", "playwright.config.mjs", "reporter.mjs", "devflow-test-dsl-v1.schema.json"] as const;
const SHA256 = /^[0-9a-f]{64}$/;
const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const PROFILE_FIELDS = new Set(["profile", "base_url", "api_base_url", "navigation_origins", "api_origins", "subresources", "secret_env", "trace", "screenshots"]);
const RESERVED_ENV = new Set([
  "DEVFLOW_DSL_FILE", "DEVFLOW_DSL_SHA256", "DEVFLOW_TC_ID", "DEVFLOW_TC_SHA256", "DEVFLOW_EXECUTION_ID",
  "DEVFLOW_EXECUTION_NONCE", "DEVFLOW_RESULT_FILE", "DEVFLOW_ARTIFACT_DIR", "DEVFLOW_ENVIRONMENT_FILE",
  "PATH", "HOME", "TMPDIR", "LANG", "PLAYWRIGHT_BROWSERS_PATH", "SHELL", "USER", "PWD",
]);
const RESERVED_ENV_PREFIXES = ["NODE_", "LD_", "DYLD_", "PLAYWRIGHT_", "PYTHON", "BUN_", "NPM_", "DEVFLOW_HOME", "DEVFLOW_KIT", "DEVFLOW_SOFT"];

export interface DoctorCheck {
  id: string;
  ok: boolean;
  message: string;
}

export interface HostDoctorReport {
  ok: boolean;
  checks: DoctorCheck[];
}

async function exists(target: string): Promise<boolean> {
  try {
    await access(target);
    return true;
  } catch {
    return false;
  }
}

function bunVersionOk(version: string): boolean {
  const [major = 0, minor = 0, patch = 0] = version.split(".").map(Number);
  return major > 1 || (major === 1 && (minor > 3 || (minor === 3 && patch >= 14)));
}

function majorVersion(version: string): number {
  return Number((version.match(/v?(\d+)/) || [])[1] || 0);
}

export interface ProbeResult {
  ok: boolean;
  timedOut: boolean;
  exitCode: number | null;
  stdout: string;
  stderr: string;
  /** Human-readable reason when ok is false. */
  error?: string;
}

export interface ProbeOptions {
  timeoutMs: number;
  cwd?: string;
}

/** Runs one external command; must never throw and must always settle. */
export type ProbeRunner = (argv: string[], options: ProbeOptions) => Promise<ProbeResult>;

export interface DoctorOptions {
  probe?: ProbeRunner;
  /** Hard limits. A probe that exceeds them is reported as a failed check, never a pass. */
  timeouts?: { browserLaunchMs?: number; versionMs?: number };
}

const DEFAULT_BROWSER_LAUNCH_TIMEOUT_MS = 20_000;
const DEFAULT_VERSION_TIMEOUT_MS = 10_000;
const PIPE_DRAIN_MS = 500;

function settleWithin<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
  return new Promise(resolve => {
    const timer = setTimeout(() => resolve(fallback), ms);
    promise.then(value => { clearTimeout(timer); resolve(value); }, () => { clearTimeout(timer); resolve(fallback); });
  });
}

/** Async spawn with a hard timeout: the child is SIGKILLed on expiry and the result is marked failed. */
export const spawnProbe: ProbeRunner = async (argv, options) => {
  let child: ReturnType<typeof Bun.spawn>;
  try {
    child = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore", cwd: options.cwd });
  } catch (error) {
    return { ok: false, timedOut: false, exitCode: null, stdout: "", stderr: "", error: error instanceof Error ? error.message : String(error) };
  }
  let timedOut = false;
  const timer = setTimeout(() => {
    timedOut = true;
    try { child.kill("SIGKILL"); } catch { /* already gone */ }
  }, options.timeoutMs);
  const stdoutText = new Response(child.stdout as ReadableStream).text();
  const stderrText = new Response(child.stderr as ReadableStream).text();
  try {
    const exitCode = await child.exited;
    // A grandchild may keep the pipes open after the kill; never wait on them indefinitely.
    const [stdout, stderr] = await Promise.all([
      settleWithin(stdoutText, timedOut ? PIPE_DRAIN_MS : 5_000, ""),
      settleWithin(stderrText, timedOut ? PIPE_DRAIN_MS : 5_000, ""),
    ]);
    if (timedOut) {
      return { ok: false, timedOut: true, exitCode: null, stdout: stdout.trim(), stderr: stderr.trim(), error: `timed out after ${options.timeoutMs} ms and was killed` };
    }
    return { ok: exitCode === 0, timedOut: false, exitCode, stdout: stdout.trim(), stderr: stderr.trim(), error: exitCode === 0 ? undefined : `exit code ${exitCode}` };
  } catch (error) {
    return { ok: false, timedOut, exitCode: null, stdout: "", stderr: "", error: error instanceof Error ? error.message : String(error) };
  } finally {
    clearTimeout(timer);
  }
};

/** Wraps a probe so that even a misbehaving runner cannot block the caller past its deadline. */
async function guardedProbe(probe: ProbeRunner, argv: string[], options: ProbeOptions): Promise<ProbeResult> {
  const deadline = options.timeoutMs + Math.min(2_000, Math.max(50, options.timeoutMs));
  const fallback: ProbeResult = { ok: false, timedOut: true, exitCode: null, stdout: "", stderr: "", error: `timed out after ${options.timeoutMs} ms` };
  try {
    const result = await settleWithin(probe(argv, options), deadline, fallback);
    // A timed-out probe can never count as a pass, whatever the runner claims.
    return result.timedOut ? { ...result, ok: false } : result;
  } catch (error) {
    return { ...fallback, timedOut: false, error: error instanceof Error ? error.message : String(error) };
  }
}

function probeFailure(result: ProbeResult): string {
  const detail = (result.stderr || result.stdout).split("\n")[0]?.trim();
  return [result.error, detail].filter(Boolean).join(": ") || "probe failed";
}

export async function verifyTestRuntime(runtimeRoot: string): Promise<{ ok: boolean; message: string; playwrightVersion?: string }> {
  const manifestPath = path.join(runtimeRoot, "runtime-manifest.json");
  try {
    const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Record<string, unknown>;
    const files = manifest.files && typeof manifest.files === "object" ? manifest.files as Record<string, unknown> : {};
    const errors: string[] = [];
    if (manifest.schema_version !== 1) errors.push("schema_version must be 1");
    if (typeof manifest.runtime_version !== "string" || !manifest.runtime_version) errors.push("runtime_version is missing");
    if (typeof manifest.playwright_version !== "string" || !/^\d+\.\d+\.\d+$/.test(manifest.playwright_version)) errors.push("playwright_version is invalid");
    for (const name of RUNTIME_FILES) {
      const expected = files[name];
      const target = path.join(runtimeRoot, name);
      const stat = await lstat(target).catch(() => null);
      if (typeof expected !== "string" || !SHA256.test(expected)) errors.push(`manifest does not pin ${name}`);
      else if (!stat?.isFile() || stat.isSymbolicLink()) errors.push(`runtime file is missing or unsafe: ${name}`);
      else if (createHash("sha256").update(await readFile(target)).digest("hex") !== expected) errors.push(`runtime file hash mismatch: ${name}`);
    }
    if (!errors.length) {
      const reporter = await readFile(path.join(runtimeRoot, "reporter.mjs"), "utf8");
      const embedded = /RUNTIME_VERSION\s*=\s*["']([^"']+)["']/.exec(reporter)?.[1];
      if (embedded !== manifest.runtime_version) errors.push(`runtime_version ${String(manifest.runtime_version)} does not match reporter ${String(embedded)}`);
    }
    return { ok: !errors.length, message: errors.length ? errors.join("; ") : manifestPath, playwrightVersion: typeof manifest.playwright_version === "string" ? manifest.playwright_version : undefined };
  } catch (error) {
    return { ok: false, message: `invalid manifest: ${error instanceof Error ? error.message : String(error)}` };
  }
}

export function playwrightVersionMatches(installed: string, manifest: string | undefined): boolean {
  return /^\d+\.\d+\.\d+$/.test(installed) && installed === manifest;
}

function exactOrigin(value: unknown): string | null {
  if (typeof value !== "string") return null;
  try {
    const url = new URL(value);
    if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null;
    return `${url.protocol}//${url.host}` === value ? value : null;
  } catch { return null; }
}

export async function validateEnvironmentProfile(file: string): Promise<string[]> {
  try {
    const stat = await lstat(file);
    if (!stat.isFile() || stat.isSymbolicLink()) return ["profile must be a regular non-symlink file"];
    const profile = JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
    const name = path.basename(file, ".json");
    const errors: string[] = [];
    if (!profile || typeof profile !== "object" || Array.isArray(profile)) return ["profile root must be an object"];
    if (!PROFILE_NAME.test(name)) errors.push(`invalid environment profile name: ${name}`);
    for (const key of Object.keys(profile)) if (!PROFILE_FIELDS.has(key)) errors.push(`profile field is not allowed: ${key}`);
    if (profile.profile !== name) errors.push("profile field must equal file name");
    for (const key of ["navigation_origins", "api_origins"] as const) {
      const values = key === "api_origins" && !(key in profile) ? [] : profile[key];
      if (!Array.isArray(values) || (key === "navigation_origins" && !values.length)) errors.push(`${key} must be ${key === "navigation_origins" ? "a non-empty " : "an "}array`);
      else {
        if (values.some(value => !exactOrigin(value))) errors.push(`${key} contains an invalid origin`);
        if (new Set(values).size !== values.length) errors.push(`${key} contains duplicate origins`);
      }
    }
    const navigation = Array.isArray(profile.navigation_origins) ? profile.navigation_origins : [];
    const api = Array.isArray(profile.api_origins) ? profile.api_origins : [];
    for (const [key, allowed] of [["base_url", navigation], ["api_base_url", api]] as const) {
      if (key === "api_base_url" && !(key in profile)) continue;
      if (typeof profile[key] !== "string") errors.push(`${key} must be an absolute http(s) URL`);
      else {
        try { if (!allowed.includes(new URL(profile[key]).origin)) errors.push(`${key} origin is not declared`); }
        catch { errors.push(`${key} must be an absolute http(s) URL`); }
      }
    }
    const subresources = profile.subresources ?? { policy: "same-site-plus-declared", allowed_origins: [] };
    if (!subresources || typeof subresources !== "object" || Array.isArray(subresources)) errors.push("subresources must be an object");
    else {
      const value = subresources as Record<string, unknown>;
      if (Object.keys(value).some(key => !["policy", "allowed_origins"].includes(key))) errors.push("subresources contains an unsupported field");
      if (!["same-site-plus-declared", "declared-only"].includes(String(value.policy))) errors.push("subresources.policy is invalid");
      const allowed = value.allowed_origins;
      if (!Array.isArray(allowed) || allowed.some(origin => !exactOrigin(origin))) errors.push("subresources.allowed_origins contains an invalid origin");
    }
    const secrets = profile.secret_env ?? {};
    if (!secrets || typeof secrets !== "object" || Array.isArray(secrets)) errors.push("secret_env must be an object");
    else for (const [key, value] of Object.entries(secrets)) {
      if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(key)) errors.push(`secret_env key is invalid: ${key}`);
      if (typeof value !== "string" || !/^[A-Z_][A-Z0-9_]{0,127}$/.test(value)) errors.push(`secret_env.${key} must name an upper-case environment variable`);
      else if (RESERVED_ENV.has(value) || RESERVED_ENV_PREFIXES.some(prefix => value.startsWith(prefix))) errors.push(`secret_env.${key} uses a reserved environment variable`);
    }
    for (const key of ["trace", "screenshots"] as const) if (key in profile && !["on_failure", "off"].includes(String(profile[key]))) errors.push(`${key} is invalid`);
    return errors;
  } catch (error) {
    return [`invalid profile: ${error instanceof Error ? error.message : String(error)}`];
  }
}

export async function runHostDoctor(project: ProjectContext, options: DoctorOptions = {}): Promise<HostDoctorReport> {
  const checks: DoctorCheck[] = [];
  const add = (id: string, ok: boolean, message: string) => checks.push({ id, ok, message });
  const probe = options.probe ?? spawnProbe;
  const versionMs = options.timeouts?.versionMs ?? DEFAULT_VERSION_TIMEOUT_MS;
  const browserMs = options.timeouts?.browserLaunchMs ?? DEFAULT_BROWSER_LAUNCH_TIMEOUT_MS;
  add("branch-runtime", true, `devflow-pi ${PRODUCT_VERSION}; OMP SDK ${OMP_RUNTIME_VERSION}`);
  add("bun", bunVersionOk(Bun.version), bunVersionOk(Bun.version) ? Bun.version : `${Bun.version}; requires >=1.3.14`);
  const node = await guardedProbe(probe, ["node", "--version"], { timeoutMs: versionMs });
  const nodeOk = node.ok && majorVersion(node.stdout) >= 20;
  add("node", nodeOk, node.ok ? `${node.stdout}; requires >=20` : `missing or unusable: ${probeFailure(node)}`);
  add("project-root", path.isAbsolute(project.root), project.root);
  add("project-id", Boolean(project.manifest.projectId), project.manifest.projectId);
  const planningStat = await lstat(project.planningRoot).catch(() => null);
  add("planning-root", Boolean(planningStat?.isDirectory() && !planningStat.isSymbolicLink()), project.planningRoot);
  const roots = pluginRoots();
  for (let index = 0; index < DEVFLOW_PLUGIN_NAMES.length; index += 1) {
    const plugin = DEVFLOW_PLUGIN_NAMES[index];
    const root = roots[index];
    add(`plugin:${plugin}`, await exists(path.join(root, "skills")), root);
  }
  const qualityRoot = roots[DEVFLOW_PLUGIN_NAMES.indexOf("devflow-quality")];
  // Never fall back to a relative path: verifyTestRuntime("") would read ./runtime-manifest.json from the cwd.
  const runtimeRoot = qualityRoot ? path.join(qualityRoot, "runtime", "playwright") : null;
  const runtime = runtimeRoot
    ? await verifyTestRuntime(runtimeRoot)
    : { ok: false, message: "devflow-quality plugin root is unknown", playwrightVersion: undefined };
  add("test-runtime-manifest", runtime.ok, runtime.message);
  let installedPlaywright: ProbeResult | null = null;
  if (runtime.ok && runtimeRoot) {
    installedPlaywright = await guardedProbe(
      probe,
      ["node", "-e", "process.stdout.write(require(require.resolve('@playwright/test/package.json',{paths:[process.argv[1]]})).version)", runtimeRoot],
      { timeoutMs: versionMs },
    );
  }
  const versionOk = Boolean(installedPlaywright?.ok && playwrightVersionMatches(installedPlaywright.stdout, runtime.playwrightVersion));
  add("test-runtime-playwright-version", versionOk,
    !installedPlaywright ? "skipped: Runtime manifest is invalid"
      : !installedPlaywright.ok ? `Playwright version probe failed: ${probeFailure(installedPlaywright)}`
        : `${installedPlaywright.stdout}; manifest ${runtime.playwrightVersion}${versionOk ? "" : " (mismatch)"}`);
  let chromiumOk = false;
  let chromiumMessage = "not checked: Node >=20 and a valid Runtime manifest are required";
  if (nodeOk && runtime.ok && runtimeRoot) {
    const launch = await guardedProbe(
      probe,
      ["node", "-e", `const {chromium}=require(require.resolve('@playwright/test',{paths:[process.argv[1]]}));chromium.launch({timeout:${Math.max(1000, browserMs - 2000)}}).then(b=>b.close()).then(()=>process.exit(0),e=>{console.error(e.message);process.exit(1)})`, runtimeRoot],
      { timeoutMs: browserMs },
    );
    chromiumOk = launch.ok && !launch.timedOut;
    chromiumMessage = chromiumOk ? "launchable"
      : launch.timedOut ? `Chromium launch timed out after ${browserMs} ms and was killed; the browser is not launchable`
        : `Chromium is not launchable: ${probeFailure(launch)}`;
  }
  add("test-runtime-chromium", chromiumOk, chromiumMessage);
  const environmentRoot = path.join(project.planningRoot, "test-environments");
  const environmentStat = await lstat(environmentRoot).catch(() => null);
  let profiles: string[] = [];
  if (environmentStat?.isDirectory() && !environmentStat.isSymbolicLink()) {
    profiles = (await readdir(environmentRoot)).filter(name => name.endsWith(".json") && !name.endsWith(".example.json"));
  }
  const profileErrors: string[] = [];
  for (const profile of profiles) for (const error of await validateEnvironmentProfile(path.join(environmentRoot, profile))) profileErrors.push(`${profile}: ${error}`);
  add("test-environments", profiles.length > 0 && !profileErrors.length,
    !profiles.length ? `${environmentRoot}: configure a profile from local.example.json` : profileErrors.length ? profileErrors.join("; ") : `${environmentRoot}: ${profiles.join(", ")}`);
  const runsRoot = path.join(project.root, ".devflow", "runs");
  const runsStat = await lstat(runsRoot).catch(() => null);
  if (!runsStat?.isDirectory() || runsStat.isSymbolicLink()) {
    add("test-runs-root", false, `${runsRoot}: missing, not a directory, or a symlink`);
  } else {
    // Run directories hold traces, screenshots and result files and must never enter source control.
    const ignored = await guardedProbe(probe, ["git", "-C", project.root, "check-ignore", "-q", "--no-index", ".devflow/runs/doctor-probe/x"], { timeoutMs: versionMs });
    if (ignored.ok) add("test-runs-root", true, `${runsRoot} (git-ignored)`);
    else if (ignored.exitCode === 1) add("test-runs-root", false, `${runsRoot} is not git-ignored; add .devflow/runs/ to .gitignore (run artifacts may contain secrets)`);
    else if (ignored.exitCode === 128 && /not a git repository/i.test(ignored.stderr)) add("test-runs-root", true, `${runsRoot} (project is not a git repository; ignore rule not applicable)`);
    else add("test-runs-root", false, `cannot verify that ${runsRoot} is git-ignored: ${probeFailure(ignored)}`);
  }
  const sdkManifest = ompSdkManifestPath();
  let installedVersion = "missing";
  if (await exists(sdkManifest)) {
    try {
      const parsed = JSON.parse(await readFile(sdkManifest, "utf8")) as { version?: unknown };
      installedVersion = typeof parsed.version === "string" && parsed.version ? parsed.version : "unknown";
    } catch {
      installedVersion = "invalid manifest";
    }
  }
  add("omp-sdk", installedVersion === OMP_RUNTIME_VERSION, installedVersion);
  add("agent-dir", true, devflowAgentDir());
  add("session-isolation", true, projectSessionDir(project.manifest.projectId));
  add("devflow-home", true, devflowHome());
  const mcpConfig = path.join(devflowHome(), "mcp.json");
  if (!(await exists(mcpConfig))) add("mcp", true, "MCP 未配置");
  else {
    try {
      const config = JSON.parse(await readFile(mcpConfig, "utf8")) as Record<string, unknown>;
      const valid = config.schema_version === 1 && config.clients !== null && typeof config.clients === "object" && !Array.isArray(config.clients);
      add("mcp", valid, valid ? `${mcpConfig} (schema_version 1)` : `${mcpConfig}: schema_version must be 1 and clients must be an object`);
    } catch (error) { add("mcp", false, `${mcpConfig}: invalid JSON: ${error instanceof Error ? error.message : String(error)}`); }
  }
  return { ok: checks.every(check => check.ok), checks };
}

export function formatDoctor(report: HostDoctorReport): string {
  return [
    `Devflow Pi doctor: ${report.ok ? "PASS" : "FAIL"}`,
    ...report.checks.map(check => ` [${check.ok ? "PASS" : "FAIL"}] ${check.id}: ${check.message}`),
  ].join("\n");
}
