import { createHash } from "crypto";
import { Op, type Transaction } from "sequelize";
import { initDb } from "@/lib/db";
import { conditionsForType, optionValuesFor } from "@/lib/domestic/rules";
import type { DomesticContentInput } from "@/lib/validation/domestic";
import type {
  DomesticConditionConfig,
  DomesticConditionType,
  DomesticEdgeType,
  DomesticOptionsConfig,
  DomesticQuestionType,
} from "@/types/participant";

function slugify(label: string) {
  return (
    label
      .toLowerCase()
      .normalize("NFKD")
      .replace(/[^a-z0-9]+/g, "_")
      .replace(/^_+|_+$/g, "")
      .slice(0, 48) || "option"
  );
}

/**
 * Normalise options so each has a stable machine value. Existing values are
 * preserved so branching conditions keep matching across versions.
 */
export function buildOptionsConfig(
  content: {
    type: DomesticQuestionType;
    options?: Array<{ value?: string; label: string }>;
    min?: number | null;
    max?: number | null;
    minLabel?: string;
    maxLabel?: string;
  }
): DomesticOptionsConfig | null {
  switch (content.type) {
    case "single_choice":
    case "multi_choice": {
      const used = new Set<string>();
      const options = (content.options ?? []).map((o) => {
        let value = o.value?.trim() ? o.value.trim() : slugify(o.label);
        let n = 2;
        const base = value;
        while (used.has(value)) value = `${base}_${n++}`;
        used.add(value);
        return { value, label: o.label.trim() };
      });
      return { options };
    }
    case "rating":
      return {
        min: content.min ?? 1,
        max: content.max ?? 5,
        ...(content.minLabel ? { minLabel: content.minLabel } : {}),
        ...(content.maxLabel ? { maxLabel: content.maxLabel } : {}),
      };
    case "number":
      return {
        ...(content.min != null ? { min: content.min } : {}),
        ...(content.max != null ? { max: content.max } : {}),
      };
    default:
      return null;
  }
}

export function contentHashFor(parts: {
  type: string;
  prompt: string;
  helpText: string | null;
  purpose: string | null;
  optionsConfig: DomesticOptionsConfig | null;
}) {
  return createHash("sha256")
    .update(
      JSON.stringify([
        parts.type,
        parts.prompt,
        parts.helpText,
        parts.purpose,
        parts.optionsConfig,
      ])
    )
    .digest("hex");
}

export type NormalisedContent = {
  type: DomesticQuestionType;
  prompt: string;
  helpText: string | null;
  purpose: string | null;
  optionsConfig: DomesticOptionsConfig | null;
  contentHash: string;
};

export function normaliseContent(
  content: Omit<DomesticContentInput, "helpText" | "purpose"> & {
    helpText: string | null;
    purpose: string | null;
  }
): NormalisedContent {
  const optionsConfig = buildOptionsConfig(content);
  const base = {
    type: content.type,
    prompt: content.prompt.trim(),
    helpText: content.helpText,
    purpose: content.purpose,
    optionsConfig,
  };
  return { ...base, contentHash: contentHashFor(base) };
}

/** Check an edge's condition makes sense for the source version. */
export function validateEdgeCondition(
  sourceType: DomesticQuestionType,
  sourceOptions: DomesticOptionsConfig | null,
  conditionType: DomesticConditionType,
  config: DomesticConditionConfig | null
): string | null {
  if (!conditionsForType(sourceType).includes(conditionType)) {
    return "That condition doesn't apply to this question type";
  }
  if (conditionType === "always") return null;

  const allowed = optionValuesFor(sourceType, sourceOptions);
  const isChoice =
    sourceType === "single_choice" ||
    sourceType === "multi_choice" ||
    sourceType === "yes_no";

  if (conditionType === "any_of") {
    const values = config?.values ?? [];
    if (!values.length) return "Pick at least one answer";
    if (isChoice && !values.every((v) => allowed.includes(String(v)))) {
      return "Condition references an unknown option";
    }
    return null;
  }

  if (config?.value === undefined || config.value === "") {
    return "Enter the answer to match";
  }
  if (isChoice && !allowed.includes(String(config.value))) {
    return "Condition references an unknown option";
  }
  if (
    (sourceType === "rating" || sourceType === "number") &&
    !Number.isFinite(Number(config.value))
  ) {
    return "Condition value must be a number";
  }
  return null;
}

/** Outgoing unlock/boost adjacency over current versions. */
async function forwardGraph(transaction?: Transaction) {
  const { DomesticQuestion, DomesticQuestionEdge } = initDb();
  const questions = await DomesticQuestion.findAll({
    where: { currentVersionId: { [Op.ne]: null } },
    attributes: ["id", "currentVersionId"],
    transaction,
  });
  const versionToQuestion = new Map(
    questions.map((q) => [q.currentVersionId as string, q.id])
  );
  const now = new Date();
  const edges = await DomesticQuestionEdge.findAll({
    where: {
      fromQuestionVersionId: { [Op.in]: Array.from(versionToQuestion.keys()) },
      edgeType: { [Op.in]: ["unlock", "boost"] },
      toQuestionId: { [Op.ne]: null },
      [Op.or]: [{ effectiveTo: null }, { effectiveTo: { [Op.gt]: now } }],
    },
    attributes: ["fromQuestionVersionId", "toQuestionId"],
    transaction,
  });

  const graph = new Map<string, Set<string>>();
  for (const e of edges) {
    const from = versionToQuestion.get(e.fromQuestionVersionId);
    if (!from || !e.toQuestionId) continue;
    if (!graph.has(from)) graph.set(from, new Set());
    graph.get(from)!.add(e.toQuestionId);
  }
  return graph;
}

/** True if adding from → to would create a cycle of unlock/boost edges. */
export async function wouldCreateCycle(
  fromQuestionId: string,
  toQuestionId: string,
  transaction?: Transaction
) {
  if (fromQuestionId === toQuestionId) return true;
  const graph = await forwardGraph(transaction);
  const stack = [toQuestionId];
  const seen = new Set<string>();
  while (stack.length) {
    const node = stack.pop()!;
    if (node === fromQuestionId) return true;
    if (seen.has(node)) continue;
    seen.add(node);
    for (const next of Array.from(graph.get(node) ?? [])) stack.push(next);
  }
  return false;
}

export function edgeNeedsTarget(edgeType: DomesticEdgeType) {
  return edgeType !== "terminate";
}

/** Pre-publish checks. Returns a list of human-readable problems. */
export async function validateForPublish(
  questionId: string,
  transaction?: Transaction
): Promise<string[]> {
  const { DomesticQuestion, DomesticQuestionVersion, DomesticQuestionEdge } = initDb();
  const problems: string[] = [];

  const question = await DomesticQuestion.findByPk(questionId, { transaction });
  if (!question?.currentVersionId) return ["Question has no content yet"];

  const version = await DomesticQuestionVersion.findByPk(question.currentVersionId, {
    transaction,
  });
  if (!version) return ["Current version is missing"];

  if (
    (version.type === "single_choice" || version.type === "multi_choice") &&
    (version.optionsConfig?.options?.length ?? 0) < 2
  ) {
    problems.push("Choice questions need at least two options");
  }
  if (question.sensitivity === "sensitive" && !version.purpose) {
    problems.push("Sensitive questions must state a purpose");
  }

  const now = new Date();
  const edges = await DomesticQuestionEdge.findAll({
    where: {
      fromQuestionVersionId: version.id,
      [Op.or]: [{ effectiveTo: null }, { effectiveTo: { [Op.gt]: now } }],
    },
    transaction,
  });

  const priorities = new Set<number>();
  for (const edge of edges) {
    const err = validateEdgeCondition(
      version.type,
      version.optionsConfig,
      edge.conditionType,
      edge.conditionConfig
    );
    if (err) problems.push(`Branch rule (priority ${edge.priority}): ${err}`);
    if (priorities.has(edge.priority)) {
      problems.push(`Two branch rules share priority ${edge.priority}`);
    }
    priorities.add(edge.priority);
    if (edge.toQuestionId) {
      if (edge.toQuestionId === question.id) {
        problems.push("A branch rule points back to this question");
      }
      const target = await DomesticQuestion.findByPk(edge.toQuestionId, {
        transaction,
        attributes: ["id", "code", "status"],
      });
      if (!target) problems.push("A branch rule targets a missing question");
      else if (target.status === "retired") {
        problems.push(`Branch target ${target.code} is retired`);
      }
    }
  }

  return problems;
}
