import type {
  JsonValue,
  PluginPropertyBatchOperation,
  PluginPropertyMutationResult,
  PluginSearchRowDto,
  PropertiesRevision,
} from "@thortiq/plugin-api";

export const JIRA_INPUT_PROPERTY = "JiraNumber";
export const SEARCH_QUERY = `has:${JIRA_INPUT_PROPERTY}`;
export const SEARCH_PAGE_SIZE = 100;
export const UPDATE_BATCH_SIZE = 100;
export const PROVIDER_BATCH_SIZE = 50;
export const PROVIDER_CONCURRENCY = 2;
export const PREVIEW_ITEM_LIMIT = 25;
export const CHECKPOINT_BATCH_LIMIT = 64;

const CONTROL_OR_UNSAFE =
  /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/u;
const JIRA_KEY = /^([A-Z][A-Z0-9_]{1,49})-([0-9]{1,15})$/u;

export interface JiraPropertyMappings {
  readonly status: string | null;
  readonly assignee: string | null;
  readonly lastSynced: string | null;
}

export interface JiraIssueProjection {
  readonly key: string;
  readonly status: string | null;
  readonly assignee: string | null;
  readonly updated: string;
}

export interface JiraNoteMapping {
  readonly id: string;
  readonly title: string;
  readonly path: string;
  readonly key: string;
  readonly properties: Readonly<Record<string, JsonValue>>;
  readonly propertiesRevision: PropertiesRevision;
}

export interface InvalidJiraNote {
  readonly title: string;
  readonly path: string;
  readonly reason: "missing-key" | "invalid-key" | "not-mutable";
}

export interface CollectedJiraNotes {
  readonly notes: readonly JiraNoteMapping[];
  readonly invalid: readonly InvalidJiraNote[];
  readonly keys: readonly string[];
  readonly notesByKey: ReadonlyMap<string, readonly JiraNoteMapping[]>;
  readonly duplicateKeys: readonly string[];
}

export interface JiraProjectionPlan {
  readonly id: string;
  readonly key: string;
  readonly title: string;
  readonly path: string;
  readonly changes: Readonly<Record<string, JsonValue | null>>;
  readonly expectedPropertiesRevision: PropertiesRevision;
  readonly currentProjectionFingerprint: string;
  readonly desiredProjectionFingerprint: string;
}

export interface JiraCheckpointBatch {
  readonly id: string;
  readonly etag: string;
  readonly mapping: string;
  readonly projections: Readonly<Record<string, string>>;
  readonly missing: readonly string[];
  readonly updatedThrough: string | null;
}

export interface JiraCheckpoint {
  readonly version: 1;
  readonly batches: readonly JiraCheckpointBatch[];
}

export interface ApplySummary {
  readonly results: readonly PluginPropertyMutationResult[];
  readonly counts: Readonly<Record<PluginPropertyMutationResult["status"], number>>;
}

export function normalizeJiraKey(value: unknown): string | null {
  if (typeof value !== "string") return null;
  const normalized = value.trim().toUpperCase();
  const match = JIRA_KEY.exec(normalized);
  if (!match) return null;
  const issueNumber = match[2].replace(/^0+/u, "");
  return issueNumber.length > 0 ? `${match[1]}-${issueNumber}` : null;
}

function propertyMatches(
  properties: Readonly<Record<string, JsonValue>>,
  requestedName: string,
): readonly JsonValue[] {
  const normalized = requestedName.toLocaleLowerCase("en-US");
  return Object.entries(properties)
    .filter(([name]) => name.toLocaleLowerCase("en-US") === normalized)
    .map(([, value]) => value);
}

export function collectJiraNotes(
  rows: readonly PluginSearchRowDto[],
): CollectedJiraNotes {
  const notes: JiraNoteMapping[] = [];
  const invalid: InvalidJiraNote[] = [];
  const notesByKey = new Map<string, JiraNoteMapping[]>();

  for (const row of rows) {
    const values = propertyMatches(row.properties, JIRA_INPUT_PROPERTY);
    if (values.length !== 1) {
      invalid.push({
        title: row.title,
        path: row.path,
        reason: "missing-key",
      });
      continue;
    }
    const key = normalizeJiraKey(values[0]);
    if (!key) {
      invalid.push({
        title: row.title,
        path: row.path,
        reason: "invalid-key",
      });
      continue;
    }
    if (row.kind !== "note" || row.id === null || row.propertiesRevision === null) {
      invalid.push({
        title: row.title,
        path: row.path,
        reason: "not-mutable",
      });
      continue;
    }
    const note: JiraNoteMapping = Object.freeze({
      id: row.id,
      title: row.title,
      path: row.path,
      key,
      properties: row.properties,
      propertiesRevision: row.propertiesRevision,
    });
    notes.push(note);
    const matches = notesByKey.get(key) ?? [];
    matches.push(note);
    notesByKey.set(key, matches);
  }

  const keys = [...notesByKey.keys()].sort(compareText);
  const frozenMap = new Map<string, readonly JiraNoteMapping[]>();
  for (const key of keys) {
    frozenMap.set(
      key,
      Object.freeze([...(notesByKey.get(key) ?? [])].sort(compareNote)),
    );
  }
  return Object.freeze({
    notes: Object.freeze([...notes].sort(compareNote)),
    invalid: Object.freeze([...invalid].sort(compareInvalidNote)),
    keys: Object.freeze(keys),
    notesByKey: frozenMap,
    duplicateKeys: Object.freeze(
      keys.filter(key => (notesByKey.get(key)?.length ?? 0) > 1),
    ),
  });
}

function normalizePropertyName(value: unknown): string | null | undefined {
  if (value === null || value === undefined || value === "") return null;
  if (typeof value !== "string") return undefined;
  const normalized = value.trim();
  if (normalized.length === 0) return null;
  if (normalized.length > 128 || CONTROL_OR_UNSAFE.test(normalized)) return undefined;
  if (normalized.toLocaleLowerCase("en-US") === JIRA_INPUT_PROPERTY.toLocaleLowerCase("en-US")) {
    return undefined;
  }
  return normalized;
}

export function normalizePropertyMappings(input: {
  readonly status: unknown;
  readonly assignee: unknown;
  readonly lastSynced: unknown;
}): { readonly mappings: JiraPropertyMappings | null; readonly errors: readonly string[] } {
  const status = normalizePropertyName(input.status);
  const assignee = normalizePropertyName(input.assignee);
  const lastSynced = normalizePropertyName(input.lastSynced);
  const errors: string[] = [];
  if (status === undefined) errors.push("statusProperty");
  if (assignee === undefined) errors.push("assigneeProperty");
  if (lastSynced === undefined) errors.push("lastSyncedProperty");
  const names = [status, assignee, lastSynced].filter(
    (value): value is string => typeof value === "string",
  );
  const unique = new Set(names.map(name => name.toLocaleLowerCase("en-US")));
  if (unique.size !== names.length) errors.push("duplicate-property-mapping");
  if (errors.length > 0) {
    return Object.freeze({ mappings: null, errors: Object.freeze(errors) });
  }
  return Object.freeze({
    mappings: Object.freeze({
      status: status ?? null,
      assignee: assignee ?? null,
      lastSynced: lastSynced ?? null,
    }),
    errors: Object.freeze([]),
  });
}

export function configuredPropertyNames(
  mappings: JiraPropertyMappings,
): readonly string[] {
  return Object.freeze(
    [mappings.status, mappings.assignee, mappings.lastSynced]
      .filter((value): value is string => value !== null),
  );
}

export function mappingSignature(mappings: JiraPropertyMappings): string {
  return stableFingerprint(JSON.stringify([
    mappings.status,
    mappings.assignee,
    mappings.lastSynced,
  ]));
}

export function issueChanges(
  issue: JiraIssueProjection,
  mappings: JiraPropertyMappings,
): Readonly<Record<string, JsonValue | null>> {
  const changes: Record<string, JsonValue | null> = {};
  if (mappings.status) changes[mappings.status] = issue.status;
  if (mappings.assignee) changes[mappings.assignee] = issue.assignee;
  if (mappings.lastSynced) changes[mappings.lastSynced] = issue.updated;
  return Object.freeze(changes);
}

function currentProjection(
  properties: Readonly<Record<string, JsonValue>>,
  mappings: JiraPropertyMappings,
): Readonly<Record<string, JsonValue | null>> {
  const projection: Record<string, JsonValue | null> = {};
  for (const name of configuredPropertyNames(mappings)) {
    projection[name] = Object.hasOwn(properties, name)
      ? properties[name]
      : null;
  }
  return projection;
}

function scalarEqual(left: JsonValue | null, right: JsonValue | null): boolean {
  return left === right;
}

export function projectionFingerprint(
  projection: Readonly<Record<string, JsonValue | null>>,
): string {
  const entries = Object.entries(projection).sort(([left], [right]) => compareText(left, right));
  return stableFingerprint(JSON.stringify(entries));
}

export function planJiraProjections(
  notes: readonly JiraNoteMapping[],
  issues: ReadonlyMap<string, JiraIssueProjection>,
  mappings: JiraPropertyMappings,
): readonly JiraProjectionPlan[] {
  const plans: JiraProjectionPlan[] = [];
  for (const note of [...notes].sort(compareNote)) {
    const issue = issues.get(note.key);
    if (!issue) continue;
    const changes = issueChanges(issue, mappings);
    const current = currentProjection(note.properties, mappings);
    const changed = Object.entries(changes).some(
      ([name, value]) => !scalarEqual(current[name] ?? null, value),
    );
    if (!changed) continue;
    plans.push(Object.freeze({
      id: note.id,
      key: note.key,
      title: note.title,
      path: note.path,
      changes,
      expectedPropertiesRevision: note.propertiesRevision,
      currentProjectionFingerprint: projectionFingerprint(current),
      desiredProjectionFingerprint: projectionFingerprint(changes),
    }));
  }
  return Object.freeze(plans);
}

export function providerBatchId(
  keys: readonly string[],
  mappings: JiraPropertyMappings,
): string {
  return stableFingerprint(JSON.stringify([
    [...keys].sort(compareText),
    mappingSignature(mappings),
  ]));
}

export function isCheckpointProjectionCurrent(
  checkpoint: JiraCheckpointBatch,
  notesByKey: ReadonlyMap<string, readonly JiraNoteMapping[]>,
  mappings: JiraPropertyMappings,
): boolean {
  if (checkpoint.mapping !== mappingSignature(mappings)) return false;
  const missing = new Set(checkpoint.missing);
  for (const [key, notes] of notesByKey) {
    if (missing.has(key)) continue;
    const expected = checkpoint.projections[key];
    if (!expected) return false;
    for (const note of notes) {
      const current = currentProjection(note.properties, mappings);
      if (projectionFingerprint(current) !== expected) return false;
    }
  }
  return true;
}

export function parseCheckpoint(value: JsonValue | null): JiraCheckpoint {
  if (!plainRecord(value) || value.version !== 1 || !Array.isArray(value.batches)) {
    return Object.freeze({ version: 1, batches: Object.freeze([]) });
  }
  const batches: JiraCheckpointBatch[] = [];
  for (const candidate of value.batches.slice(0, CHECKPOINT_BATCH_LIMIT)) {
    if (!plainRecord(candidate)
      || typeof candidate.id !== "string"
      || typeof candidate.etag !== "string"
      || typeof candidate.mapping !== "string"
      || !plainRecord(candidate.projections)
      || !Array.isArray(candidate.missing)
      || !(candidate.updatedThrough === null || typeof candidate.updatedThrough === "string")) {
      continue;
    }
    const projections: Record<string, string> = {};
    let valid = true;
    for (const [key, fingerprint] of Object.entries(candidate.projections)) {
      if (!normalizeJiraKey(key) || typeof fingerprint !== "string" || fingerprint.length > 64) {
        valid = false;
        break;
      }
      projections[key] = fingerprint;
    }
    const missing = candidate.missing
      .map(normalizeJiraKey)
      .filter((key): key is string => key !== null)
      .slice(0, PROVIDER_BATCH_SIZE)
      .sort(compareText);
    if (!valid
      || candidate.id.length > 64
      || candidate.etag.length > 512
      || candidate.mapping.length > 64) {
      continue;
    }
    batches.push(Object.freeze({
      id: candidate.id,
      etag: candidate.etag,
      mapping: candidate.mapping,
      projections: Object.freeze(projections),
      missing: Object.freeze(missing),
      updatedThrough: candidate.updatedThrough,
    }));
  }
  return Object.freeze({ version: 1, batches: Object.freeze(batches) });
}

export function mergeCheckpointBatches(
  previous: JiraCheckpoint,
  updates: readonly JiraCheckpointBatch[],
): JiraCheckpoint {
  const byId = new Map(previous.batches.map(batch => [batch.id, batch]));
  for (const update of updates) byId.set(update.id, update);
  const currentIds = new Set(updates.map(update => update.id));
  const ordered = [
    ...updates,
    ...[...byId.values()]
      .filter(batch => !currentIds.has(batch.id))
      .sort((left, right) => compareText(left.id, right.id)),
  ].slice(0, CHECKPOINT_BATCH_LIMIT);
  return Object.freeze({ version: 1, batches: Object.freeze(ordered) });
}

export function checkpointBatchMap(
  checkpoint: JiraCheckpoint,
): ReadonlyMap<string, JiraCheckpointBatch> {
  return new Map(checkpoint.batches.map(batch => [batch.id, batch]));
}

export function chunkBounded<T>(
  values: readonly T[],
  size: number,
): readonly (readonly T[])[] {
  if (!Number.isSafeInteger(size) || size < 1) throw new TypeError("Chunk size must be positive.");
  const chunks: T[][] = [];
  for (let index = 0; index < values.length; index += size) {
    chunks.push(values.slice(index, index + size));
  }
  return Object.freeze(chunks.map(chunk => Object.freeze(chunk)));
}

export function toPropertyBatchOperations(
  plans: readonly JiraProjectionPlan[],
): readonly PluginPropertyBatchOperation[] {
  return Object.freeze(plans.map(plan => Object.freeze({
    id: plan.id,
    changes: plan.changes,
    expectedPropertiesRevision: plan.expectedPropertiesRevision,
  })));
}

export function summarizeApplyResults(
  results: readonly PluginPropertyMutationResult[],
): ApplySummary {
  const counts: Record<PluginPropertyMutationResult["status"], number> = {
    updated: 0,
    unchanged: 0,
    stale: 0,
    denied: 0,
    failed: 0,
  };
  for (const result of results) counts[result.status] += 1;
  return Object.freeze({
    results: Object.freeze([...results]),
    counts: Object.freeze(counts),
  });
}

function previewValue(value: JsonValue | null): string {
  if (value === null) return "(remove)";
  const text = typeof value === "string" ? value : JSON.stringify(value);
  const safe = text.replace(CONTROL_OR_UNSAFE, " ").slice(0, 80);
  return JSON.stringify(safe);
}

export function buildBoundedPreview(
  plans: readonly JiraProjectionPlan[],
  duplicateKeys: readonly string[],
  invalidCount: number,
  missingKeys: readonly string[],
  providerFailureCount: number,
): { readonly message: string; readonly items: readonly string[] } {
  const items = plans.slice(0, PREVIEW_ITEM_LIMIT).map(plan => {
    const changes = Object.entries(plan.changes)
      .sort(([left], [right]) => compareText(left, right))
      .map(([name, value]) => `${name}=${previewValue(value)}`)
      .join(", ");
    return `${plan.key} · ${plan.title.slice(0, 100)} · ${changes}`.slice(0, 360);
  });
  const lines = [
    `${plans.length} note update${plans.length === 1 ? "" : "s"} proposed.`,
    `${duplicateKeys.length} duplicate Jira key mapping${duplicateKeys.length === 1 ? "" : "s"} retained.`,
    `${invalidCount} invalid or non-mutable note mapping${invalidCount === 1 ? "" : "s"}.`,
    `${missingKeys.length} Jira issue${missingKeys.length === 1 ? "" : "s"} missing.`,
    `${providerFailureCount} provider batch${providerFailureCount === 1 ? "" : "es"} unavailable or denied.`,
    "",
    ...items.map(item => `• ${item}`),
  ];
  if (plans.length > items.length) {
    lines.push(`…and ${plans.length - items.length} more deterministic update(s).`);
  }
  return Object.freeze({
    message: lines.join("\n").slice(0, 8_000),
    items: Object.freeze(items),
  });
}

export function stableFingerprint(value: string): string {
  let left = 0x811c9dc5;
  let right = 0x9e3779b9;
  for (let index = 0; index < value.length; index += 1) {
    const code = value.charCodeAt(index);
    left ^= code;
    left = Math.imul(left, 0x01000193);
    right ^= code + index;
    right = Math.imul(right, 0x85ebca6b);
  }
  return `${(left >>> 0).toString(16).padStart(8, "0")}${(right >>> 0)
    .toString(16)
    .padStart(8, "0")}`;
}

function compareText(left: string, right: string): number {
  return left < right ? -1 : left > right ? 1 : 0;
}

function compareNote(left: JiraNoteMapping, right: JiraNoteMapping): number {
  return compareText(left.key, right.key)
    || compareText(left.path, right.path)
    || compareText(left.id, right.id);
}

function compareInvalidNote(left: InvalidJiraNote, right: InvalidJiraNote): number {
  return compareText(left.path, right.path)
    || compareText(left.title, right.title)
    || compareText(left.reason, right.reason);
}

function plainRecord(value: unknown): value is Record<string, JsonValue> {
  // Checkpoint values cross the public JSON boundary, which already excludes
  // prototypes and non-serializable objects. Avoid prototype reflection so
  // this runtime-neutral core is also valid inside the QuickJS guest.
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
