import Ajv, { type ErrorObject } from "ajv";
import addFormats from "ajv-formats";
import {
  DashboardDocumentSchema,
  type DashboardDocument,
} from "./schema";

type DashboardValidationIssue = ErrorObject | SemanticValidationIssue;

interface SemanticValidationIssue {
  instancePath: string;
  schemaPath: string;
  keyword: "semantic";
  params: Record<string, unknown>;
  message: string;
}

export interface DashboardValidationFailure {
  valid: false;
  errors: string[];
  details: DashboardValidationIssue[];
}

export interface DashboardValidationSuccess {
  valid: true;
  data: DashboardDocument;
}

export type DashboardValidationResult =
  | DashboardValidationFailure
  | DashboardValidationSuccess;

const ajv = addFormats(
  new Ajv({
    allErrors: true,
    strict: false,
    strictNumbers: true,
  }),
);

const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);

export function formatValidationErrors(
  errors: DashboardValidationIssue[] = [],
): string[] {
  return errors.map((error) => {
    const path = error.instancePath || "/";
    const suffix = formatErrorParams(error);
    const message = error.message || "is invalid";
    return `${path} ${message}${suffix}`;
  });
}

export function validateDashboardDocument(
  value: unknown,
): DashboardValidationResult {
  const finiteNumberIssues: SemanticValidationIssue[] = [];
  const undefinedIssues: SemanticValidationIssue[] = [];
  collectFiniteNumberIssues(value, "", finiteNumberIssues);
  collectUndefinedIssues(value, "", undefinedIssues);

  if (validateDashboard(value)) {
    const semanticIssues = [
      ...finiteNumberIssues,
      ...undefinedIssues,
      ...validateSemanticRules(value),
    ];
    if (semanticIssues.length === 0) {
      return { valid: true, data: value };
    }

    return {
      valid: false,
      errors: formatValidationErrors(semanticIssues),
      details: semanticIssues,
    };
  }

  const details = [
    ...(validateDashboard.errors || []),
    ...finiteNumberIssues,
    ...undefinedIssues,
  ];
  return {
    valid: false,
    errors: formatValidationErrors(details),
    details,
  };
}

export function assertDashboardDocument(
  value: unknown,
): asserts value is DashboardDocument {
  const result = validateDashboardDocument(value);
  if (!result.valid) {
    throw new Error(`Invalid dashboard document: ${result.errors.join("; ")}`);
  }
}

export function isDashboardDocument(value: unknown): value is DashboardDocument {
  return validateDashboardDocument(value).valid;
}

function formatErrorParams(error: DashboardValidationIssue): string {
  if (error.keyword === "additionalProperties") {
    const additionalProperty = error.params.additionalProperty;
    return typeof additionalProperty === "string"
      ? `: ${additionalProperty}`
      : "";
  }

  if (error.keyword === "semantic") {
    const id = error.params.id;
    const ref = error.params.ref;
    if (typeof id === "string") return `: ${id}`;
    if (typeof ref === "string") return `: ${ref}`;
  }

  return "";
}

function validateSemanticRules(document: DashboardDocument): SemanticValidationIssue[] {
  const issues: SemanticValidationIssue[] = [];

  collectDuplicateIdIssues("telemetry", document.telemetry, issues);
  collectDuplicateIdIssues("serviceGroups", document.serviceGroups, issues);
  collectDuplicateIdIssues("statusStrips", document.statusStrips, issues);
  collectDuplicateIdIssues("modules", document.modules || [], issues);

  document.serviceGroups.forEach((group, groupIndex) => {
    collectDuplicateIdIssues(
      `serviceGroups/${groupIndex}/services`,
      group.services,
      issues,
    );
  });

  document.statusStrips.forEach((strip, stripIndex) => {
    collectDuplicateIdIssues(
      `statusStrips/${stripIndex}/items`,
      strip.items,
      issues,
    );
  });

  collectMissingReferenceIssues(
    "layout/telemetry",
    document.layout.telemetry,
    new Set(document.telemetry.map((item) => item.id)),
    issues,
  );
  collectMissingReferenceIssues(
    "layout/serviceGroups",
    document.layout.serviceGroups,
    new Set(document.serviceGroups.map((item) => item.id)),
    issues,
  );
  collectMissingReferenceIssues(
    "layout/statusStrips",
    document.layout.statusStrips,
    new Set(document.statusStrips.map((item) => item.id)),
    issues,
  );
  collectMissingReferenceIssues(
    "layout/modules",
    document.layout.modules || [],
    new Set((document.modules || []).map((item) => item.id)),
    issues,
  );

  document.telemetry.forEach((card, index) => {
    if (card.value.kind === "text" && card.thresholds !== undefined) {
      issues.push(
        semanticIssue(
          `/telemetry/${index}/thresholds`,
          "must not be set for text metric values",
          { id: card.id },
        ),
      );
    }

    if (
      card.value.kind === "percent" &&
      ((card.thresholds?.warning !== undefined && card.thresholds.warning > 100) ||
        (card.thresholds?.danger !== undefined && card.thresholds.danger > 100))
    ) {
      issues.push(
        semanticIssue(
          `/telemetry/${index}/thresholds`,
          "percent thresholds must be between 0 and 100",
          { id: card.id },
        ),
      );
    }

    const warning = card.thresholds?.warning;
    const danger = card.thresholds?.danger;
    if (warning !== undefined && danger !== undefined && warning > danger) {
      issues.push(
        semanticIssue(
          `/telemetry/${index}/thresholds`,
          "warning threshold must be less than or equal to danger threshold",
          { id: card.id },
        ),
      );
    }
  });

  return issues;
}

function collectUndefinedIssues(
  value: unknown,
  path: string,
  issues: SemanticValidationIssue[],
) {
  if (value === undefined) {
    issues.push(
      semanticIssue(path || "/", "must be omitted instead of undefined", {}),
    );
    return;
  }

  if (Array.isArray(value)) {
    value.forEach((item, index) => {
      collectUndefinedIssues(item, `${path}/${index}`, issues);
    });
    return;
  }

  if (value && typeof value === "object") {
    Object.entries(value).forEach(([key, item]) => {
      collectUndefinedIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
    });
  }
}

function collectFiniteNumberIssues(
  value: unknown,
  path: string,
  issues: SemanticValidationIssue[],
) {
  if (typeof value === "number") {
    if (!Number.isFinite(value)) {
      issues.push(
        semanticIssue(path || "/", "must be a finite JSON number", {}),
      );
    }
    return;
  }

  if (Array.isArray(value)) {
    value.forEach((item, index) => {
      collectFiniteNumberIssues(item, `${path}/${index}`, issues);
    });
    return;
  }

  if (value && typeof value === "object") {
    Object.entries(value).forEach(([key, item]) => {
      collectFiniteNumberIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
    });
  }
}

function collectDuplicateIdIssues(
  collectionPath: string,
  items: Array<{ id: string }>,
  issues: SemanticValidationIssue[],
) {
  const seen = new Set<string>();
  items.forEach((item, index) => {
    if (seen.has(item.id)) {
      issues.push(
        semanticIssue(
          `/${collectionPath}/${index}/id`,
          "must be unique within its collection",
          { id: item.id },
        ),
      );
      return;
    }
    seen.add(item.id);
  });
}

function collectMissingReferenceIssues(
  layoutPath: string,
  refs: string[],
  validIds: Set<string>,
  issues: SemanticValidationIssue[],
) {
  refs.forEach((ref, index) => {
    if (!validIds.has(ref)) {
      issues.push(
        semanticIssue(
          `/${layoutPath}/${index}`,
          "must reference an existing item",
          { ref },
        ),
      );
    }
  });
}

function semanticIssue(
  instancePath: string,
  message: string,
  params: Record<string, unknown>,
): SemanticValidationIssue {
  return {
    instancePath,
    schemaPath: "",
    keyword: "semantic",
    params,
    message,
  };
}

function escapeJsonPointer(value: string): string {
  return value.replaceAll("~", "~0").replaceAll("/", "~1");
}
