// SPEC: PJ2026-03 PikaOA v0.9.
import { resolveConfigRef } from "./ops/config-refs";

interface SecretSourceSpec {
  purpose: string;
  sourceRef: string;
  sourceKey: string | null;
  targetKey: string;
}

interface PrometheusSelectorSpec {
  sourceRef: string;
  key: string;
  value: string;
  presence: true;
  fingerprint: string;
  valuesPrinted: false;
}

export interface PikaoaWebProbeSmokeProfileSpec {
  kind: "route-matrix";
  routes: Array<{ path: string; navigationSelector: string; readySelector: string; heading: string }>;
  businessAssertions: {
    contract: {
      number: string;
      rowSelector: string;
      currentVersionLinkSelector: string;
      attachmentSectionSelector: string;
      attachmentName: string;
    };
    invoice: {
      activeNumber: string;
      voidedNumber: string;
      voidedLabel: string;
      rowSelector: string;
      attachmentHeaderSelector: string;
      attachmentHeaderText: string;
      attachmentCellSelector: string;
      minimumActiveAttachmentCount: number;
      attachmentSectionSelector: string;
      attachmentName: string;
      layout: {
        toolbarSelector: string;
        listWorkspaceSelector: string;
        advancedFiltersSelector: string;
        screenshotName: string;
        maxToolbarHeight: number;
        maxListTop: number;
        minListVisibleHeight: number;
        mobile: {
          screenshotName: string;
          viewport: { width: number; height: number };
          maxToolbarHeight: number;
          maxListTop: number;
          minListVisibleHeight: number;
        };
      };
    };
    attachments: { rowSelector: string; names: string[] };
    resourceReferences: {
      filter: {
        partnerLinkSelector: string;
        advancedToggleSelector: string;
        comboboxLabel: string;
        optionSelector: string;
        submitSelector: string;
        clearSelector: string;
        queryParameter: string;
        apiPath: string;
        idSearchLength: number;
      };
      detail: {
        definitionListSelector: string;
        resourceLinkSelector: string;
        contract: { label: string; pathPrefix: string };
        invoice: { label: string; pathPrefix: string };
        attachment: { label: string; pathPrefix: string };
      };
    };
    pdf: { openButtonSelector: string; contentPathPrefix: string; contentPathSuffix: string; mediaType: string };
  } | null;
  screenshotName: string;
  viewport: { width: number; height: number };
  navigationTimeoutMs: number;
  settleMs: number;
  commandTimeoutSeconds: number;
  outputLimits: { network: number; failures: number; routes: number };
}

export interface PikaoaWebProbeSpec {
  enabled: boolean;
  productId: "pikaoa";
  runner: { node: string; lane: string };
  origin: { name: "public" | "native"; mode: "public" | "custom-url"; baseUrl: string; browserProxyMode: "auto" | "direct" };
  authentication: {
    mode: "ui-form-memory";
    path: string;
    usernameSelector: string;
    passwordSelector: string;
    submitSelector: string;
    authenticatedPath: string;
    authenticatedSelector: string;
    credentials: { configRef: string; targetId: string; secretName: string; passwordTargetKey: string };
  };
  defaultSmokeProfile: string;
  smokeProfiles: Record<string, PikaoaWebProbeSmokeProfileSpec>;
}

export interface PikaoaNativeSpec {
  stateDir: string;
  logDir: string;
  api: { command: string; pidFile: string; logFile: string; endpoint: string };
  worker: { command: string; pidFile: string; logFile: string; endpoint: string };
  web: { workingDirectory: string; command: string; pidFile: string; logFile: string; endpoint: string };
  webProbe: Pick<PikaoaWebProbeSpec, "runner" | "origin">;
}

export interface PikaoaRuntimeSpec {
  id: string;
  enabled: boolean;
  node: string;
  route: string;
  namespace: string;
  fieldManager: string;
  source: {
    mode: string;
    repository: string;
    commitPolicy: string;
    branch: string;
    worktreeRemote: string;
    readUrl: string;
    snapshotPrefix: string;
    pullPolicy: string;
  };
  ci: {
    lane: string;
    namespace: string;
    pipeline: string;
    pipelineRunPrefix: string;
    serviceAccountName: string;
    workspaceSize: string;
    pipelineTimeout: string;
    toolsImage: string;
    buildkitImage: string;
  };
  build: {
    dockerfiles: { api: string; worker: string; web: string };
    images: { api: string; worker: string; web: string };
    cache: {
      mode: "registry";
      refs: { api: string; worker: string; web: string };
    };
    networkMode: string;
  };
  gitops: {
    readUrl: string;
    writeUrl: string;
    branch: string;
    manifestPath: string;
    credentialSecretName: string;
    credentialTokenKey: string;
    credentialUsername: string;
    authorName: string;
    authorEmail: string;
  };
  database: {
    configRef: string;
    name: string;
    username: string;
    schema: string;
    connection: SecretSourceSpec;
  };
  temporal: {
    configRef: string;
    serviceRef: { namespace: string; name: string; port: number };
    logicalNamespace: string;
    taskQueue: string;
    workflowId: string;
    drainInterval: string;
    activityTimeout: string;
    retryInitialInterval: string;
    retryMaximumInterval: string;
    retryMaximumAttempts: number;
    continueAsNewEvery: number;
  };
  native: PikaoaNativeSpec;
  runtime: {
    replicas: { api: number; worker: number; web: number };
    resources: Record<"api" | "worker" | "web" | "initializer", Record<string, unknown>>;
    secretName: string;
    configMapName: string;
    apiServiceName: string;
    apiPort: number;
    workerMetricsPort: number;
    webPort: number;
    webApiUpstreamEnv: string;
    attachment: {
      storageRoot: string;
      maxBytes: number;
      claimName: string;
      storageRequest: string;
      storageClassName: string | null;
      fsGroup: number;
      accessModes: string[];
    };
    outboxBatchSize: number;
    outboxMaxAttempts: number;
    outboxRetryDelay: string;
    sessionTTL: string;
    shutdownTimeout: string;
    administrator: { username: string; password: SecretSourceSpec };
    employee: { username: string; password: SecretSourceSpec };
    sessionSecret: SecretSourceSpec;
    adminToken: SecretSourceSpec;
  };
  initializer: {
    mode: "fresh-database-only";
    jobName: string;
    backoffLimit: number;
    command: string[];
  };
  exposure: {
    serviceName: string;
    serviceType: "NodePort";
    hostIP: string;
    port: number;
    publicBaseUrl: string;
    expectedA: string;
  };
  webProbe: PikaoaWebProbeSpec;
  observability: {
    otlpEndpoint: string;
    prometheusScrape: boolean;
    prometheusSelector: PrometheusSelectorSpec;
    apiMetricsPath: string;
    workerMetricsPath: string;
    exporterWarning: boolean;
    exporterBlocking: false;
  };
  probes: {
    apiLivenessPath: string;
    apiReadinessPath: string;
    workerLivenessPath: string;
    workerReadinessPath: string;
    webLivenessPath: string;
    webReadinessPath: string;
  };
}

export function pikaoaDevelopmentRuntimeConfigRef(targetId: string): string {
  simpleId(targetId, "PikaOA development runtime target id");
  return `config/pikaoa.yaml#developmentRuntime.targets.${targetId}`;
}

export function resolvePikaoaDevelopmentRuntimeTargetByConfigRef(configRef: string): PikaoaRuntimeSpec {
  return resolvePikaoaRuntimeTarget(configRef, "developmentRuntime");
}

export function pikaoaReleaseRuntimeConfigRef(targetId: string): string {
  simpleId(targetId, "PikaOA release runtime target id");
  return `config/pikaoa.yaml#releaseRuntime.targets.${targetId}`;
}

export function resolvePikaoaReleaseRuntimeTargetByConfigRef(configRef: string): PikaoaRuntimeSpec {
  return resolvePikaoaRuntimeTarget(configRef, "releaseRuntime");
}

function resolvePikaoaRuntimeTarget(configRef: string, runtimeSection: "developmentRuntime" | "releaseRuntime"): PikaoaRuntimeSpec {
  const resolved = resolveConfigRef(configRef, `PikaOA ${runtimeSection} configRef`);
  const match = new RegExp(`^${runtimeSection}\\.targets\\.([A-Za-z0-9._-]+)$`, "u").exec(resolved.fragment);
  if (resolved.file !== "config/pikaoa.yaml" || match === null) {
    throw new Error(`PikaOA ${runtimeSection} configRef must select config/pikaoa.yaml#${runtimeSection}.targets.<id>`);
  }
  return parseRuntime(match[1]!, record(resolved.value, configRef), resolved.file, runtimeSection);
}

function parseRuntime(id: string, root: Record<string, unknown>, configLabel: string, runtimeSection: string): PikaoaRuntimeSpec {
  const path = `${configLabel}.${runtimeSection}.targets.${id}`;
  simpleId(id, "target id");
  const source = record(root.source, `${path}.source`);
  const ci = record(root.ci, `${path}.ci`);
  const build = record(root.build, `${path}.build`);
  const dockerfiles = record(build.dockerfiles, `${path}.build.dockerfiles`);
  const buildImages = record(build.images, `${path}.build.images`);
  const buildCache = record(build.cache, `${path}.build.cache`);
  const buildCacheRefs = record(buildCache.refs, `${path}.build.cache.refs`);
  const gitops = record(root.gitops, `${path}.gitops`);
  const gitopsCredential = record(gitops.credential, `${path}.gitops.credential`);
  const gitopsAuthor = record(gitops.author, `${path}.gitops.author`);
  const database = record(root.database, `${path}.database`);
  const temporal = record(root.temporal, `${path}.temporal`);
  const temporalServiceRef = record(temporal.serviceRef, `${path}.temporal.serviceRef`);
  const temporalWorkflow = record(temporal.workflow, `${path}.temporal.workflow`);
  const temporalRetry = record(temporalWorkflow.retry, `${path}.temporal.workflow.retry`);
  const native = record(root.native, `${path}.native`);
  const runtime = record(root.runtime, `${path}.runtime`);
  const replicas = record(runtime.replicas, `${path}.runtime.replicas`);
  const resources = record(runtime.resources, `${path}.runtime.resources`);
  const attachment = record(runtime.attachment, `${path}.runtime.attachment`);
  const outbox = record(runtime.outbox, `${path}.runtime.outbox`);
  const administrator = record(runtime.administrator, `${path}.runtime.administrator`);
  const employee = record(runtime.employee, `${path}.runtime.employee`);
  const initializer = record(root.initializer, `${path}.initializer`);
  const exposure = record(root.exposure, `${path}.exposure`);
  const webProbe = record(root.webProbe, `${path}.webProbe`);
  const observability = record(root.observability, `${path}.observability`);
  const prometheus = record(observability.prometheus, `${path}.observability.prometheus`);
  const exporterFailure = record(observability.exporterFailure, `${path}.observability.exporterFailure`);
  const probes = record(root.probes, `${path}.probes`);
  const apiProbes = record(probes.api, `${path}.probes.api`);
  const workerProbes = record(probes.worker, `${path}.probes.worker`);
  const webProbes = record(probes.web, `${path}.probes.web`);
  if (exporterFailure.blocking !== false || exporterFailure.warning !== true) {
    throw new Error(`${path}.observability.exporterFailure 必须为 warning=true、blocking=false`);
  }
  const target: PikaoaRuntimeSpec = {
    id,
    enabled: bool(root.enabled, `${path}.enabled`),
    node: text(root.node, `${path}.node`),
    route: text(root.route, `${path}.route`),
    namespace: kubernetesName(root.namespace, `${path}.namespace`),
    fieldManager: kubernetesName(root.fieldManager, `${path}.fieldManager`),
    source: {
      mode: exact(source.mode, `${path}.source.mode`, "pac-source-build"),
      repository: text(source.repository, `${path}.source.repository`),
      commitPolicy: exact(source.commitPolicy, `${path}.source.commitPolicy`, "pac-revision-required"),
      branch: text(source.branch, `${path}.source.branch`),
      worktreeRemote: text(source.worktreeRemote, `${path}.source.worktreeRemote`),
      readUrl: text(source.readUrl, `${path}.source.readUrl`),
      snapshotPrefix: text(source.snapshotPrefix, `${path}.source.snapshotPrefix`),
      pullPolicy: oneOf(source.pullPolicy, `${path}.source.pullPolicy`, ["Always", "IfNotPresent", "Never"]),
    },
    ci: {
      lane: simpleId(text(ci.lane, `${path}.ci.lane`), `${path}.ci.lane`),
      namespace: kubernetesName(ci.namespace, `${path}.ci.namespace`),
      pipeline: kubernetesName(ci.pipeline, `${path}.ci.pipeline`),
      pipelineRunPrefix: kubernetesName(ci.pipelineRunPrefix, `${path}.ci.pipelineRunPrefix`),
      serviceAccountName: kubernetesName(ci.serviceAccountName, `${path}.ci.serviceAccountName`),
      workspaceSize: text(ci.workspaceSize, `${path}.ci.workspaceSize`),
      pipelineTimeout: text(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`),
      toolsImage: imageReference(ci.toolsImage, `${path}.ci.toolsImage`),
      buildkitImage: imageReference(ci.buildkitImage, `${path}.ci.buildkitImage`),
    },
    build: {
      dockerfiles: {
        api: relativePath(dockerfiles.api, `${path}.build.dockerfiles.api`),
        worker: relativePath(dockerfiles.worker, `${path}.build.dockerfiles.worker`),
        web: relativePath(dockerfiles.web, `${path}.build.dockerfiles.web`),
      },
      images: {
        api: imageRepository(buildImages.api, `${path}.build.images.api`),
        worker: imageRepository(buildImages.worker, `${path}.build.images.worker`),
        web: imageRepository(buildImages.web, `${path}.build.images.web`),
      },
      cache: {
        mode: exact(buildCache.mode, `${path}.build.cache.mode`, "registry"),
        refs: {
          api: imageReference(buildCacheRefs.api, `${path}.build.cache.refs.api`),
          worker: imageReference(buildCacheRefs.worker, `${path}.build.cache.refs.worker`),
          web: imageReference(buildCacheRefs.web, `${path}.build.cache.refs.web`),
        },
      },
      networkMode: exact(build.networkMode, `${path}.build.networkMode`, "host"),
    },
    gitops: {
      readUrl: text(gitops.readUrl, `${path}.gitops.readUrl`),
      writeUrl: text(gitops.writeUrl, `${path}.gitops.writeUrl`),
      branch: text(gitops.branch, `${path}.gitops.branch`),
      manifestPath: relativePath(gitops.manifestPath, `${path}.gitops.manifestPath`),
      credentialSecretName: kubernetesName(gitopsCredential.secretName, `${path}.gitops.credential.secretName`),
      credentialTokenKey: text(gitopsCredential.tokenKey, `${path}.gitops.credential.tokenKey`),
      credentialUsername: text(gitopsCredential.username, `${path}.gitops.credential.username`),
      authorName: text(gitopsAuthor.name, `${path}.gitops.author.name`),
      authorEmail: text(gitopsAuthor.email, `${path}.gitops.author.email`),
    },
    database: {
      configRef: text(database.configRef, `${path}.database.configRef`),
      name: postgresIdentifier(database.name, `${path}.database.name`),
      username: postgresIdentifier(database.username, `${path}.database.username`),
      schema: postgresIdentifier(database.schema, `${path}.database.schema`),
      connection: secretSource(database.connection, "database.connection", `${path}.database.connection`, true),
    },
    temporal: {
      configRef: relativePath(temporal.configRef, `${path}.temporal.configRef`),
      serviceRef: {
        namespace: kubernetesName(temporalServiceRef.namespace, `${path}.temporal.serviceRef.namespace`),
        name: kubernetesName(temporalServiceRef.name, `${path}.temporal.serviceRef.name`),
        port: positiveInteger(temporalServiceRef.port, `${path}.temporal.serviceRef.port`, 65_535),
      },
      logicalNamespace: simpleId(text(temporal.logicalNamespace, `${path}.temporal.logicalNamespace`), `${path}.temporal.logicalNamespace`),
      taskQueue: simpleId(text(temporal.taskQueue, `${path}.temporal.taskQueue`), `${path}.temporal.taskQueue`),
      workflowId: simpleId(text(temporalWorkflow.id, `${path}.temporal.workflow.id`), `${path}.temporal.workflow.id`),
      drainInterval: duration(temporalWorkflow.drainInterval, `${path}.temporal.workflow.drainInterval`),
      activityTimeout: duration(temporalWorkflow.activityTimeout, `${path}.temporal.workflow.activityTimeout`),
      retryInitialInterval: duration(temporalRetry.initialInterval, `${path}.temporal.workflow.retry.initialInterval`),
      retryMaximumInterval: duration(temporalRetry.maximumInterval, `${path}.temporal.workflow.retry.maximumInterval`),
      retryMaximumAttempts: positiveInteger(temporalRetry.maximumAttempts, `${path}.temporal.workflow.retry.maximumAttempts`, Number.MAX_SAFE_INTEGER),
      continueAsNewEvery: positiveInteger(temporalWorkflow.continueAsNewEvery, `${path}.temporal.workflow.continueAsNewEvery`, Number.MAX_SAFE_INTEGER),
    },
    native: parsePikaoaNative(native, `${path}.native`),
    runtime: {
      replicas: {
        api: positiveInteger(replicas.api, `${path}.runtime.replicas.api`, Number.MAX_SAFE_INTEGER),
        worker: positiveInteger(replicas.worker, `${path}.runtime.replicas.worker`, Number.MAX_SAFE_INTEGER),
        web: positiveInteger(replicas.web, `${path}.runtime.replicas.web`, Number.MAX_SAFE_INTEGER),
      },
      resources: {
        api: record(resources.api, `${path}.runtime.resources.api`),
        worker: record(resources.worker, `${path}.runtime.resources.worker`),
        web: record(resources.web, `${path}.runtime.resources.web`),
        initializer: record(resources.initializer, `${path}.runtime.resources.initializer`),
      },
      secretName: kubernetesName(runtime.secretName, `${path}.runtime.secretName`),
      configMapName: kubernetesName(runtime.configMapName, `${path}.runtime.configMapName`),
      apiServiceName: kubernetesName(runtime.apiServiceName, `${path}.runtime.apiServiceName`),
      apiPort: positiveInteger(runtime.apiPort, `${path}.runtime.apiPort`, 65_535),
      workerMetricsPort: positiveInteger(runtime.workerMetricsPort, `${path}.runtime.workerMetricsPort`, 65_535),
      webPort: positiveInteger(runtime.webPort, `${path}.runtime.webPort`, 65_535),
      webApiUpstreamEnv: envName(runtime.webApiUpstreamEnv, `${path}.runtime.webApiUpstreamEnv`),
      attachment: {
        storageRoot: absolutePath(attachment.storageRoot, `${path}.runtime.attachment.storageRoot`),
        maxBytes: positiveInteger(attachment.maxBytes, `${path}.runtime.attachment.maxBytes`, Number.MAX_SAFE_INTEGER),
        claimName: kubernetesName(attachment.claimName, `${path}.runtime.attachment.claimName`),
        storageRequest: text(attachment.storageRequest, `${path}.runtime.attachment.storageRequest`),
        storageClassName: nullableText(attachment.storageClassName, `${path}.runtime.attachment.storageClassName`),
        fsGroup: positiveInteger(attachment.fsGroup, `${path}.runtime.attachment.fsGroup`, 2_147_483_647),
        accessModes: stringList(attachment.accessModes, `${path}.runtime.attachment.accessModes`, ["ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod"]),
      },
      outboxBatchSize: positiveInteger(outbox.batchSize, `${path}.runtime.outbox.batchSize`, Number.MAX_SAFE_INTEGER),
      outboxMaxAttempts: positiveInteger(outbox.maxAttempts, `${path}.runtime.outbox.maxAttempts`, Number.MAX_SAFE_INTEGER),
      outboxRetryDelay: duration(outbox.retryDelay, `${path}.runtime.outbox.retryDelay`),
      sessionTTL: duration(runtime.sessionTTL, `${path}.runtime.sessionTTL`),
      shutdownTimeout: duration(runtime.shutdownTimeout, `${path}.runtime.shutdownTimeout`),
      administrator: {
        username: text(administrator.username, `${path}.runtime.administrator.username`),
        password: secretSource(administrator.password, "runtime.administrator.password", `${path}.runtime.administrator.password`),
      },
      employee: {
        username: text(employee.username, `${path}.runtime.employee.username`),
        password: secretSource(employee.password, "runtime.employee.password", `${path}.runtime.employee.password`),
      },
      sessionSecret: secretSource(runtime.sessionSecret, "runtime.sessionSecret", `${path}.runtime.sessionSecret`),
      adminToken: secretSource(runtime.adminToken, "runtime.adminToken", `${path}.runtime.adminToken`, true),
    },
    initializer: {
      mode: exact(initializer.mode, `${path}.initializer.mode`, "fresh-database-only") as "fresh-database-only",
      jobName: kubernetesName(initializer.jobName, `${path}.initializer.jobName`),
      backoffLimit: nonNegativeInteger(initializer.backoffLimit, `${path}.initializer.backoffLimit`),
      command: nonEmptyStringList(initializer.command, `${path}.initializer.command`),
    },
    exposure: {
      serviceName: kubernetesName(exposure.serviceName, `${path}.exposure.serviceName`),
      serviceType: exact(exposure.serviceType, `${path}.exposure.serviceType`, "NodePort") as "NodePort",
      hostIP: ipv4Address(exposure.hostIP, `${path}.exposure.hostIP`),
      port: positiveInteger(exposure.port, `${path}.exposure.port`, 65_535),
      publicBaseUrl: httpsUrl(exposure.publicBaseUrl, `${path}.exposure.publicBaseUrl`),
      expectedA: ipv4Address(exposure.expectedA, `${path}.exposure.expectedA`),
    },
    webProbe: parsePikaoaWebProbe(webProbe, `${path}.webProbe`),
    observability: {
      otlpEndpoint: otlpGrpcEndpoint(observability.otlpEndpoint, `${path}.observability.otlpEndpoint`),
      prometheusScrape: bool(prometheus.scrape, `${path}.observability.prometheus.scrape`),
      prometheusSelector: prometheusSelector(prometheus.configRef, `${path}.observability.prometheus`),
      apiMetricsPath: httpPath(prometheus.apiPath, `${path}.observability.prometheus.apiPath`),
      workerMetricsPath: httpPath(prometheus.workerPath, `${path}.observability.prometheus.workerPath`),
      exporterWarning: true,
      exporterBlocking: false,
    },
    probes: {
      apiLivenessPath: httpPath(apiProbes.livenessPath, `${path}.probes.api.livenessPath`),
      apiReadinessPath: httpPath(apiProbes.readinessPath, `${path}.probes.api.readinessPath`),
      workerLivenessPath: httpPath(workerProbes.livenessPath, `${path}.probes.worker.livenessPath`),
      workerReadinessPath: httpPath(workerProbes.readinessPath, `${path}.probes.worker.readinessPath`),
      webLivenessPath: httpPath(webProbes.livenessPath, `${path}.probes.web.livenessPath`),
      webReadinessPath: httpPath(webProbes.readinessPath, `${path}.probes.web.readinessPath`),
    },
  };
  const secretKeys = [target.database.connection, target.runtime.sessionSecret, target.runtime.adminToken, target.runtime.administrator.password, target.runtime.employee.password]
    .map((item) => item.targetKey);
  if (new Set(secretKeys).size !== secretKeys.length || secretKeys.includes("pikaoa.yaml")) {
    throw new Error(`${path} 的 Secret targetKey 必须唯一且不能使用 pikaoa.yaml`);
  }
  return target;
}

function parsePikaoaNative(raw: Record<string, unknown>, path: string): PikaoaNativeSpec {
  const api = record(raw.api, `${path}.api`);
  const worker = record(raw.worker, `${path}.worker`);
  const web = record(raw.web, `${path}.web`);
  const webProbe = record(raw.webProbe, `${path}.webProbe`);
  const runner = record(webProbe.runner, `${path}.webProbe.runner`);
  const origin = record(webProbe.origin, `${path}.webProbe.origin`);
  const component = (value: Record<string, unknown>, componentPath: string) => ({
    command: text(value.command, `${componentPath}.command`),
    pidFile: relativePath(value.pidFile, `${componentPath}.pidFile`),
    logFile: relativePath(value.logFile, `${componentPath}.logFile`),
    endpoint: httpUrl(value.endpoint, `${componentPath}.endpoint`),
  });
  return {
    stateDir: relativePath(raw.stateDir, `${path}.stateDir`),
    logDir: relativePath(raw.logDir, `${path}.logDir`),
    api: component(api, `${path}.api`),
    worker: component(worker, `${path}.worker`),
    web: { workingDirectory: relativePath(web.workingDirectory, `${path}.web.workingDirectory`), ...component(web, `${path}.web`) },
    webProbe: {
      runner: {
        node: simpleId(text(runner.node, `${path}.webProbe.runner.node`), `${path}.webProbe.runner.node`),
        lane: simpleId(text(runner.lane, `${path}.webProbe.runner.lane`), `${path}.webProbe.runner.lane`),
      },
      origin: {
        name: exact(origin.name, `${path}.webProbe.origin.name`, "native") as "native",
        mode: exact(origin.mode, `${path}.webProbe.origin.mode`, "custom-url") as "custom-url",
        baseUrl: httpUrl(origin.baseUrl, `${path}.webProbe.origin.baseUrl`),
        browserProxyMode: exact(origin.browserProxyMode, `${path}.webProbe.origin.browserProxyMode`, "direct") as "direct",
      },
    },
  };
}

function parsePikaoaWebProbe(raw: Record<string, unknown>, path: string): PikaoaWebProbeSpec {
  const runner = record(raw.runner, `${path}.runner`);
  const origin = record(raw.origin, `${path}.origin`);
  const authentication = record(raw.authentication, `${path}.authentication`);
  const credentials = record(authentication.credentials, `${path}.authentication.credentials`);
  const profiles = record(raw.smokeProfiles, `${path}.smokeProfiles`);
  const smokeProfiles = Object.fromEntries(Object.entries(profiles).map(([name, value]) => [simpleId(name, `${path}.smokeProfiles key`), parsePikaoaWebProbeProfile(value, `${path}.smokeProfiles.${name}`)]));
  const defaultSmokeProfile = simpleId(text(raw.defaultSmokeProfile, `${path}.defaultSmokeProfile`), `${path}.defaultSmokeProfile`);
  if (smokeProfiles[defaultSmokeProfile] === undefined) throw new Error(`${path}.defaultSmokeProfile 必须引用 smokeProfiles`);
  return {
    enabled: bool(raw.enabled, `${path}.enabled`),
    productId: exact(raw.productId, `${path}.productId`, "pikaoa") as "pikaoa",
    runner: { node: simpleId(text(runner.node, `${path}.runner.node`), `${path}.runner.node`), lane: simpleId(text(runner.lane, `${path}.runner.lane`), `${path}.runner.lane`) },
    origin: {
      name: exact(origin.name, `${path}.origin.name`, "public") as "public",
      mode: exact(origin.mode, `${path}.origin.mode`, "public") as "public",
      baseUrl: httpsUrl(origin.baseUrl, `${path}.origin.baseUrl`),
      browserProxyMode: oneOf(origin.browserProxyMode, `${path}.origin.browserProxyMode`, ["auto", "direct"]),
    },
    authentication: {
      mode: exact(authentication.mode, `${path}.authentication.mode`, "ui-form-memory") as "ui-form-memory",
      path: httpPath(authentication.path, `${path}.authentication.path`),
      usernameSelector: boundedText(authentication.usernameSelector, `${path}.authentication.usernameSelector`, 500),
      passwordSelector: boundedText(authentication.passwordSelector, `${path}.authentication.passwordSelector`, 500),
      submitSelector: boundedText(authentication.submitSelector, `${path}.authentication.submitSelector`, 500),
      authenticatedPath: httpPath(authentication.authenticatedPath, `${path}.authentication.authenticatedPath`),
      authenticatedSelector: boundedText(authentication.authenticatedSelector, `${path}.authentication.authenticatedSelector`, 500),
      credentials: {
        configRef: relativePath(credentials.configRef, `${path}.authentication.credentials.configRef`),
        targetId: simpleId(text(credentials.targetId, `${path}.authentication.credentials.targetId`), `${path}.authentication.credentials.targetId`),
        secretName: kubernetesName(credentials.secretName, `${path}.authentication.credentials.secretName`),
        passwordTargetKey: envName(credentials.passwordTargetKey, `${path}.authentication.credentials.passwordTargetKey`),
      },
    },
    defaultSmokeProfile,
    smokeProfiles,
  };
}

function parsePikaoaWebProbeProfile(value: unknown, path: string): PikaoaWebProbeSmokeProfileSpec {
  const raw = record(value, path);
  const viewport = record(raw.viewport, `${path}.viewport`);
  const limits = record(raw.outputLimits, `${path}.outputLimits`);
  if (!Array.isArray(raw.routes) || raw.routes.length === 0) throw new Error(`${path}.routes 必须是非空数组`);
  const routes = raw.routes.map((value, index) => {
    const route = record(value, `${path}.routes[${index}]`);
    return {
      path: httpPath(route.path, `${path}.routes[${index}].path`),
      navigationSelector: boundedText(route.navigationSelector, `${path}.routes[${index}].navigationSelector`, 500),
      readySelector: boundedText(route.readySelector, `${path}.routes[${index}].readySelector`, 500),
      heading: boundedText(route.heading, `${path}.routes[${index}].heading`, 100),
    };
  });
  const screenshotName = pngFileName(raw.screenshotName, `${path}.screenshotName`);
  return {
    kind: exact(raw.kind, `${path}.kind`, "route-matrix") as "route-matrix",
    routes,
    businessAssertions: raw.businessAssertions === undefined ? null : parsePikaoaBusinessAssertions(raw.businessAssertions, `${path}.businessAssertions`),
    screenshotName,
    viewport: { width: positiveInteger(viewport.width, `${path}.viewport.width`, 7680), height: positiveInteger(viewport.height, `${path}.viewport.height`, 4320) },
    navigationTimeoutMs: positiveInteger(raw.navigationTimeoutMs, `${path}.navigationTimeoutMs`, 300_000),
    settleMs: nonNegativeInteger(raw.settleMs, `${path}.settleMs`),
    commandTimeoutSeconds: positiveInteger(raw.commandTimeoutSeconds, `${path}.commandTimeoutSeconds`, 3600),
    outputLimits: {
      network: positiveInteger(limits.network, `${path}.outputLimits.network`, 1000),
      failures: positiveInteger(limits.failures, `${path}.outputLimits.failures`, 1000),
      routes: positiveInteger(limits.routes, `${path}.outputLimits.routes`, 1000),
    },
  };
}

function parsePikaoaBusinessAssertions(value: unknown, path: string): NonNullable<PikaoaWebProbeSmokeProfileSpec["businessAssertions"]> {
  const raw = record(value, path);
  const contract = record(raw.contract, `${path}.contract`);
  const invoice = record(raw.invoice, `${path}.invoice`);
  const invoiceLayout = record(invoice.layout, `${path}.invoice.layout`);
  const invoiceMobileLayout = record(invoiceLayout.mobile, `${path}.invoice.layout.mobile`);
  const invoiceMobileViewport = record(invoiceMobileLayout.viewport, `${path}.invoice.layout.mobile.viewport`);
  const attachments = record(raw.attachments, `${path}.attachments`);
  const resourceReferences = record(raw.resourceReferences, `${path}.resourceReferences`);
  const resourceFilter = record(resourceReferences.filter, `${path}.resourceReferences.filter`);
  const resourceDetail = record(resourceReferences.detail, `${path}.resourceReferences.detail`);
  const contractDetail = record(resourceDetail.contract, `${path}.resourceReferences.detail.contract`);
  const invoiceDetail = record(resourceDetail.invoice, `${path}.resourceReferences.detail.invoice`);
  const attachmentDetail = record(resourceDetail.attachment, `${path}.resourceReferences.detail.attachment`);
  const pdf = record(raw.pdf, `${path}.pdf`);
  return {
    contract: {
      number: boundedText(contract.number, `${path}.contract.number`, 200),
      rowSelector: boundedText(contract.rowSelector, `${path}.contract.rowSelector`, 500),
      currentVersionLinkSelector: boundedText(contract.currentVersionLinkSelector, `${path}.contract.currentVersionLinkSelector`, 500),
      attachmentSectionSelector: boundedText(contract.attachmentSectionSelector, `${path}.contract.attachmentSectionSelector`, 500),
      attachmentName: boundedText(contract.attachmentName, `${path}.contract.attachmentName`, 500),
    },
    invoice: {
      activeNumber: boundedText(invoice.activeNumber, `${path}.invoice.activeNumber`, 200),
      voidedNumber: boundedText(invoice.voidedNumber, `${path}.invoice.voidedNumber`, 200),
      voidedLabel: boundedText(invoice.voidedLabel, `${path}.invoice.voidedLabel`, 100),
      rowSelector: boundedText(invoice.rowSelector, `${path}.invoice.rowSelector`, 500),
      attachmentHeaderSelector: boundedText(invoice.attachmentHeaderSelector, `${path}.invoice.attachmentHeaderSelector`, 500),
      attachmentHeaderText: boundedText(invoice.attachmentHeaderText, `${path}.invoice.attachmentHeaderText`, 100),
      attachmentCellSelector: boundedText(invoice.attachmentCellSelector, `${path}.invoice.attachmentCellSelector`, 500),
      minimumActiveAttachmentCount: positiveInteger(invoice.minimumActiveAttachmentCount, `${path}.invoice.minimumActiveAttachmentCount`, 1000),
      attachmentSectionSelector: boundedText(invoice.attachmentSectionSelector, `${path}.invoice.attachmentSectionSelector`, 500),
      attachmentName: boundedText(invoice.attachmentName, `${path}.invoice.attachmentName`, 500),
      layout: {
        toolbarSelector: boundedText(invoiceLayout.toolbarSelector, `${path}.invoice.layout.toolbarSelector`, 500),
        listWorkspaceSelector: boundedText(invoiceLayout.listWorkspaceSelector, `${path}.invoice.layout.listWorkspaceSelector`, 500),
        advancedFiltersSelector: boundedText(invoiceLayout.advancedFiltersSelector, `${path}.invoice.layout.advancedFiltersSelector`, 500),
        screenshotName: pngFileName(invoiceLayout.screenshotName, `${path}.invoice.layout.screenshotName`),
        maxToolbarHeight: positiveInteger(invoiceLayout.maxToolbarHeight, `${path}.invoice.layout.maxToolbarHeight`, 4320),
        maxListTop: positiveInteger(invoiceLayout.maxListTop, `${path}.invoice.layout.maxListTop`, 4320),
        minListVisibleHeight: positiveInteger(invoiceLayout.minListVisibleHeight, `${path}.invoice.layout.minListVisibleHeight`, 4320),
        mobile: {
          screenshotName: pngFileName(invoiceMobileLayout.screenshotName, `${path}.invoice.layout.mobile.screenshotName`),
          viewport: {
            width: positiveInteger(invoiceMobileViewport.width, `${path}.invoice.layout.mobile.viewport.width`, 7680),
            height: positiveInteger(invoiceMobileViewport.height, `${path}.invoice.layout.mobile.viewport.height`, 4320),
          },
          maxToolbarHeight: positiveInteger(invoiceMobileLayout.maxToolbarHeight, `${path}.invoice.layout.mobile.maxToolbarHeight`, 4320),
          maxListTop: positiveInteger(invoiceMobileLayout.maxListTop, `${path}.invoice.layout.mobile.maxListTop`, 4320),
          minListVisibleHeight: positiveInteger(invoiceMobileLayout.minListVisibleHeight, `${path}.invoice.layout.mobile.minListVisibleHeight`, 4320),
        },
      },
    },
    attachments: {
      rowSelector: boundedText(attachments.rowSelector, `${path}.attachments.rowSelector`, 500),
      names: nonEmptyStringList(attachments.names, `${path}.attachments.names`).map((item, index) => boundedText(item, `${path}.attachments.names[${index}]`, 500)),
    },
    resourceReferences: {
      filter: {
        partnerLinkSelector: boundedText(resourceFilter.partnerLinkSelector, `${path}.resourceReferences.filter.partnerLinkSelector`, 500),
        advancedToggleSelector: boundedText(resourceFilter.advancedToggleSelector, `${path}.resourceReferences.filter.advancedToggleSelector`, 500),
        comboboxLabel: boundedText(resourceFilter.comboboxLabel, `${path}.resourceReferences.filter.comboboxLabel`, 100),
        optionSelector: boundedText(resourceFilter.optionSelector, `${path}.resourceReferences.filter.optionSelector`, 500),
        submitSelector: boundedText(resourceFilter.submitSelector, `${path}.resourceReferences.filter.submitSelector`, 500),
        clearSelector: boundedText(resourceFilter.clearSelector, `${path}.resourceReferences.filter.clearSelector`, 500),
        queryParameter: boundedText(resourceFilter.queryParameter, `${path}.resourceReferences.filter.queryParameter`, 100),
        apiPath: httpPath(resourceFilter.apiPath, `${path}.resourceReferences.filter.apiPath`),
        idSearchLength: positiveInteger(resourceFilter.idSearchLength, `${path}.resourceReferences.filter.idSearchLength`, 64),
      },
      detail: {
        definitionListSelector: boundedText(resourceDetail.definitionListSelector, `${path}.resourceReferences.detail.definitionListSelector`, 500),
        resourceLinkSelector: boundedText(resourceDetail.resourceLinkSelector, `${path}.resourceReferences.detail.resourceLinkSelector`, 500),
        contract: {
          label: boundedText(contractDetail.label, `${path}.resourceReferences.detail.contract.label`, 100),
          pathPrefix: httpPath(contractDetail.pathPrefix, `${path}.resourceReferences.detail.contract.pathPrefix`),
        },
        invoice: {
          label: boundedText(invoiceDetail.label, `${path}.resourceReferences.detail.invoice.label`, 100),
          pathPrefix: httpPath(invoiceDetail.pathPrefix, `${path}.resourceReferences.detail.invoice.pathPrefix`),
        },
        attachment: {
          label: boundedText(attachmentDetail.label, `${path}.resourceReferences.detail.attachment.label`, 100),
          pathPrefix: httpPath(attachmentDetail.pathPrefix, `${path}.resourceReferences.detail.attachment.pathPrefix`),
        },
      },
    },
    pdf: {
      openButtonSelector: boundedText(pdf.openButtonSelector, `${path}.pdf.openButtonSelector`, 500),
      contentPathPrefix: httpPath(pdf.contentPathPrefix, `${path}.pdf.contentPathPrefix`),
      contentPathSuffix: httpPath(pdf.contentPathSuffix, `${path}.pdf.contentPathSuffix`),
      mediaType: boundedText(pdf.mediaType, `${path}.pdf.mediaType`, 200),
    },
  };
}

function secretSource(value: unknown, purpose: string, path: string, requireSourceKey = false): SecretSourceSpec {
  const source = record(value, path);
  const targetKey = envName(source.targetKey, `${path}.targetKey`);
  const sourceKey = nullableText(source.sourceKey, `${path}.sourceKey`);
  if (requireSourceKey && sourceKey === null) throw new Error(`${path}.sourceKey 必须显式声明`);
  if (sourceKey !== null) envName(sourceKey, `${path}.sourceKey`);
  return { purpose, sourceRef: text(source.sourceRef, `${path}.sourceRef`), sourceKey, targetKey };
}

function prometheusSelector(value: unknown, path: string): PrometheusSelectorSpec {
  const sourceRef = text(value, `${path}.configRef`);
  const resolved = resolveConfigRef(sourceRef, `${path}.configRef`);
  const selector = record(resolved.value, sourceRef);
  return {
    sourceRef,
    key: kubernetesLabelKey(selector.key, `${path}.configRef.key`),
    value: kubernetesLabelValue(selector.value, `${path}.configRef.value`),
    presence: true,
    fingerprint: `sha256:${resolved.sha256}`,
    valuesPrinted: false,
  };
}

function record(value: unknown, path: string): Record<string, unknown> {
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} 必须是 YAML object`);
  return value as Record<string, unknown>;
}

function text(value: unknown, path: string): string {
  if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`);
  return value.trim();
}

function boundedText(value: unknown, path: string, max: number): string {
  const result = text(value, path);
  if (result.length > max || result.includes("\0")) throw new Error(`${path} 必须是不超过 ${max} 字符的文本`);
  return result;
}

function pngFileName(value: unknown, path: string): string {
  const result = boundedText(value, path, 120);
  if (!/^[A-Za-z0-9._-]+\.png$/u.test(result)) throw new Error(`${path} 必须是安全 PNG 文件名`);
  return result;
}

function nullableText(value: unknown, path: string): string | null {
  return value === undefined || value === null ? null : text(value, path);
}

function bool(value: unknown, path: string): boolean {
  if (typeof value !== "boolean") throw new Error(`${path} 必须是 boolean`);
  return value;
}

function positiveInteger(value: unknown, path: string, max: number): number {
  if (!Number.isInteger(value) || Number(value) <= 0 || Number(value) > max) throw new Error(`${path} 必须是 1-${max} 的整数`);
  return Number(value);
}

function nonNegativeInteger(value: unknown, path: string): number {
  if (!Number.isSafeInteger(value) || Number(value) < 0) throw new Error(`${path} 必须是非负安全整数`);
  return Number(value);
}

function simpleId(value: string, path: string): string {
  if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path} 必须是简单 ID`);
  return value;
}

function kubernetesName(value: unknown, path: string): string {
  const result = text(value, path);
  if (result.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/u.test(result)) throw new Error(`${path} 必须是 Kubernetes DNS label`);
  return result;
}

function postgresIdentifier(value: unknown, path: string): string {
  const result = text(value, path);
  if (!/^[a-z_][a-z0-9_]{0,62}$/u.test(result)) throw new Error(`${path} 必须是 PostgreSQL identifier`);
  return result;
}

function imageReference(value: unknown, path: string): string {
  const result = text(value, path);
  if (/\s/u.test(result) || !result.includes(":")) throw new Error(`${path} 必须是带 tag/template 的镜像引用`);
  return result;
}

function imageRepository(value: unknown, path: string): string {
  const result = text(value, path);
  if (/\s/u.test(result) || result.includes("@") || /:[^/]+$/u.test(result)) throw new Error(`${path} 必须是不带 tag/digest 的镜像仓库`);
  return result;
}

function relativePath(value: unknown, path: string): string {
  const result = text(value, path);
  if (result.startsWith("/") || result.split(/[\\/]/u).includes("..")) throw new Error(`${path} 必须是仓库相对路径`);
  return result;
}

function absolutePath(value: unknown, path: string): string {
  const result = text(value, path);
  if (!result.startsWith("/") || result.includes("\0")) throw new Error(`${path} 必须是绝对路径`);
  return result;
}

function envName(value: unknown, path: string): string {
  const result = text(value, path);
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(result)) throw new Error(`${path} 必须是合法环境变量名`);
  return result;
}

function duration(value: unknown, path: string): string {
  const result = text(value, path);
  if (!/^[1-9][0-9]*(?:ms|s|m|h)$/u.test(result)) throw new Error(`${path} 必须是正 duration`);
  return result;
}

function httpPath(value: unknown, path: string): string {
  const result = text(value, path);
  if (!result.startsWith("/") || /[\s?#]/u.test(result)) throw new Error(`${path} 必须是绝对 HTTP path`);
  return result;
}

function httpsUrl(value: unknown, path: string): string {
  const result = text(value, path);
  const parsed = new URL(result);
  if (parsed.protocol !== "https:") throw new Error(`${path} 必须是 HTTPS URL`);
  return result;
}

function httpUrl(value: unknown, path: string): string {
  const result = text(value, path);
  const parsed = new URL(result);
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`${path} 必须是 HTTP(S) URL`);
  return result;
}

function ipv4Address(value: unknown, path: string): string {
  const result = text(value, path);
  const octets = result.split(".");
  if (octets.length !== 4 || octets.some((item) => !/^(?:0|[1-9][0-9]{0,2})$/u.test(item) || Number(item) > 255)) throw new Error(`${path} 必须是 IPv4 地址`);
  return result;
}

function otlpGrpcEndpoint(value: unknown, path: string): string {
  const result = text(value, path);
  if (result.includes("://") || !/^[A-Za-z0-9.-]+:[1-9][0-9]{0,4}$/u.test(result)) throw new Error(`${path} 必须是 OTLP gRPC host:port`);
  const port = Number(result.slice(result.lastIndexOf(":") + 1));
  if (port > 65_535) throw new Error(`${path} 端口必须是 1-65535`);
  return result;
}

function kubernetesLabelKey(value: unknown, path: string): string {
  const result = text(value, path);
  const parts = result.split("/");
  const name = parts.pop() ?? "";
  const prefix = parts.length === 1 ? parts[0] : null;
  const namePattern = /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u;
  const prefixPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/u;
  if (parts.length > 1 || !namePattern.test(name) || (prefix !== null && !prefixPattern.test(prefix))) throw new Error(`${path} 必须是 Kubernetes label key`);
  return result;
}

function kubernetesLabelValue(value: unknown, path: string): string {
  const result = text(value, path);
  if (!/^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(result)) throw new Error(`${path} 必须是 Kubernetes label value`);
  return result;
}

function nonEmptyStringList(value: unknown, path: string): string[] {
  if (!Array.isArray(value) || value.length === 0) throw new Error(`${path} 必须是非空字符串数组`);
  return value.map((item, index) => text(item, `${path}[${index}]`));
}

function stringList<T extends string>(value: unknown, path: string, allowed: readonly T[]): T[] {
  const items = nonEmptyStringList(value, path).map((item) => oneOf(item, path, allowed));
  if (new Set(items).size !== items.length) throw new Error(`${path} 不能包含重复值`);
  return items;
}

function oneOf<T extends string>(value: unknown, path: string, allowed: readonly T[]): T {
  if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} 必须是 ${allowed.join("、")}`);
  return value as T;
}

function exact(value: unknown, path: string, expected: string): string {
  if (value !== expected) throw new Error(`${path} 必须是 ${expected}`);
  return expected;
}
