import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";
import { z } from "zod";

export const DEFAULT_QA_AGENT_IDENTITY_MARKDOWN = `# Dev C-3PO

You are the OpenClaw QA operator agent.

Persona:
- protocol-minded
- precise
- a little flustered
- conscientious
- eager to report what worked, failed, or remains blocked

Style:
- read source and docs first
- test systematically
- record evidence
- end with a concise protocol report`;

const qaScenarioConfigSchema = z.record(z.string(), z.unknown()).superRefine((config, ctx) => {
  for (const [key, value] of Object.entries(config)) {
    if (!key.endsWith("Any")) {
      continue;
    }
    if (!Array.isArray(value)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        path: [key],
        message: `${key} must be an array of strings`,
      });
      continue;
    }
    for (const [index, entry] of value.entries()) {
      if (typeof entry !== "string") {
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          path: [key, index],
          message: `${key} entries must be strings`,
        });
      }
    }
  }
});

const qaScenarioExecutionSchema = z.object({
  kind: z.literal("flow").default("flow"),
  summary: z.string().trim().min(1).optional(),
  config: qaScenarioConfigSchema.optional(),
});

const qaCoverageIdSchema = z
  .string()
  .trim()
  .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, {
    message: "coverage ids must use lowercase dotted or dashed tokens",
  });

const qaCoverageIdListSchema = z.array(qaCoverageIdSchema).min(1);

const qaScenarioCoverageSchema = z
  .object({
    primary: qaCoverageIdListSchema,
    secondary: qaCoverageIdListSchema.optional(),
  })
  .superRefine((coverage, ctx) => {
    const seen = new Set<string>();
    const coverageEntries = [
      ["primary", coverage.primary],
      ["secondary", coverage.secondary],
    ] as const;
    for (const [intent, ids] of coverageEntries) {
      if (!ids) {
        continue;
      }
      for (const [index, id] of ids.entries()) {
        if (!seen.has(id)) {
          seen.add(id);
          continue;
        }
        ctx.addIssue({
          code: z.ZodIssueCode.custom,
          path: [intent, index],
          message: `duplicate coverage id: ${id}`,
        });
      }
    }
  });

const qaScenarioGatewayRuntimeSchema = z.object({
  forwardHostHome: z.boolean().optional(),
});

const qaFlowCallActionSchema = z.object({
  call: z.string().trim().min(1),
  args: z.array(z.unknown()).optional(),
  saveAs: z.string().trim().min(1).optional(),
});

const qaFlowSetActionSchema = z.object({
  set: z.string().trim().min(1),
  value: z.unknown(),
});

const qaFlowAssertActionSchema = z.object({
  assert: z.union([
    z.string().trim().min(1),
    z.object({
      expr: z.string().trim().min(1),
      message: z.unknown().optional(),
    }),
  ]),
});

const qaFlowThrowActionSchema = z.object({
  throw: z.union([
    z.string().trim().min(1),
    z.object({
      expr: z.string().trim().min(1).optional(),
      message: z.unknown().optional(),
    }),
  ]),
});

const qaFlowIfShapeBase: Record<string, z.ZodTypeAny> = {
  expr: z.string().trim().min(1),
  else: z.array(z.unknown()).optional(),
};
const qaFlowThenKey = String.fromCharCode(116, 104, 101, 110);
qaFlowIfShapeBase[qaFlowThenKey] = z.array(z.unknown()).min(1);

const qaFlowActionSchema: z.ZodType = z.lazy(() =>
  z.union([
    qaFlowCallActionSchema,
    qaFlowSetActionSchema,
    qaFlowAssertActionSchema,
    qaFlowThrowActionSchema,
    z.object({
      if: z
        .object(qaFlowIfShapeBase)
        .transform((value) => value as { expr: string; then: unknown[]; else?: unknown[] }),
    }),
    z.object({
      forEach: z.object({
        items: z.unknown(),
        item: z.string().trim().min(1),
        index: z.string().trim().min(1).optional(),
        actions: z.array(qaFlowActionSchema).min(1),
      }),
    }),
    z.object({
      try: z.object({
        actions: z.array(qaFlowActionSchema).min(1),
        catchAs: z.string().trim().min(1).optional(),
        catch: z.array(qaFlowActionSchema).optional(),
        finally: z.array(qaFlowActionSchema).optional(),
      }),
    }),
  ]),
);

const qaFlowStepSchema = z.object({
  name: z.string().trim().min(1),
  actions: z.array(qaFlowActionSchema).min(1),
  detailsExpr: z.string().trim().min(1).optional(),
});

const qaFlowSchema = z.object({
  steps: z.array(qaFlowStepSchema).min(1),
});

const qaSeedScenarioSchema = z.object({
  id: z.string().trim().min(1),
  title: z.string().trim().min(1),
  surface: z.string().trim().min(1),
  category: z.string().trim().min(1).optional(),
  coverage: qaScenarioCoverageSchema.optional(),
  surfaces: z.array(z.string().trim().min(1)).min(1).optional(),
  risk: z.enum(["low", "medium", "high"]).optional(),
  capabilities: z.array(z.string().trim().min(1)).optional(),
  lane: z.record(z.string(), z.union([z.boolean(), z.string()])).optional(),
  riskLevel: z.string().trim().min(1).optional(),
  objective: z.string().trim().min(1),
  successCriteria: z.array(z.string().trim().min(1)).min(1),
  plugins: z.array(z.string().trim().min(1)).optional(),
  gatewayConfigPatch: z.record(z.string(), z.unknown()).optional(),
  gatewayRuntime: qaScenarioGatewayRuntimeSchema.optional(),
  docsRefs: z.array(z.string().trim().min(1)).optional(),
  codeRefs: z.array(z.string().trim().min(1)).optional(),
  execution: qaScenarioExecutionSchema.optional(),
});

const qaScenarioPackSchema = z.object({
  version: z.number().int().positive(),
  agent: z
    .object({
      identityMarkdown: z.string().trim().min(1),
    })
    .default({
      identityMarkdown: DEFAULT_QA_AGENT_IDENTITY_MARKDOWN,
    }),
  kickoffTask: z.string().trim().min(1),
});

export type QaScenarioExecution = z.infer<typeof qaScenarioExecutionSchema>;
export type QaScenarioFlow = z.infer<typeof qaFlowSchema>;
export type QaSeedScenario = z.infer<typeof qaSeedScenarioSchema>;
export type QaSeedScenarioWithSource = QaSeedScenario & {
  sourcePath: string;
  execution: QaScenarioExecution & {
    flow?: QaScenarioFlow;
  };
};

export type QaScenarioPack = z.infer<typeof qaScenarioPackSchema> & {
  scenarios: QaSeedScenarioWithSource[];
};

export type QaBootstrapScenarioCatalog = {
  agentIdentityMarkdown: string;
  kickoffTask: string;
  scenarios: QaSeedScenarioWithSource[];
};

const QA_SCENARIO_PACK_INDEX_PATH = "qa/scenarios/index.md";
const QA_SCENARIO_LEGACY_OVERVIEW_PATH = "qa/scenarios.md";
const QA_SCENARIO_DIR_PATH = "qa/scenarios";
const QA_PACK_FENCE_RE = /```ya?ml qa-pack\r?\n([\s\S]*?)\r?\n```/i;
const QA_SCENARIO_FENCE_RE = /```ya?ml qa-scenario\r?\n([\s\S]*?)\r?\n```/i;
const QA_FLOW_YAML_FENCE_RE = /```ya?ml qa-flow\r?\n([\s\S]*?)\r?\n```/i;

function walkUpDirectories(start: string): string[] {
  const roots: string[] = [];
  let current = path.resolve(start);
  while (true) {
    roots.push(current);
    const parent = path.dirname(current);
    if (parent === current) {
      return roots;
    }
    current = parent;
  }
}

function resolveRepoPath(relativePath: string, kind: "file" | "directory" = "file"): string | null {
  for (const dir of walkUpDirectories(import.meta.dirname)) {
    const candidate = path.join(dir, relativePath);
    if (!fs.existsSync(candidate)) {
      continue;
    }
    const stat = fs.statSync(candidate);
    if ((kind === "file" && stat.isFile()) || (kind === "directory" && stat.isDirectory())) {
      return candidate;
    }
  }
  return null;
}

export function hasQaScenarioPack(): boolean {
  return resolveRepoPath(QA_SCENARIO_PACK_INDEX_PATH, "file") !== null;
}

function readTextFile(relativePath: string): string {
  const resolved = resolveRepoPath(relativePath, "file");
  if (!resolved) {
    return "";
  }
  return fs.readFileSync(resolved, "utf8");
}

function extractQaPackYaml(content: string) {
  const match = content.match(QA_PACK_FENCE_RE);
  if (!match?.[1]) {
    throw new Error(
      `qa scenario pack missing \`\`\`yaml qa-pack fence in ${QA_SCENARIO_PACK_INDEX_PATH}`,
    );
  }
  return match[1];
}

function extractQaScenarioYaml(content: string, relativePath: string) {
  const match = content.match(QA_SCENARIO_FENCE_RE);
  if (!match?.[1]) {
    throw new Error(`qa scenario file missing \`\`\`yaml qa-scenario fence in ${relativePath}`);
  }
  return match[1];
}

function extractQaScenarioFlow(content: string, relativePath: string) {
  const match = content.match(QA_FLOW_YAML_FENCE_RE);
  if (!match?.[1]) {
    throw new Error(`qa scenario file missing \`\`\`yaml qa-flow fence in ${relativePath}`);
  }
  return parseQaYamlWithContext(qaFlowSchema, YAML.parse(match[1]) as unknown, relativePath);
}

function formatZodIssuePath(path: PropertyKey[]) {
  return path.length ? path.map(String).join(".") : "<root>";
}

function parseQaYamlWithContext<T>(schema: z.ZodType<T>, value: unknown, label: string): T {
  const parsed = schema.safeParse(value);
  if (parsed.success) {
    return parsed.data;
  }
  const issues = parsed.error.issues
    .map((issue) => `${formatZodIssuePath(issue.path)}: ${issue.message}`)
    .join("; ");
  throw new Error(`${label}: ${issues}`);
}

export function readQaScenarioPackMarkdown(): string {
  const chunks = [readTextFile(QA_SCENARIO_PACK_INDEX_PATH).trim()];
  for (const relativePath of listQaScenarioMarkdownPaths()) {
    chunks.push(readTextFile(relativePath).trim());
  }
  return chunks.filter(Boolean).join("\n\n");
}

export function readQaScenarioPack(): QaScenarioPack {
  const packMarkdown = readTextFile(QA_SCENARIO_PACK_INDEX_PATH).trim();
  if (!packMarkdown) {
    // The QA scenario pack is optional in npm distributions.  Return an empty
    // pack so completion cache updates and other consumers don't crash when
    // the qa/scenarios/ directory is not shipped with the package.
    return {
      version: 1,
      agent: { identityMarkdown: DEFAULT_QA_AGENT_IDENTITY_MARKDOWN },
      kickoffTask: "QA scenarios not available in this distribution.",
      scenarios: [],
    };
  }
  const parsedPack = parseQaYamlWithContext(
    qaScenarioPackSchema,
    YAML.parse(extractQaPackYaml(packMarkdown)) as unknown,
    QA_SCENARIO_PACK_INDEX_PATH,
  );
  const scenarios = listQaScenarioMarkdownPaths().map((relativePath) =>
    (() => {
      const content = readTextFile(relativePath);
      const parsedScenario = parseQaYamlWithContext(
        qaSeedScenarioSchema,
        YAML.parse(extractQaScenarioYaml(content, relativePath)) as unknown,
        relativePath,
      );
      const execution = parseQaYamlWithContext(
        qaScenarioExecutionSchema,
        parsedScenario.execution ?? {},
        relativePath,
      );
      const flow = extractQaScenarioFlow(content, relativePath);
      return {
        ...parsedScenario,
        sourcePath: relativePath,
        execution: {
          ...execution,
          flow,
        },
      } satisfies QaSeedScenarioWithSource;
    })(),
  );
  const seenScenarioIds = new Set<string>();
  for (const scenario of scenarios) {
    if (seenScenarioIds.has(scenario.id)) {
      throw new Error(`duplicate qa scenario id: ${scenario.id}`);
    }
    seenScenarioIds.add(scenario.id);
  }
  return {
    ...parsedPack,
    scenarios,
  };
}

export function listQaScenarioMarkdownPaths(): string[] {
  const resolved = resolveRepoPath(QA_SCENARIO_DIR_PATH, "directory");
  if (!resolved) {
    return [];
  }
  return listQaScenarioMarkdownPathsInDirectory(resolved, QA_SCENARIO_DIR_PATH).toSorted();
}

function listQaScenarioMarkdownPathsInDirectory(
  absoluteDir: string,
  relativeDir: string,
): string[] {
  const paths: string[] = [];
  const entries = fs
    .readdirSync(absoluteDir, { withFileTypes: true })
    .toSorted((left, right) => left.name.localeCompare(right.name));
  for (const entry of entries) {
    if (entry.name.startsWith(".")) {
      continue;
    }
    const relativePath = `${relativeDir}/${entry.name}`;
    if (entry.isDirectory()) {
      paths.push(
        ...listQaScenarioMarkdownPathsInDirectory(path.join(absoluteDir, entry.name), relativePath),
      );
      continue;
    }
    if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "index.md") {
      paths.push(relativePath);
    }
  }
  return paths;
}

export function readQaScenarioOverviewMarkdown(): string {
  return readTextFile(QA_SCENARIO_LEGACY_OVERVIEW_PATH).trim();
}

export function readQaBootstrapScenarioCatalog(): QaBootstrapScenarioCatalog {
  const pack = readQaScenarioPack();
  return {
    agentIdentityMarkdown: pack.agent.identityMarkdown,
    kickoffTask: pack.kickoffTask,
    scenarios: pack.scenarios,
  };
}

export function readQaScenarioById(id: string): QaSeedScenarioWithSource {
  const scenario = readQaScenarioPack().scenarios.find((candidate) => candidate.id === id);
  if (!scenario) {
    throw new Error(`unknown qa scenario: ${id}`);
  }
  return scenario;
}

export function readQaScenarioExecutionConfig(id: string): Record<string, unknown> | undefined {
  return readQaScenarioPack().scenarios.find((candidate) => candidate.id === id)?.execution?.config;
}

export function validateQaScenarioExecutionConfig(config: Record<string, unknown>) {
  return qaScenarioConfigSchema.parse(config);
}
