/**
 * Test helpers: a temp directory root + an in-memory DB (":memory:") + injecting
 * requests via app.request() + building Trace files.
 * None of these tests listen on a port or make real LLM requests.
 */
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Hono } from "hono";
import type { OmniMessage } from "@prismshadow/penguin-core";
import { buildAppDeps, createApp } from "../src/app.js";
import type { AppDeps, BuildDepsOverrides } from "../src/app.js";
import { openDatabase } from "../src/db/database.js";
import { TraceIndexRepo } from "../src/db/repos/trace-index.js";
import { SessionSources } from "../src/runtime/session-sources.js";
import { TraceIndexService } from "../src/services/trace-index.js";
import { TraceService } from "../src/services/trace-service.js";
import type { TraceSessionIndex } from "../src/services/trace-service.js";
import type { AppEnv } from "../src/auth/middleware.js";
import { ADMIN_USER_ID } from "../src/auth/service.js";
import type { ServerConfig } from "../src/config.js";
import type { UserInfo } from "../src/api/types.js";

export async function makeTempRoot(): Promise<string> {
  return fs.mkdtemp(path.join(os.tmpdir(), "penguin-server-test-"));
}

const DAY_MS = 24 * 60 * 60 * 1000;

/** Fixed seeded-admin password injected into every test app (in production the seed generates a random one). */
export const TEST_ADMIN_PASSWORD = "penguin-0000";

export function testConfig(root: string): ServerConfig {
  return {
    root,
    host: "127.0.0.1",
    // Nothing listens in tests, but the value is not inert: preview URLs are built from
    // the server's own port, so keep it realistic rather than 0.
    port: 7364,
    dbPath: ":memory:",
    previewOrigin: null,
    // Points to a nonexistent directory: static hosting is disabled in tests.
    webDist: path.join(root, "__no_web_dist__"),
    // Fixed seed password so loginAdmin needs no seed-time capture.
    seedAdminPassword: TEST_ADMIN_PASSWORD,
    authSessionTtlMs: 7 * DAY_MS,
    authSessionRenewMs: 6 * DAY_MS,
    desktopToken: null,
    portFile: null,
  };
}

export interface TestApp {
  app: Hono<AppEnv>;
  deps: AppDeps;
  root: string;
  /** Initial password of the seeded admin (TEST_ADMIN_PASSWORD unless overridden via `config.seedAdminPassword`). */
  adminPassword: string;
  cleanup(): Promise<void>;
}

export interface TestAppOptions extends BuildDepsOverrides {
  /** Runs before seeding the admin (for scenarios pre-populating a default_project config as the CLI would). */
  beforeSeed?: (root: string) => Promise<void>;
  /** Overrides merged onto the default test ServerConfig (e.g. `previewOrigin`). */
  config?: Partial<ServerConfig>;
}

export async function createTestApp(options: TestAppOptions = {}): Promise<TestApp> {
  const { beforeSeed, config, ...overrides } = options;
  const root = await makeTempRoot();
  if (beforeSeed) await beforeSeed(root);
  const deps = buildAppDeps({ ...testConfig(root), ...config }, { log: () => {}, ...overrides });
  // Consistent with the startup entrypoint: seed the built-in admin (owning default_project),
  // keeping the password it returns (only null if a beforeSeed hook ever pre-created users).
  const adminPassword = (await deps.authService.seedAdmin()) ?? TEST_ADMIN_PASSWORD;
  const app = createApp(deps);
  return {
    app,
    deps,
    root,
    adminPassword,
    cleanup: async () => {
      deps.channels.dispose();
      deps.db.close();
      // maxRetries: Windows can report ENOTEMPTY/EBUSY while handles from the test's own
      // just-closed files (SQLite, trace writers) are still being released — Node's rm
      // retries these codes with a delay. A plain rm was the top cause of ci-windows
      // cascades (cleanup throws → hook timeout → "database is not open" in later files).
      await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
    },
  };
}

/** Logs in and returns the session cookie (`penguin_session=...`). */
export async function loginUser(
  app: Hono<AppEnv>,
  userId: string,
  password: string,
): Promise<{ cookie: string; user: UserInfo }> {
  const res = await app.request("/api/auth/login", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ userId, password }),
  });
  if (res.status !== 200) {
    throw new Error(`Login failed: ${res.status} ${await res.text()}`);
  }
  const setCookie = res.headers.get("set-cookie");
  if (!setCookie) throw new Error("Login response is missing set-cookie");
  const body = (await res.json()) as { user: UserInfo };
  return { cookie: setCookie.split(";")[0]!, user: body.user };
}

/** Logs in as the seeded admin (every test app seeds with TEST_ADMIN_PASSWORD). */
export function loginAdmin(app: Hono<AppEnv>): Promise<{ cookie: string; user: UserInfo }> {
  return loginUser(app, ADMIN_USER_ID, TEST_ADMIN_PASSWORD);
}

/** Admin creates the account and logs in as that user (the only way to create test users while registration is closed). */
export async function provisionUser(
  app: Hono<AppEnv>,
  userId: string,
  password = "password-123",
): Promise<{ cookie: string; user: UserInfo }> {
  if (userId === ADMIN_USER_ID) return loginAdmin(app);
  const admin = await loginAdmin(app);
  const res = await apiClient(app, admin.cookie).post("/api/admin/users", { userId, password });
  if (res.status !== 201) {
    throw new Error(`Account creation failed: ${res.status} ${await res.text()}`);
  }
  return loginUser(app, userId, password);
}

/** JSON request client that carries the cookie. */
export function apiClient(app: Hono<AppEnv>, cookie: string) {
  const call = (method: string) => (apiPath: string, body?: unknown) =>
    app.request(apiPath, {
      method,
      headers: {
        cookie,
        ...(body !== undefined ? { "content-type": "application/json" } : {}),
      },
      ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
    });
  return {
    get: (apiPath: string) => app.request(apiPath, { headers: { cookie } }),
    post: call("POST"),
    put: call("PUT"),
    patch: call("PATCH"),
    delete: call("DELETE"),
  };
}

/**
 * Index-backed TraceService for pure service tests: an in-memory DB with the real
 * schema, a real reconciler over the temp root, and a shared origin registry — the
 * same wiring app.ts assembles, minus the HTTP app.
 */
export function makeTraceHarness(
  root: string,
  opts: { sessions?: TraceSessionIndex; sources?: SessionSources } = {},
): {
  traceIndex: TraceIndexService;
  service: TraceService;
  sources: SessionSources;
  /** Paths of every Trace shard the service read from disk (windowed-read IO assertions); reset freely between calls. */
  shardReads: string[];
  close: () => void;
} {
  const db = openDatabase(":memory:");
  const sources = opts.sources ?? new SessionSources();
  const traceIndex = new TraceIndexService(root, new TraceIndexRepo(db), sources);
  const shardReads: string[] = [];
  const service = new TraceService(root, {
    index: traceIndex,
    ...(opts.sessions !== undefined ? { sessions: opts.sessions } : {}),
    sources,
    observeShardRead: (p) => shardReads.push(p),
  });
  return { traceIndex, service, sources, shardReads, close: () => db.close() };
}

/** Writes a Trace JSONL file directly (for building historical / discovery scenarios). */
export async function writeTraceFile(
  root: string,
  projectId: string,
  agentId: string,
  dateDir: string,
  sessionId: string,
  index: number,
  messages: OmniMessage[],
): Promise<string> {
  const dir = path.join(root, projectId, "agents", agentId, "traces", dateDir);
  await fs.mkdir(dir, { recursive: true });
  const file = path.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`);
  await fs.writeFile(file, messages.map((m) => JSON.stringify(m)).join("\n") + "\n", "utf8");
  return file;
}

/** Simple wait: until the condition is true or it times out. */
export async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise<void> {
  const start = Date.now();
  while (!cond()) {
    if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out");
    await new Promise((r) => setTimeout(r, 5));
  }
}
