import type {
  JsonValue,
  PluginNotePropertiesDto,
  PluginPropertyBatchOperation,
  PluginPropertyMutationResult,
  PluginPropertyScalar,
  PropertiesRevision,
} from "@thortiq/plugin-api";
import type { CsvEncoding } from "./csv.js";
import {
  isCsvHeaderIdentifier,
  isMutablePropertyIdentifier,
  isReadablePropertyIdentifier,
  sameIdentifier,
} from "./schema.js";

export const PROPERTY_LOOKUP_CHUNK_SIZE = 500;
export const PROPERTY_LOOKUP_PAGE_SIZE = 200;
export const PROPERTY_UPDATE_BATCH_SIZE = 100;
export const MAX_IMPORT_PLANS = 1_000;
export const MAX_IMPORT_DIAGNOSTICS = 100;
export const MAX_PREVIEW_ROWS = 100;
export const MAX_IDENTITY_CODE_UNITS = 128;
export const MAX_PROPERTY_BATCH_SERIALIZED_BYTES = 900_000;

const DELIMITERS = Object.freeze({
  comma: ",",
  semicolon: ";",
  tab: "\t",
  pipe: "|",
});

export type CsvDelimiterSetting = keyof typeof DELIMITERS;
export type CsvHeaderMode = "present" | "absent";
export type CsvIdentityType = "string" | "number" | "boolean";

export interface CsvFieldMapping {
  readonly field: string;
  readonly property: string;
}

export interface CsvImportConfiguration {
  readonly csvPath: string;
  readonly encoding: CsvEncoding;
  readonly delimiter: string;
  readonly headerMode: CsvHeaderMode;
  readonly noteIdentityProperty: string;
  readonly csvIdentityField: string;
  readonly identityType: CsvIdentityType;
  readonly mappings: readonly CsvFieldMapping[];
  readonly watchForChanges: boolean;
  readonly autoApply: boolean;
}

export interface CsvImportConfigurationInput {
  readonly csvPath: unknown;
  readonly encoding: unknown;
  readonly delimiter: unknown;
  readonly headerMode: unknown;
  readonly noteIdentityProperty: unknown;
  readonly csvIdentityField: unknown;
  readonly identityType: unknown;
  readonly mappings: unknown;
  readonly watchForChanges: unknown;
  readonly autoApply: unknown;
}

export interface CsvImportDiagnostic {
  readonly kind:
    | "blank-record"
    | "malformed-record"
    | "missing-identity"
    | "invalid-identity"
    | "duplicate-csv-identity"
    | "unmatched-identity"
    | "duplicate-note-match";
  readonly row: number | null;
  readonly message: string;
}

export interface CsvImportRecord {
  readonly row: number;
  readonly identity: PluginPropertyScalar;
  readonly identityKey: string;
  readonly fields: Readonly<Record<string, string>>;
}

export interface PreparedCsvDataset {
  readonly headers: readonly string[];
  readonly records: readonly CsvImportRecord[];
  readonly lookupValues: readonly PluginPropertyScalar[];
  readonly diagnostics: readonly CsvImportDiagnostic[];
  readonly diagnosticCount: number;
  readonly counts: {
    readonly sourceRecords: number;
    readonly actionableRecords: number;
    readonly blankRecords: number;
    readonly malformedRecords: number;
    readonly missingIdentities: number;
    readonly invalidIdentities: number;
    readonly duplicateCsvIdentities: number;
  };
}

export interface CsvPropertyChange {
  readonly property: string;
  readonly current: JsonValue | null;
  readonly desired: string;
}

export interface CsvImportPlan {
  readonly id: string;
  readonly title: string;
  readonly path: string;
  readonly row: number;
  readonly identity: PluginPropertyScalar;
  readonly identityKey: string;
  readonly changes: Readonly<Record<string, string>>;
  readonly displayChanges: readonly CsvPropertyChange[];
  readonly expectedPropertiesRevision: PropertiesRevision;
  /** Display-only metadata. Never use it as a write precondition. */
  readonly updatedAt: string;
}

export interface CsvImportPlanningResult {
  readonly plans: readonly CsvImportPlan[];
  readonly diagnostics: readonly CsvImportDiagnostic[];
  readonly diagnosticCount: number;
  readonly unmatchedValues: readonly PluginPropertyScalar[];
  readonly duplicateNoteValues: readonly PluginPropertyScalar[];
}

export interface CsvImportPreview {
  readonly message: string;
  readonly markdown: string;
  readonly rows: readonly CsvImportPlan[];
  readonly omitted: number;
}

export interface CsvApplyResult {
  readonly id: string;
  readonly row: number;
  readonly status: PluginPropertyMutationResult["status"];
  readonly retryable: boolean;
}

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

export function normalizeCsvConfiguration(
  input: CsvImportConfigurationInput,
): {
  readonly configuration: CsvImportConfiguration | null;
  readonly errors: readonly string[];
} {
  const errors: string[] = [];
  const csvPath = typeof input.csvPath === "string" ? input.csvPath : "";
  if (csvPath.length === 0 || !csvPath.toLocaleLowerCase("en-US").endsWith(".csv")) {
    errors.push("csvPath");
  }
  const encoding = (
    input.encoding === "auto"
    || input.encoding === "utf-8"
    || input.encoding === "utf-16le"
    || input.encoding === "utf-16be"
  ) ? input.encoding : null;
  if (!encoding) errors.push("encoding");
  const delimiter = (
    typeof input.delimiter === "string"
    && Object.prototype.hasOwnProperty.call(DELIMITERS, input.delimiter)
  )
    ? DELIMITERS[input.delimiter as CsvDelimiterSetting]
    : null;
  if (!delimiter) errors.push("delimiter");
  const headerMode = input.headerMode === "present" || input.headerMode === "absent"
    ? input.headerMode
    : null;
  if (!headerMode) errors.push("headerMode");
  const identityType = (
    input.identityType === "string"
    || input.identityType === "number"
    || input.identityType === "boolean"
  ) ? input.identityType : null;
  if (!identityType) errors.push("identityType");

  const noteIdentityProperty = normalizeIdentifier(input.noteIdentityProperty);
  if (!noteIdentityProperty
    || !isReadablePropertyIdentifier(noteIdentityProperty)) {
    errors.push("noteIdentityProperty");
  }
  const csvIdentityField = normalizeIdentifier(input.csvIdentityField);
  if (!csvIdentityField || !isCsvHeaderIdentifier(csvIdentityField)) {
    errors.push("csvIdentityField");
  }
  const parsedMappings = parseFieldMappings(input.mappings);
  errors.push(...parsedMappings.errors);
  if (noteIdentityProperty) {
    for (const mapping of parsedMappings.mappings) {
      if (sameIdentifier(mapping.property, noteIdentityProperty)) {
        errors.push("identity-output-property");
        break;
      }
    }
  }

  if (errors.length > 0
    || !encoding
    || !delimiter
    || !headerMode
    || !identityType
    || !noteIdentityProperty
    || !csvIdentityField) {
    return Object.freeze({
      configuration: null,
      errors: Object.freeze([...new Set(errors)]),
    });
  }
  return Object.freeze({
    configuration: Object.freeze({
      csvPath,
      encoding,
      delimiter,
      headerMode,
      noteIdentityProperty,
      csvIdentityField,
      identityType,
      mappings: parsedMappings.mappings,
      watchForChanges: input.watchForChanges === true,
      autoApply: input.autoApply === true,
    }),
    errors: Object.freeze([]),
  });
}

export function parseFieldMappings(input: unknown): {
  readonly mappings: readonly CsvFieldMapping[];
  readonly errors: readonly string[];
} {
  if (typeof input !== "string") {
    return Object.freeze({
      mappings: Object.freeze([]),
      errors: Object.freeze(["mappings"]),
    });
  }
  const mappings: CsvFieldMapping[] = [];
  const errors: string[] = [];
  for (const [index, rawLine] of input.split(/\r?\n/u).entries()) {
    const line = rawLine.trim();
    if (line.length === 0) continue;
    const equals = line.indexOf("=");
    if (equals <= 0 || equals !== line.lastIndexOf("=")) {
      errors.push(`mapping-line-${index + 1}`);
      continue;
    }
    const field = normalizeIdentifier(line.slice(0, equals));
    const property = normalizeIdentifier(line.slice(equals + 1));
    if (!field || !isCsvHeaderIdentifier(field)
      || !property || !isMutablePropertyIdentifier(property)) {
      errors.push(`mapping-line-${index + 1}`);
      continue;
    }
    mappings.push(Object.freeze({ field, property }));
  }
  if (mappings.length === 0) errors.push("mappings-empty");
  const fieldKeys = mappings.map(mapping => folded(mapping.field));
  const propertyKeys = mappings.map(mapping => folded(mapping.property));
  if (new Set(fieldKeys).size !== fieldKeys.length) {
    errors.push("duplicate-mapping-field");
  }
  if (new Set(propertyKeys).size !== propertyKeys.length) {
    errors.push("duplicate-mapping-property");
  }
  return Object.freeze({
    mappings: Object.freeze(mappings),
    errors: Object.freeze([...new Set(errors)]),
  });
}

function normalizeIdentifier(value: unknown): string | null {
  if (typeof value !== "string") return null;
  const normalized = value.trim();
  return normalized.length > 0 ? normalized : null;
}

export function prepareCsvDataset(
  rows: readonly (readonly string[])[],
  configuration: Pick<
    CsvImportConfiguration,
    "headerMode" | "csvIdentityField" | "identityType" | "mappings"
  >,
): PreparedCsvDataset {
  const diagnostic = diagnosticCollector();
  if (rows.length === 0) throw workflowError("empty-csv");

  let headers: readonly string[];
  let dataRows: readonly (readonly string[])[];
  let firstDataRow: number;
  if (configuration.headerMode === "present") {
    headers = Object.freeze([...rows[0]]);
    dataRows = rows.slice(1);
    firstDataRow = 2;
  } else {
    const columnCount = rows[0].length;
    headers = Object.freeze(
      Array.from({ length: columnCount }, (_, index) => `column${index + 1}`),
    );
    dataRows = rows;
    firstDataRow = 1;
  }
  validateHeaders(headers, configuration);

  const validRecords: CsvImportRecord[] = [];
  let blankRecords = 0;
  let malformedRecords = 0;
  let missingIdentities = 0;
  let invalidIdentities = 0;
  for (const [index, values] of dataRows.entries()) {
    const rowNumber = firstDataRow + index;
    if (values.every(value => value.length === 0)) {
      blankRecords += 1;
      diagnostic.add({
        kind: "blank-record",
        row: rowNumber,
        message: `Row ${rowNumber} is blank and was skipped.`,
      });
      continue;
    }
    if (values.length !== headers.length) {
      malformedRecords += 1;
      diagnostic.add({
        kind: "malformed-record",
        row: rowNumber,
        message:
          `Row ${rowNumber} has ${values.length} fields; ${headers.length} were required.`,
      });
      continue;
    }
    const fields: Record<string, string> = {};
    headers.forEach((header, column) => {
      fields[header] = values[column];
    });
    const rawIdentity = fields[configuration.csvIdentityField];
    if (rawIdentity.trim().length === 0) {
      missingIdentities += 1;
      diagnostic.add({
        kind: "missing-identity",
        row: rowNumber,
        message: `Row ${rowNumber} has no CSV identity value.`,
      });
      continue;
    }
    const identity = parseIdentityScalar(rawIdentity, configuration.identityType);
    if (identity === null) {
      invalidIdentities += 1;
      diagnostic.add({
        kind: "invalid-identity",
        row: rowNumber,
        message: `Row ${rowNumber} has an invalid ${configuration.identityType} identity.`,
      });
      continue;
    }
    validRecords.push(Object.freeze({
      row: rowNumber,
      identity,
      identityKey: scalarKey(identity),
      fields: Object.freeze(fields),
    }));
  }

  const recordsByIdentity = new Map<string, CsvImportRecord[]>();
  for (const record of validRecords) {
    const matches = recordsByIdentity.get(record.identityKey) ?? [];
    matches.push(record);
    recordsByIdentity.set(record.identityKey, matches);
  }
  let duplicateCsvIdentities = 0;
  const actionableRecords: CsvImportRecord[] = [];
  for (const records of recordsByIdentity.values()) {
    if (records.length === 1) {
      actionableRecords.push(records[0]);
      continue;
    }
    duplicateCsvIdentities += records.length;
    for (const record of records) {
      diagnostic.add({
        kind: "duplicate-csv-identity",
        row: record.row,
        message:
          `Row ${record.row} repeats CSV identity ${displayScalar(record.identity)} and was skipped.`,
      });
    }
  }
  actionableRecords.sort(compareRecord);
  const lookupValues = actionableRecords.map(record => record.identity);
  return Object.freeze({
    headers,
    records: Object.freeze(actionableRecords),
    lookupValues: Object.freeze(lookupValues),
    diagnostics: diagnostic.items(),
    diagnosticCount: diagnostic.count(),
    counts: Object.freeze({
      sourceRecords: dataRows.length,
      actionableRecords: actionableRecords.length,
      blankRecords,
      malformedRecords,
      missingIdentities,
      invalidIdentities,
      duplicateCsvIdentities,
    }),
  });
}

function validateHeaders(
  headers: readonly string[],
  configuration: Pick<CsvImportConfiguration, "csvIdentityField" | "mappings">,
): void {
  if (headers.length === 0) throw workflowError("missing-headers");
  if (headers.some(header => !isCsvHeaderIdentifier(header))) {
    throw workflowError("invalid-header");
  }
  const foldedHeaders = headers.map(folded);
  if (new Set(foldedHeaders).size !== foldedHeaders.length) {
    throw workflowError("duplicate-header");
  }
  const required = [
    configuration.csvIdentityField,
    ...configuration.mappings.map(mapping => mapping.field),
  ];
  for (const field of required) {
    if (!headers.includes(field)) {
      throw workflowError(`missing-header:${field}`);
    }
  }
}

export function parseIdentityScalar(
  input: string,
  type: CsvIdentityType,
): PluginPropertyScalar | null {
  const value = input.trim();
  if (value.length > MAX_IDENTITY_CODE_UNITS) return null;
  if (type === "string") return value.normalize("NFC");
  if (type === "boolean") {
    const lower = value.toLocaleLowerCase("en-US");
    if (lower === "true") return true;
    if (lower === "false") return false;
    return null;
  }
  if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/iu.test(value)) {
    return null;
  }
  const number = Number(value);
  if (!Number.isFinite(number)) return null;
  return Object.is(number, -0) ? 0 : number;
}

export function scalarKey(value: PluginPropertyScalar): string {
  if (typeof value === "string") {
    return `s:${JSON.stringify(value.normalize("NFC"))}`;
  }
  if (typeof value === "number") {
    return `n:${Object.is(value, -0) ? "0" : String(value)}`;
  }
  return value ? "b:1" : "b:0";
}

export function planCsvImport(
  dataset: PreparedCsvDataset,
  matchesByValue: ReadonlyMap<string, readonly PluginNotePropertiesDto[]>,
  mappings: readonly CsvFieldMapping[],
): CsvImportPlanningResult {
  const diagnostic = diagnosticCollector(
    dataset.diagnostics,
    dataset.diagnosticCount,
  );
  const plans: CsvImportPlan[] = [];
  const unmatchedValues: PluginPropertyScalar[] = [];
  const duplicateNoteValues: PluginPropertyScalar[] = [];

  for (const record of dataset.records) {
    const matches = [...(matchesByValue.get(record.identityKey) ?? [])]
      .sort(compareNote);
    if (matches.length === 0) {
      unmatchedValues.push(record.identity);
      diagnostic.add({
        kind: "unmatched-identity",
        row: record.row,
        message:
          `Row ${record.row} identity ${displayScalar(record.identity)} matches no note.`,
      });
      continue;
    }
    if (matches.length > 1) {
      duplicateNoteValues.push(record.identity);
      diagnostic.add({
        kind: "duplicate-note-match",
        row: record.row,
        message:
          `Row ${record.row} identity ${displayScalar(record.identity)} matches ${matches.length} notes; all were retained.`,
      });
    }
    for (const note of matches) {
      const changes: Record<string, string> = {};
      const displayChanges: CsvPropertyChange[] = [];
      for (const mapping of mappings) {
        const desired = record.fields[mapping.field];
        const hasCurrent = Object.prototype.hasOwnProperty.call(
          note.values,
          mapping.property,
        );
        const current = hasCurrent ? note.values[mapping.property] : null;
        if (hasCurrent && current === desired) continue;
        changes[mapping.property] = desired;
        displayChanges.push(Object.freeze({
          property: mapping.property,
          current,
          desired,
        }));
      }
      if (displayChanges.length === 0) continue;
      if (plans.length >= MAX_IMPORT_PLANS) {
        throw workflowError("preview-limit");
      }
      plans.push(Object.freeze({
        id: note.id,
        title: note.title.slice(0, 256),
        path: note.path,
        row: record.row,
        identity: record.identity,
        identityKey: record.identityKey,
        changes: Object.freeze(changes),
        displayChanges: Object.freeze(displayChanges),
        expectedPropertiesRevision: note.propertiesRevision,
        updatedAt: note.updatedAt,
      }));
    }
  }
  plans.sort(comparePlan);
  return Object.freeze({
    plans: Object.freeze(plans),
    diagnostics: diagnostic.items(),
    diagnosticCount: diagnostic.count(),
    unmatchedValues: Object.freeze(unmatchedValues.sort(compareScalar)),
    duplicateNoteValues: Object.freeze(duplicateNoteValues.sort(compareScalar)),
  });
}

export function buildCsvPreview(
  plans: readonly CsvImportPlan[],
  diagnosticCount: number,
): CsvImportPreview {
  const rows = Object.freeze(plans.slice(0, MAX_PREVIEW_ROWS));
  const omitted = Math.max(0, plans.length - rows.length);
  const messageLines = [
    `${plans.length} note update${plans.length === 1 ? "" : "s"} proposed.`,
    `${diagnosticCount} data diagnostic${diagnosticCount === 1 ? "" : "s"} retained.`,
    "",
    ...rows.slice(0, 25).map(plan => previewLine(plan)),
  ];
  if (plans.length > 25) {
    messageLines.push(`…and ${plans.length - 25} more deterministic update(s).`);
  }
  const tableLines = [
    "| CSV row | Identity | Note | Changed properties | Updated (display only) |",
    "| ---: | --- | --- | --- | --- |",
    ...rows.map(plan => [
      plan.row,
      markdownCell(displayScalar(plan.identity)),
      markdownCell(plan.title),
      markdownCell(plan.displayChanges.map(change => (
        `${change.property}: ${displayValue(change.current)} → ${displayValue(change.desired)}`
      )).join("; ")),
      markdownCell(plan.updatedAt),
    ].join(" | ").replace(/^/u, "| ").replace(/$/u, " |")),
  ];
  if (omitted > 0) {
    tableLines.push("", `_${omitted} additional update(s) omitted from this bounded preview._`);
  }
  return Object.freeze({
    message: messageLines.join("\n").slice(0, 8_000),
    markdown: tableLines.join("\n").slice(0, 100_000),
    rows,
    omitted,
  });
}

function previewLine(plan: CsvImportPlan): string {
  const changes = plan.displayChanges
    .map(change => `${change.property}=${displayValue(change.desired)}`)
    .join(", ");
  return `• Row ${plan.row} · ${displayScalar(plan.identity)} · ${plan.title} · ${changes}`
    .slice(0, 360);
}

function markdownCell(value: string): string {
  return value
    .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
    .replace(/\|/gu, "\\|")
    .slice(0, 360);
}

function displayValue(value: JsonValue | null): string {
  if (value === null) return "(missing)";
  const text = typeof value === "string" ? value : JSON.stringify(value);
  return JSON.stringify(text.slice(0, 80));
}

export function displayScalar(value: PluginPropertyScalar): string {
  return typeof value === "string" ? JSON.stringify(value) : String(value);
}

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 a positive integer.");
  }
  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 CsvImportPlan[],
): readonly PluginPropertyBatchOperation[] {
  return Object.freeze(plans.map(plan => Object.freeze({
    id: plan.id,
    changes: plan.changes,
    expectedPropertiesRevision: plan.expectedPropertiesRevision,
  })));
}

export function buildPropertyOperationBatches(
  operations: readonly PluginPropertyBatchOperation[],
): readonly (readonly PluginPropertyBatchOperation[])[] {
  const encoder = new TextEncoder();
  const batches: PluginPropertyBatchOperation[][] = [];
  let batch: PluginPropertyBatchOperation[] = [];
  for (const operation of operations) {
    const singleBytes = encoder.encode(JSON.stringify([operation])).byteLength;
    if (singleBytes > MAX_PROPERTY_BATCH_SERIALIZED_BYTES) {
      throw workflowError("property-operation-size-limit");
    }
    const candidate = [...batch, operation];
    const candidateBytes = encoder.encode(JSON.stringify(candidate)).byteLength;
    if (batch.length >= PROPERTY_UPDATE_BATCH_SIZE
      || candidateBytes > MAX_PROPERTY_BATCH_SERIALIZED_BYTES) {
      batches.push(batch);
      batch = [operation];
    } else {
      batch = candidate;
    }
  }
  if (batch.length > 0) batches.push(batch);
  return Object.freeze(batches.map(value => Object.freeze(value)));
}

export function summarizeApplyResults(
  plans: readonly CsvImportPlan[],
  results: readonly PluginPropertyMutationResult[],
): CsvApplySummary {
  if (plans.length !== results.length) throw workflowError("invalid-batch-result");
  const counts: Record<PluginPropertyMutationResult["status"], number> = {
    updated: 0,
    unchanged: 0,
    stale: 0,
    denied: 0,
    failed: 0,
  };
  const reported = results.map((result, index) => {
    const plan = plans[index];
    if (result.id !== plan.id) throw workflowError("invalid-batch-result");
    counts[result.status] += 1;
    return Object.freeze({
      id: plan.id,
      row: plan.row,
      status: result.status,
      retryable: result.status === "stale"
        || (result.status === "failed" && result.error?.retryable === true),
    });
  });
  return Object.freeze({
    results: Object.freeze(reported),
    retry: Object.freeze(
      reported.filter(result => (
        result.status === "stale"
        || result.status === "denied"
        || result.status === "failed"
      )),
    ),
    counts: Object.freeze(counts),
  });
}

function diagnosticCollector(
  initial: readonly CsvImportDiagnostic[] = [],
  initialCount = initial.length,
): {
  add(value: CsvImportDiagnostic): void;
  items(): readonly CsvImportDiagnostic[];
  count(): number;
} {
  const diagnostics = [...initial].slice(0, MAX_IMPORT_DIAGNOSTICS);
  let count = initialCount;
  return {
    add(value) {
      count += 1;
      if (diagnostics.length < MAX_IMPORT_DIAGNOSTICS) {
        diagnostics.push(Object.freeze(value));
      }
    },
    items() {
      return Object.freeze([...diagnostics]);
    },
    count() {
      return count;
    },
  };
}

function compareRecord(left: CsvImportRecord, right: CsvImportRecord): number {
  return compareText(left.identityKey, right.identityKey) || left.row - right.row;
}

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

function comparePlan(left: CsvImportPlan, right: CsvImportPlan): number {
  return compareText(left.identityKey, right.identityKey)
    || left.row - right.row
    || compareText(left.path, right.path)
    || compareText(left.id, right.id);
}

function compareScalar(
  left: PluginPropertyScalar,
  right: PluginPropertyScalar,
): number {
  return compareText(scalarKey(left), scalarKey(right));
}

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

function folded(value: string): string {
  return value.toLocaleLowerCase("en-US");
}

export function workflowError(code: string): Error {
  return Object.assign(new Error(`CSV import failed: ${code}.`), { code });
}
