// Qa Lab plugin module implements suite planning behavior.
import path from "node:path";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "./cli-paths.js";
import type { QaCliBackendAuthMode } from "./gateway-child.js";
import { splitQaModelRef as splitModelRef, type QaProviderMode } from "./model-selection.js";
import { getQaProvider } from "./providers/index.js";
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
import type { QaScorecardChannelDriver } from "./scorecard-taxonomy.js";
import { applyQaMergePatch, isQaMergePatchObject } from "./suite-merge-patch.js";

const DEFAULT_QA_SUITE_CONCURRENCY = 64;
const DEFAULT_QA_SUITE_WORKER_START_STAGGER_MS = 1_500;
const QA_IMPLICIT_ISOLATION_FLOW_CALLS = new Set([
  "ensureImageGenerationConfigured",
  "forceMemoryIndex",
  "patchConfig",
  "writeWorkspaceSkill",
]);

type QaSeedScenario = ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"][number];

function normalizeQaConfigString(value: unknown): string | undefined {
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function scenarioMatchesQaProviderLane(params: {
  scenario: QaSeedScenario;
  primaryModel: string;
  providerMode: QaProviderMode;
  channelDriver?: QaScorecardChannelDriver | null;
  claudeCliAuthMode?: QaCliBackendAuthMode;
}) {
  const provider = getQaProvider(params.providerMode);
  if (params.scenario.runtimeParityTier === "live-only" && provider.kind !== "live") {
    return false;
  }
  const config = params.scenario.execution.config ?? {};
  const requiredProviderMode = normalizeQaConfigString(config.requiredProviderMode);
  if (requiredProviderMode && params.providerMode !== requiredProviderMode) {
    return false;
  }
  const requiredChannelDriver = normalizeQaConfigString(config.requiredChannelDriver);
  const effectiveChannelDriver = params.channelDriver ?? "qa-channel";
  if (requiredChannelDriver && effectiveChannelDriver !== requiredChannelDriver) {
    return false;
  }
  if (provider.kind !== "live") {
    return true;
  }
  const selected = splitModelRef(params.primaryModel);
  const requiredProvider = normalizeQaConfigString(config.requiredProvider);
  if (requiredProvider && selected?.provider !== requiredProvider) {
    return false;
  }
  const requiredModel = normalizeQaConfigString(config.requiredModel);
  if (requiredModel && selected?.model !== requiredModel) {
    return false;
  }
  const requiredAuthMode = normalizeQaConfigString(config.authMode);
  if (requiredAuthMode && params.claudeCliAuthMode !== requiredAuthMode) {
    return false;
  }
  return true;
}

function selectQaFlowSuiteScenarios(params: {
  scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
  scenarioIds?: string[];
  providerMode: QaProviderMode;
  primaryModel: string;
  channelDriver?: QaScorecardChannelDriver | null;
  claudeCliAuthMode?: QaCliBackendAuthMode;
}) {
  const requestedScenarioIds =
    params.scenarioIds && params.scenarioIds.length > 0 ? new Set(params.scenarioIds) : null;
  if (requestedScenarioIds) {
    const scenarioById = new Map(params.scenarios.map((scenario) => [scenario.id, scenario]));
    const missingScenarioIds = [...requestedScenarioIds].filter(
      (scenarioId) => !scenarioById.has(scenarioId),
    );
    if (missingScenarioIds.length > 0) {
      throw new Error(`unknown QA scenario id(s): ${missingScenarioIds.join(", ")}`);
    }
    const selectedScenarios = [...requestedScenarioIds].map(
      (scenarioId) => scenarioById.get(scenarioId)!,
    );
    const nonFlowScenarios = selectedScenarios.filter(
      (scenario) => scenario.execution.kind !== "flow",
    );
    if (nonFlowScenarios.length > 0) {
      const scenarioList = nonFlowScenarios
        .map((scenario) => `${scenario.id} (${scenario.execution.kind})`)
        .join(", ");
      throw new Error(
        `flow execution requires execution.kind: flow; unsupported scenario(s): ${scenarioList}`,
      );
    }
    return selectedScenarios;
  }
  return params.scenarios.filter(
    (scenario) =>
      scenario.execution.kind === "flow" &&
      scenarioMatchesQaProviderLane({
        scenario,
        providerMode: params.providerMode,
        primaryModel: params.primaryModel,
        channelDriver: params.channelDriver,
        claudeCliAuthMode: params.claudeCliAuthMode,
      }),
  );
}

function listQaSuiteScenarioChannels(
  scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"],
) {
  return [
    ...new Set(
      scenarios
        .map((scenario) => scenario.execution.channel?.trim().toLowerCase())
        .filter((channel): channel is string => Boolean(channel)),
    ),
  ];
}

function resolveQaSuiteScenarioChannel(params: {
  defaultChannel: string;
  explicitChannel?: string | null;
  scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
}) {
  const scenarioChannels = listQaSuiteScenarioChannels(params.scenarios);
  const explicitChannel = params.explicitChannel?.trim().toLowerCase();
  if (explicitChannel) {
    const conflictingChannels = scenarioChannels.filter((channel) => channel !== explicitChannel);
    if (conflictingChannels.length > 0) {
      throw new Error(
        `--channel ${explicitChannel} conflicts with selected scenario execution.channel ${conflictingChannels.join(", ")}.`,
      );
    }
    return explicitChannel;
  }
  if (scenarioChannels.length === 0) {
    return params.defaultChannel;
  }
  if (scenarioChannels.length === 1) {
    return scenarioChannels[0];
  }
  throw new Error(
    `Selected QA scenarios require multiple channels (${scenarioChannels.join(", ")}); split the run by channel.`,
  );
}

function collectQaSuitePluginIds(
  scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"],
) {
  return [
    ...new Set(
      scenarios.flatMap((scenario) =>
        Array.isArray(scenario.plugins)
          ? scenario.plugins
              .map((pluginId) => pluginId.trim())
              .filter((pluginId) => pluginId.length > 0)
          : [],
      ),
    ),
  ];
}

function collectQaSuiteGatewayConfigPatch(
  scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"],
): Record<string, unknown> | undefined {
  let merged: Record<string, unknown> | undefined;
  for (const scenario of scenarios) {
    if (!isQaMergePatchObject(scenario.gatewayConfigPatch)) {
      continue;
    }
    merged = applyQaMergePatch(merged ?? {}, scenario.gatewayConfigPatch) as Record<
      string,
      unknown
    >;
  }
  return merged;
}

function collectQaSuiteGatewayRuntimeOptions(
  scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"],
) {
  let forwardHostHome = false;
  let preserveDebugArtifacts = false;
  for (const scenario of scenarios) {
    if (scenario.gatewayRuntime?.forwardHostHome === true) {
      forwardHostHome = true;
    }
    if (scenario.gatewayRuntime?.preserveDebugArtifacts === true) {
      preserveDebugArtifacts = true;
    }
  }
  return forwardHostHome || preserveDebugArtifacts
    ? {
        ...(forwardHostHome ? { forwardHostHome: true } : {}),
        ...(preserveDebugArtifacts ? { preserveDebugArtifacts: true } : {}),
      }
    : undefined;
}

function shouldUseIsolatedQaSuiteScenarioWorkers(params: {
  scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
  concurrency: number;
}) {
  return (
    params.scenarios.length > 1 &&
    (params.concurrency > 1 ||
      params.scenarios.some((scenario) => isQaMergePatchObject(scenario.gatewayConfigPatch)))
  );
}

function scenarioRequiresIsolatedQaSuiteWorker(scenario: QaSeedScenario) {
  if (scenario.execution.kind !== "flow") {
    return false;
  }
  return (
    scenario.execution.suiteIsolation === "isolated" ||
    isQaMergePatchObject(scenario.gatewayConfigPatch) ||
    scenario.gatewayRuntime !== undefined ||
    (Array.isArray(scenario.plugins) && scenario.plugins.length > 0) ||
    normalizeLowercaseStringOrEmpty(scenario.surface) === "memory" ||
    scenario.execution.config?.ensureImageGeneration === true ||
    flowContainsImplicitIsolationCall(scenario.execution.flow)
  );
}

function flowContainsImplicitIsolationCall(value: unknown): boolean {
  if (Array.isArray(value)) {
    return value.some(flowContainsImplicitIsolationCall);
  }
  if (!value || typeof value !== "object") {
    return false;
  }
  const record = value as Record<string, unknown>;
  if (typeof record.call === "string" && QA_IMPLICIT_ISOLATION_FLOW_CALLS.has(record.call)) {
    return true;
  }
  return Object.values(record).some(flowContainsImplicitIsolationCall);
}

function scenarioRequiresControlUi(scenario: QaSeedScenario) {
  return normalizeLowercaseStringOrEmpty(scenario.surface) === "control-ui";
}

function normalizeQaSuiteConcurrency(
  value: number | undefined,
  scenarioCount: number,
  defaultConcurrency = DEFAULT_QA_SUITE_CONCURRENCY,
) {
  const envValue = parseStrictNonNegativeInteger(process.env.OPENCLAW_QA_SUITE_CONCURRENCY);
  const raw =
    typeof value === "number" && Number.isFinite(value)
      ? value
      : envValue !== undefined
        ? envValue
        : defaultConcurrency;
  return Math.max(1, Math.min(Math.floor(raw), Math.max(1, scenarioCount)));
}

function resolveQaSuiteWorkerStartStaggerMs(
  concurrency: number,
  env: NodeJS.ProcessEnv = process.env,
  defaultStaggerMs = DEFAULT_QA_SUITE_WORKER_START_STAGGER_MS,
) {
  if (concurrency <= 1) {
    return 0;
  }
  const raw = env.OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS;
  if (raw === undefined) {
    return defaultStaggerMs;
  }
  const parsed = parseStrictNonNegativeInteger(raw);
  if (parsed === undefined) {
    return defaultStaggerMs;
  }
  return parsed;
}

async function mapQaSuiteWithConcurrency<T, U>(
  items: readonly T[],
  concurrency: number,
  mapper: (item: T, index: number) => Promise<U>,
  opts?: {
    startStaggerMs?: number;
    sleepImpl?: (ms: number) => Promise<unknown>;
  },
) {
  const results = Array.from<U>({ length: items.length });
  let nextIndex = 0;
  let nextStartGate = Promise.resolve();
  const workerCount = Math.min(Math.max(1, Math.floor(concurrency)), items.length);
  const startStaggerMs = Math.max(0, Math.floor(opts?.startStaggerMs ?? 0));
  const sleepImpl =
    opts?.sleepImpl ??
    ((ms: number) =>
      new Promise<void>((resolve) => {
        setTimeout(resolve, ms);
      }));
  async function waitForStartSlot(shouldReleaseNextSlot: boolean) {
    const currentGate = nextStartGate;
    let releaseNextSlot: (() => void) | undefined;
    if (shouldReleaseNextSlot) {
      nextStartGate = new Promise<void>((resolve) => {
        releaseNextSlot = resolve;
      });
    }
    await currentGate;
    if (!releaseNextSlot) {
      return;
    }
    void (async () => {
      try {
        if (startStaggerMs > 0) {
          await sleepImpl(startStaggerMs);
        }
      } finally {
        releaseNextSlot();
      }
    })();
  }
  const workers = Array.from({ length: workerCount }, async () => {
    while (nextIndex < items.length) {
      const index = nextIndex;
      nextIndex += 1;
      await waitForStartSlot(nextIndex < items.length);
      results[index] = await mapper(items[index], index);
    }
  });
  await Promise.all(workers);
  return results;
}

async function resolveQaSuiteOutputDir(repoRoot: string, outputDir?: string) {
  const targetDir = !outputDir
    ? path.join(repoRoot, ".artifacts", "qa-e2e", `suite-${createQaArtifactRunId()}`)
    : outputDir;
  if (!path.isAbsolute(targetDir)) {
    const resolved = resolveRepoRelativeOutputDir(repoRoot, targetDir);
    if (!resolved) {
      throw new Error("QA suite outputDir must be set.");
    }
    return await ensureRepoBoundDirectory(repoRoot, resolved, "QA suite outputDir", {
      mode: 0o700,
    });
  }
  return await ensureRepoBoundDirectory(repoRoot, targetDir, "QA suite outputDir", {
    mode: 0o700,
  });
}

export {
  applyQaMergePatch,
  collectQaSuiteGatewayConfigPatch,
  collectQaSuiteGatewayRuntimeOptions,
  collectQaSuitePluginIds,
  mapQaSuiteWithConcurrency,
  normalizeQaSuiteConcurrency,
  resolveQaSuiteScenarioChannel,
  resolveQaSuiteWorkerStartStaggerMs,
  resolveQaSuiteOutputDir,
  scenarioRequiresControlUi,
  scenarioRequiresIsolatedQaSuiteWorker,
  scenarioMatchesQaProviderLane,
  selectQaFlowSuiteScenarios,
  shouldUseIsolatedQaSuiteScenarioWorkers,
  splitModelRef,
};
