import { readFileSync } from "node:fs";
import { z } from "zod";

export const CONFIG_VERSION = "config/v1" as const;

const envReferencePattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;

const surfaceSchema = z
  .object({
    module: z.string(),
    export: z.string(),
  })
  .strict();

const detectorsSchema = z
  .object({
    include: z.array(z.string()).optional(),
    exceptions: z.array(
      z.union([
        z.object({ decisionId: z.string().min(1) }).strict(),
        z.object({ contractId: z.string().min(1) }).strict(),
      ]),
    ).optional(),
  })
  .strict();

const scalarOrObjectSchema = z.union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.unknown())]);

const matrixSchema = z.object({ dimensions: z.record(z.string(), z.array(scalarOrObjectSchema)) }).strict();
const discoverySchema = z.object({ adapters: z.array(z.object({ id: z.string().min(1), module: z.string().min(1) }).strict()) }).strict();
const credentialsSchema = z.object({ adapters: z.array(z.object({ id: z.string().min(1), provider: z.literal("env"), reference: z.string().min(1) }).strict()) }).strict();
const networkSchema = z.object({ expectations: z.array(z.object({ id: z.string().min(1), url: z.url(), method: z.string().min(1) }).strict()) }).strict();
const contractsSchema = z.object({ references: z.array(z.string().min(1)) }).strict();
const redactionSchema = z.object({ patterns: z.array(z.string()), replacement: z.string() }).strict();
const artifactsSchema = z.object({ retention: z.object({ maxRuns: z.number().int().positive(), maxAgeDays: z.number().int().positive() }).strict() }).strict();
const timeoutsSchema = z.object({ defaultMs: z.number().int().positive(), phases: z.record(z.string(), z.number().int().positive()) }).strict();
const reportSchema = z.object({ mode: z.enum(["interactive", "static"]), autoOpen: z.boolean() }).strict();
const acceptedFindingsSchema = z.object({ store: z.string().min(1) }).strict();
const hookSchema = z.object({
  budgetMs: z.number().int().positive().optional(),
  dependencyMap: z.record(z.string().min(1), z.array(z.string().min(1)).min(1)).optional(),
}).strict();

const uiRouteSchema = z
  .object({
    pattern: z.string().min(1),
    fixtures: z
      .array(
        z
          .object({
            params: z.record(z.string().min(1), z.string()),
            label: z.string().min(1),
          })
          .strict(),
      )
      .optional(),
  })
  .strict();

const uiViewportSchema = z
  .object({
    name: z.string().min(1),
    width: z.number().int().positive(),
    height: z.number().int().positive(),
  })
  .strict();

const uiAuthSchema = z
  .object({
    adapters: z.record(
      z.string().min(1),
      z.object({ module: z.string().min(1), export: z.string().min(1) }).strict(),
    ),
  })
  .strict();

function duplicateValues(values: readonly string[]): string[] {
  const seen = new Set<string>();
  const duplicates = new Set<string>();
  for (const value of values) {
    if (seen.has(value)) {
      duplicates.add(value);
    }
    seen.add(value);
  }
  return [...duplicates].sort();
}

const nonBlankStringSchema = z.string().refine((value) => value.trim().length > 0, {
  message: "must not be blank",
});

const uiJourneyDeclarationSchema = z
  .object({
    id: nonBlankStringSchema,
    status: z.enum(["inferred", "confirmed"]),
    appliesTo: z
      .object({
        routes: z.array(nonBlankStringSchema).min(1),
        roles: z.array(nonBlankStringSchema).optional(),
        viewports: z.array(nonBlankStringSchema).optional(),
      })
      .strict()
      .superRefine((value, ctx) => {
        for (const [field, values] of Object.entries({
          routes: value.routes,
          roles: value.roles,
          viewports: value.viewports,
        })) {
          for (const duplicate of duplicateValues(values ?? [])) {
            ctx.addIssue({
              code: "custom",
              path: [field],
              message: `duplicate ${field.slice(0, -1)} selector: ${duplicate}`,
            });
          }
        }
      }),
    adapter: z
      .object({ module: nonBlankStringSchema, export: nonBlankStringSchema })
      .strict(),
  })
  .strict();

const uiJourneysSchema = z
  .object({
    schemaVersion: z.literal("ui.journeys/v1"),
    declarations: z.array(uiJourneyDeclarationSchema),
  })
  .strict()
  .superRefine((value, ctx) => {
    for (const duplicate of duplicateValues(value.declarations.map((declaration) => declaration.id))) {
      ctx.addIssue({
        code: "custom",
        path: ["declarations"],
        message: `duplicate journey declaration id: ${duplicate}`,
      });
    }
  });

export type UiJourneyDeclaration = z.infer<typeof uiJourneyDeclarationSchema>;
export type UiJourneysConfig = z.infer<typeof uiJourneysSchema>;

const uiSchema = z
  .object({
    baseUrl: z.url(),
    routes: z.array(uiRouteSchema),
    roles: z.array(z.string().min(1)).optional(),
    auth: uiAuthSchema.optional(),
    journeys: uiJourneysSchema.optional(),
    interactionDiscovery: z.object({ enabled: z.boolean() }).strict().optional(),
    locales: z.array(z.string().min(1)).optional(),
    viewports: z.array(uiViewportSchema).optional(),
    families: z.array(z.string().min(1)).optional(),
    timeouts: z
      .object({
        navigateMs: z.number().int().positive(),
        settleMs: z.number().int().positive(),
      })
      .strict()
      .optional(),
  })
  .strict();

const envAllowlistSchema = z
  .object({
    allow: z.array(z.string()),
  })
  .strict();

export const invariantumConfigSchema = z
  .object({
    schemaVersion: z.literal(CONFIG_VERSION),
    seed: z.string().optional(),
    surfaces: z.array(surfaceSchema),
    detectors: detectorsSchema.optional(),
    env: envAllowlistSchema.optional(),
    matrix: matrixSchema.optional(),
    ui: uiSchema.optional(),
    discovery: discoverySchema.optional(),
    credentials: credentialsSchema.optional(),
    network: networkSchema.optional(),
    contracts: contractsSchema.optional(),
    redaction: redactionSchema.optional(),
    artifacts: artifactsSchema.optional(),
    concurrency: z.number().int().positive().optional(),
    timeouts: timeoutsSchema.optional(),
    report: reportSchema.optional(),
    journeys: z.array(z.string()).optional(),
    invariants: z.array(z.string()).optional(),
    acceptedFindings: acceptedFindingsSchema.optional(),
    hook: hookSchema.optional(),
  })
  .strict();

export type InvariantumConfig = z.infer<typeof invariantumConfigSchema>;

export type ConfigErrorCode =
  | "parse-error"
  | "validation-error"
  | "unknown-field"
  | "unsupported-version"
  | "env-reference-not-allowed"
  | "read-error";

export interface ConfigError {
  readonly code: ConfigErrorCode;
  readonly path: string;
  readonly message: string;
  readonly recoveryInstructions: string;
}

export type ConfigResult =
  | { ok: true; config: InvariantumConfig }
  | { ok: false; error: ConfigError };

function defaultConfig(): InvariantumConfig {
  return {
    schemaVersion: CONFIG_VERSION,
    surfaces: [],
  };
}

function formatZodIssuePath(path: PropertyKey[]): string {
  if (path.length === 0) {
    return "$";
  }

  return path.reduce<string>((current, segment) => {
    if (typeof segment === "number") {
      return `${current}[${String(segment)}]`;
    }
    return current === "$" ? String(segment) : `${current}.${String(segment)}`;
  }, "$");
}

function schemaErrorCodeFromZodIssue(issue: z.core.$ZodIssue): ConfigErrorCode {
  if (issue.code === "unrecognized_keys") {
    return "unknown-field";
  }
  return "validation-error";
}

function formatZodIssue(issue: z.core.$ZodIssue): { path: string; message: string } {
  if (issue.code === "unrecognized_keys" && "keys" in issue) {
    const keys = issue.keys;
    if (Array.isArray(keys) && keys.length > 0) {
      const firstKey = keys[0];
      const path =
        typeof firstKey === "string"
          ? formatZodIssuePath([...issue.path, firstKey])
          : formatZodIssuePath(issue.path);
      return { path, message: issue.message };
    }
  }

  return {
    path: formatZodIssuePath(issue.path),
    message: issue.message,
  };
}

function configError(config: {
  code: ConfigErrorCode;
  path: string;
  message: string;
  recoveryInstructions: string;
}): ConfigResult {
  return { ok: false, error: config };
}

function unsupportedVersionError(version: string): ConfigResult {
  return configError({
    code: "unsupported-version",
    path: "schemaVersion",
    message: `unsupported schema version: ${version}`,
    recoveryInstructions: [
      `Set schemaVersion to "${CONFIG_VERSION}" or upgrade invariantum to a release that supports ${version}.`,
      "Do not run verification until the config schema version matches this CLI.",
    ].join(" "),
  });
}

function parseError(path: string, message: string): ConfigResult {
  return configError({
    code: "parse-error",
    path,
    message,
    recoveryInstructions: [
      `Repair JSON syntax in ${path}.`,
      "Validate the file with a JSON parser before rerunning invariantum.",
    ].join(" "),
  });
}

function validationError(path: string, message: string, code: ConfigErrorCode): ConfigResult {
  const recoveryByCode: Record<ConfigErrorCode, string> = {
    "parse-error": "Repair the config file syntax.",
    "validation-error": "Correct the invalid field and rerun invariantum.",
    "unknown-field": "Remove unrecognized keys from the config file.",
    "unsupported-version": `Use schemaVersion "${CONFIG_VERSION}".`,
    "env-reference-not-allowed": "Add the variable to env.allow or replace the reference with a literal value.",
    "read-error": "Ensure the config path is readable and retry.",
  };

  return configError({
    code,
    path,
    message,
    recoveryInstructions: recoveryByCode[code],
  });
}

function collectEnvReferences(value: unknown, path = "$"): Array<{ path: string; name: string }> {
  if (typeof value === "string") {
    const references: Array<{ path: string; name: string }> = [];
    for (const match of value.matchAll(envReferencePattern)) {
      const name = match[1];
      if (name !== undefined) {
        references.push({ path, name });
      }
    }
    return references;
  }

  if (Array.isArray(value)) {
    return value.flatMap((entry, index) => collectEnvReferences(entry, `${path}[${String(index)}]`));
  }

  if (typeof value === "object" && value !== null) {
    return Object.entries(value).flatMap(([key, entry]) =>
      collectEnvReferences(entry, path === "$" ? key : `${path}.${key}`),
    );
  }

  return [];
}

function validateEnvReferences(config: InvariantumConfig): ConfigResult | null {
  const allowed = new Set(config.env?.allow ?? []);
  const references = collectEnvReferences(config);

  for (const reference of references) {
    if (!allowed.has(reference.name)) {
      return configError({
        code: "env-reference-not-allowed",
        path: reference.path,
        message: `environment reference \${${reference.name}} is not listed in env.allow`,
        recoveryInstructions: [
          `Add "${reference.name}" to env.allow or remove \${${reference.name}} from the config.`,
          "Secret values must be referenced, not embedded directly in config files.",
        ].join(" "),
      });
    }
  }

  return null;
}

function parseConfigObject(value: unknown, configPath: string): ConfigResult {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    return validationError("$", "expected config object", "validation-error");
  }

  const record = value as Record<string, unknown>;
  const detectors = record.detectors;
  if (
    typeof detectors === "object" &&
    detectors !== null &&
    !Array.isArray(detectors) &&
    "exclude" in detectors
  ) {
    return validationError(
      "detectors.exclude",
      "inline broad detector ignores are forbidden; use reviewed decisionId or contractId exceptions",
      "validation-error",
    );
  }
  const schemaVersion = record.schemaVersion;
  if (typeof schemaVersion === "string" && schemaVersion !== CONFIG_VERSION) {
    return unsupportedVersionError(schemaVersion);
  }

  const result = invariantumConfigSchema.safeParse(value);
  if (!result.success) {
    const issue = result.error.issues[0];
    if (!issue) {
      return validationError("$", "validation failed", "validation-error");
    }

    const formatted = formatZodIssue(issue);
    return validationError(
      formatted.path,
      formatted.message,
      schemaErrorCodeFromZodIssue(issue),
    );
  }

  const envError = validateEnvReferences(result.data);
  if (envError !== null) {
    return envError;
  }

  void configPath;
  return { ok: true, config: result.data };
}

export function loadConfig(
  path: string | undefined,
  env: Record<string, string | undefined>,
): ConfigResult {
  void env;

  if (path === undefined) {
    return { ok: true, config: defaultConfig() };
  }

  let raw: string;
  try {
    raw = readFileSync(path, "utf8");
  } catch (error) {
    if (
      typeof error === "object" &&
      error !== null &&
      "code" in error &&
      error.code === "ENOENT"
    ) {
      return { ok: true, config: defaultConfig() };
    }

    const message = error instanceof Error ? error.message : "failed to read config file";
    return configError({
      code: "read-error",
      path,
      message,
      recoveryInstructions: [
        `Ensure ${path} exists and is readable.`,
        "Retry after fixing filesystem permissions or correcting the --config path.",
      ].join(" "),
    });
  }

  let parsed: unknown;
  try {
    parsed = JSON.parse(raw) as unknown;
  } catch (error) {
    const message = error instanceof Error ? error.message : "invalid JSON";
    return parseError(path, message);
  }

  return parseConfigObject(parsed, path);
}
