import type {
  JsonValue,
  PluginAssetsApi,
  PluginJobRunContext,
  PluginMetadataApi,
  PluginNotePropertiesDto,
  PluginNotesApi,
  PluginPropertyMutationResult,
  PluginPropertyScalar,
  PluginSettingsApi,
  PluginStorageNamespaceApi,
} from "@thortiq/plugin-api";
import {
  MAX_RAW_CSV_BYTES,
  CsvDecodeError,
  CsvParseError,
  decodeCsvBytes,
  parseCsv,
} from "./csv.js";
import {
  MAX_IMPORT_PLANS,
  PROPERTY_LOOKUP_CHUNK_SIZE,
  PROPERTY_LOOKUP_PAGE_SIZE,
  buildPropertyOperationBatches,
  buildCsvPreview,
  chunkBounded,
  normalizeCsvConfiguration,
  planCsvImport,
  prepareCsvDataset,
  scalarKey,
  summarizeApplyResults,
  toPropertyBatchOperations,
  workflowError,
  type CsvApplySummary,
  type CsvImportConfiguration,
  type CsvImportDiagnostic,
  type CsvImportPlan,
} from "./workflow-core.js";

export const CSV_STATUS_STORAGE_KEY = "csv-import-status";
export const CSV_AUTO_APPLY_CONSENT_KEY = "csv-auto-apply-consent";
export const MAX_INDEXED_CSV_ASSETS = 10_000;
export const MAX_LOOKUP_MATCHES = MAX_IMPORT_PLANS * 2;

export type CsvRunReason = PluginJobRunContext["reason"];

export interface CsvPreviewStatusRow {
  readonly row: number;
  readonly identity: PluginPropertyScalar;
  readonly title: string;
  readonly changes: string;
  readonly updatedAt: string;
}

export interface CsvWorkflowStatus {
  readonly version: 1;
  readonly outcome:
    | "applied"
    | "preview"
    | "cancelled"
    | "configuration-error"
    | "invalid-csv"
    | "partial"
    | "failed";
  readonly reason: CsvRunReason;
  readonly finishedAt: string;
  readonly guidance: string | null;
  readonly assetPath: string | null;
  readonly counts: {
    readonly sourceRecords: number;
    readonly actionableRecords: number;
    readonly blankRecords: number;
    readonly malformedRecords: number;
    readonly missingIdentities: number;
    readonly invalidIdentities: number;
    readonly duplicateCsvIdentities: number;
    readonly unmatchedIdentities: number;
    readonly duplicateNoteIdentities: number;
    readonly proposed: number;
    readonly updated: number;
    readonly unchanged: number;
    readonly stale: number;
    readonly denied: number;
    readonly failed: number;
  };
  readonly previewMarkdown: string;
  readonly previewSourceNoteId: string | null;
  readonly previewRows: readonly CsvPreviewStatusRow[];
  readonly diagnostics: readonly CsvImportDiagnostic[];
  readonly diagnosticCount: number;
  readonly results: CsvApplySummary["results"];
  readonly retry: readonly number[];
}

export interface CsvWorkflowPorts {
  readonly assets: Pick<PluginAssetsApi, "list" | "readBytes">;
  readonly metadata: Pick<PluginMetadataApi, "findByPropertyValues">;
  readonly notes: Pick<PluginNotesApi, "updatePropertiesBatch">;
  readonly settings: Pick<PluginSettingsApi, "get">;
  readonly statusStorage: Pick<PluginStorageNamespaceApi, "set">;
  readonly now: () => string;
  readonly confirmPreview?: (preview: {
    readonly message: string;
    readonly markdown: string;
    readonly proposed: number;
  }) => Promise<boolean>;
}

export interface CsvWorkflowOptions {
  readonly reason: CsvRunReason;
  readonly signal: AbortSignal;
  readonly scheduledAutoApplyConfirmed: boolean;
}

export async function runCsvImportWorkflow(
  ports: CsvWorkflowPorts,
  options: CsvWorkflowOptions,
): Promise<CsvWorkflowStatus> {
  const empty = emptyCounts();
  try {
    throwIfAborted(options.signal);
    const normalized = await readConfiguration(ports.settings);
    if (!normalized.configuration) {
      return publishStatus(ports, makeStatus({
        outcome: "configuration-error",
        reason: options.reason,
        finishedAt: ports.now(),
        guidance:
          `Configure a CSV asset and valid identity/mapping fields (${normalized.errors.join(", ")}).`,
        counts: empty,
      }));
    }
    const configuration = normalized.configuration;
    if (options.reason === "asset-change" && !configuration.watchForChanges) {
      return publishStatus(ports, makeStatus({
        outcome: "preview",
        reason: options.reason,
        finishedAt: ports.now(),
        guidance:
          "The exact-path change was ignored because automatic preview refresh is disabled.",
        assetPath: configuration.csvPath,
        counts: empty,
      }));
    }

    const asset = await findConfiguredCsvAsset(
      ports.assets,
      configuration.csvPath,
      options.signal,
    );
    if (!asset) throw workflowError("asset-not-indexed");
    if (asset.sizeBytes > MAX_RAW_CSV_BYTES) {
      throw new CsvDecodeError(
        "raw-size-limit",
        "The indexed CSV exceeds the raw-byte limit.",
      );
    }
    const bytes = await ports.assets.readBytes(configuration.csvPath, {
      maxBytes: MAX_RAW_CSV_BYTES,
      signal: options.signal,
    });
    throwIfAborted(options.signal);
    const text = decodeCsvBytes(bytes, configuration.encoding);
    const parsed = parseCsv(text, { delimiter: configuration.delimiter });
    // Header and mapping validation completes here, before any metadata call.
    const dataset = prepareCsvDataset(parsed, configuration);
    const matches = await findMatchingNotes(
      ports.metadata,
      configuration.noteIdentityProperty,
      dataset.lookupValues,
      options.signal,
    );
    const planning = planCsvImport(dataset, matches, configuration.mappings);
    // Validate both per-operation and batch envelopes before showing a
    // preview which could be accepted.
    buildPropertyOperationBatches(toPropertyBatchOperations(planning.plans));
    const preview = buildCsvPreview(
      planning.plans,
      planning.diagnosticCount,
    );
    const plannedCounts = {
      ...empty,
      ...dataset.counts,
      unmatchedIdentities: planning.unmatchedValues.length,
      duplicateNoteIdentities: planning.duplicateNoteValues.length,
      proposed: planning.plans.length,
    };
    const common = {
      reason: options.reason,
      finishedAt: ports.now(),
      assetPath: configuration.csvPath,
      counts: plannedCounts,
      previewMarkdown: preview.markdown,
      previewSourceNoteId: planning.plans[0]?.id ?? null,
      previewRows: preview.rows.map(plan => Object.freeze({
        row: plan.row,
        identity: plan.identity,
        title: plan.title.slice(0, 100),
        changes: plan.displayChanges
          .map(change => `${change.property}: ${String(change.current)} → ${change.desired}`)
          .join("; ")
          .slice(0, 200),
        updatedAt: plan.updatedAt.slice(0, 64),
      })),
      diagnostics: planning.diagnostics,
      diagnosticCount: planning.diagnosticCount,
    };
    if (planning.plans.length === 0) {
      return publishStatus(ports, makeStatus({
        ...common,
        outcome: "preview",
        guidance: planning.diagnosticCount > 0
          ? "No property changes are ready. Resolve the retained CSV diagnostics and preview again."
          : "Every matched note already has the mapped CSV values.",
      }));
    }

    let apply = false;
    if (options.reason === "manual") {
      apply = await ports.confirmPreview?.({
        message: preview.message,
        markdown: preview.markdown,
        proposed: planning.plans.length,
      }) ?? false;
      throwIfAborted(options.signal);
      if (!apply) {
        return publishStatus(ports, makeStatus({
          ...common,
          outcome: "cancelled",
          guidance: "The preview was retained and no note properties were written.",
        }));
      }
    } else {
      apply = configuration.autoApply && options.scheduledAutoApplyConfirmed;
    }
    if (!apply) {
      return publishStatus(ports, makeStatus({
        ...common,
        outcome: "preview",
        guidance: configuration.autoApply
          ? "Watched writes remain disabled until the separate device-local warning is accepted."
          : "The exact-path watch refreshed this preview only. Enable and separately confirm auto-apply to write on the designated runner.",
      }));
    }

    const applied = await applyCsvImportPlans(
      ports.notes,
      planning.plans,
      options.signal,
    );
    const counts = {
      ...plannedCounts,
      ...applied.counts,
    };
    const partial = applied.retry.length > 0;
    return publishStatus(ports, makeStatus({
      ...common,
      outcome: partial ? "partial" : "applied",
      counts,
      results: applied.results,
      retry: applied.retry,
      guidance: partial
        ? "Stale, denied, and failed rows were retained. Preview again to retry them against fresh note revisions."
        : null,
    }));
  } catch (error) {
    const cancelled = options.signal.aborted || publicErrorCode(error) === "cancelled";
    const invalidCsv = error instanceof CsvDecodeError
      || error instanceof CsvParseError
      || isCsvSchemaError(error);
    return publishStatus(ports, makeStatus({
      outcome: cancelled
        ? "cancelled"
        : invalidCsv
          ? "invalid-csv"
          : "failed",
      reason: options.reason,
      finishedAt: ports.now(),
      guidance: cancelled ? null : safeGuidance(error),
      counts: empty,
    }));
  }
}

export async function findConfiguredCsvAsset(
  assets: Pick<PluginAssetsApi, "list">,
  path: string,
  signal: AbortSignal,
): Promise<Awaited<ReturnType<PluginAssetsApi["list"]>>["assets"][number] | null> {
  let cursor: string | null = null;
  let visited = 0;
  const cursors = new Set<string>();
  do {
    throwIfAborted(signal);
    const page = await assets.list({
      extensions: ["csv"],
      limit: 200,
      cursor,
      signal,
    });
    visited += page.assets.length;
    if (visited > MAX_INDEXED_CSV_ASSETS) throw workflowError("asset-list-limit");
    const match = page.assets.find(asset => asset.path === path);
    if (match) return match;
    cursor = page.cursor;
    if (cursor) {
      if (cursors.has(cursor)) throw workflowError("asset-cursor-loop");
      cursors.add(cursor);
    }
  } while (cursor);
  return null;
}

export async function findMatchingNotes(
  metadata: Pick<PluginMetadataApi, "findByPropertyValues">,
  property: string,
  values: readonly PluginPropertyScalar[],
  signal: AbortSignal,
): Promise<ReadonlyMap<string, readonly PluginNotePropertiesDto[]>> {
  const result = new Map<string, PluginNotePropertiesDto[]>();
  let matchCount = 0;
  for (const chunk of chunkBounded(values, PROPERTY_LOOKUP_CHUNK_SIZE)) {
    let cursor: string | null = null;
    const cursors = new Set<string>();
    do {
      throwIfAborted(signal);
      const page = await metadata.findByPropertyValues({
        property,
        values: chunk,
        limit: PROPERTY_LOOKUP_PAGE_SIZE,
        cursor,
      });
      throwIfAborted(signal);
      for (const row of page.rows) {
        const key = scalarKey(row.value);
        const matches = result.get(key) ?? [];
        matches.push(...row.matches);
        matchCount += row.matches.length;
        if (matchCount > MAX_LOOKUP_MATCHES) {
          throw workflowError("lookup-match-limit");
        }
        result.set(key, matches);
      }
      cursor = page.cursor;
      if (cursor) {
        if (cursors.has(cursor)) throw workflowError("lookup-cursor-loop");
        cursors.add(cursor);
      }
    } while (cursor);
  }
  return new Map(
    [...result.entries()].map(([key, notes]) => [
      key,
      Object.freeze([...notes]),
    ]),
  );
}

export async function applyCsvImportPlans(
  notes: Pick<PluginNotesApi, "updatePropertiesBatch">,
  plans: readonly CsvImportPlan[],
  signal: AbortSignal,
): Promise<CsvApplySummary> {
  const operations = toPropertyBatchOperations(plans);
  const results: PluginPropertyMutationResult[] = [];
  for (const batch of buildPropertyOperationBatches(operations)) {
    throwIfAborted(signal);
    const response = await notes.updatePropertiesBatch(batch, {
      operation: "apply-csv-import",
    });
    if (response.length !== batch.length) {
      throw workflowError("invalid-batch-result");
    }
    results.push(...response);
  }
  return summarizeApplyResults(plans, results);
}

async function readConfiguration(
  settings: Pick<PluginSettingsApi, "get">,
): Promise<ReturnType<typeof normalizeCsvConfiguration>> {
  const [
    csvPath,
    encoding,
    delimiter,
    headerMode,
    noteIdentityProperty,
    csvIdentityField,
    identityType,
    mappings,
    watchForChanges,
    autoApply,
  ] = await Promise.all([
    settings.get("csvPath"),
    settings.get("encoding"),
    settings.get("delimiter"),
    settings.get("headerMode"),
    settings.get("noteIdentityProperty"),
    settings.get("csvIdentityField"),
    settings.get("identityType"),
    settings.get("mappings"),
    settings.get("watchForChanges"),
    settings.get("autoApply"),
  ]);
  return normalizeCsvConfiguration({
    csvPath,
    encoding: encoding ?? "auto",
    delimiter: delimiter ?? "comma",
    headerMode: headerMode ?? "present",
    noteIdentityProperty: noteIdentityProperty ?? "ExternalId",
    csvIdentityField: csvIdentityField ?? "id",
    identityType: identityType ?? "string",
    mappings: mappings ?? "name=ImportedName",
    watchForChanges: watchForChanges ?? false,
    autoApply: autoApply ?? false,
  });
}

function emptyCounts(): CsvWorkflowStatus["counts"] {
  return {
    sourceRecords: 0,
    actionableRecords: 0,
    blankRecords: 0,
    malformedRecords: 0,
    missingIdentities: 0,
    invalidIdentities: 0,
    duplicateCsvIdentities: 0,
    unmatchedIdentities: 0,
    duplicateNoteIdentities: 0,
    proposed: 0,
    updated: 0,
    unchanged: 0,
    stale: 0,
    denied: 0,
    failed: 0,
  };
}

function makeStatus(input: {
  readonly outcome: CsvWorkflowStatus["outcome"];
  readonly reason: CsvWorkflowStatus["reason"];
  readonly finishedAt: string;
  readonly guidance?: string | null;
  readonly assetPath?: string | null;
  readonly counts: CsvWorkflowStatus["counts"];
  readonly previewMarkdown?: string;
  readonly previewSourceNoteId?: string | null;
  readonly previewRows?: readonly CsvPreviewStatusRow[];
  readonly diagnostics?: readonly CsvImportDiagnostic[];
  readonly diagnosticCount?: number;
  readonly results?: CsvApplySummary["results"];
  readonly retry?: CsvApplySummary["retry"];
}): CsvWorkflowStatus {
  return Object.freeze({
    version: 1,
    outcome: input.outcome,
    reason: input.reason,
    finishedAt: input.finishedAt,
    guidance: input.guidance ?? null,
    assetPath: input.assetPath ?? null,
    counts: Object.freeze({ ...input.counts }),
    previewMarkdown: (input.previewMarkdown ?? "").slice(0, 50_000),
    previewSourceNoteId: input.previewSourceNoteId ?? null,
    previewRows: Object.freeze([...(input.previewRows ?? [])]),
    diagnostics: Object.freeze([...(input.diagnostics ?? [])]),
    diagnosticCount: input.diagnosticCount ?? 0,
    results: Object.freeze([...(input.results ?? [])]),
    retry: Object.freeze((input.retry ?? []).map(result => result.row)),
  });
}

async function publishStatus(
  ports: Pick<CsvWorkflowPorts, "statusStorage">,
  status: CsvWorkflowStatus,
): Promise<CsvWorkflowStatus> {
  await ports.statusStorage.set(
    CSV_STATUS_STORAGE_KEY,
    status as unknown as JsonValue,
  );
  return status;
}

function publicErrorCode(error: unknown): string | null {
  if (!error || typeof error !== "object") return null;
  const code = (error as { code?: unknown }).code;
  return typeof code === "string" ? code : null;
}

function isCsvSchemaError(error: unknown): boolean {
  const code = publicErrorCode(error);
  return Boolean(code && (
    code === "empty-csv"
    || code === "missing-headers"
    || code === "invalid-header"
    || code === "duplicate-header"
    || code.startsWith("missing-header:")
    || code === "preview-limit"
  ));
}

function safeGuidance(error: unknown): string {
  if (error instanceof CsvDecodeError || error instanceof CsvParseError) {
    return error.message.slice(0, 1_024);
  }
  switch (publicErrorCode(error)) {
    case "asset-not-indexed":
      return "Select an indexed .csv asset before importing.";
    case "asset-list-limit":
      return "The bounded indexed-asset scan ended before the configured CSV was found.";
    case "lookup-match-limit":
      return "The CSV identity lookup has too many note matches for one bounded preview.";
    case "preview-limit":
      return `The import would exceed the bounded ${MAX_IMPORT_PLANS}-note preview limit.`;
    case "property-operation-size-limit":
      return "One mapped note update is too large for a bounded conditional property batch.";
    case "empty-csv":
      return "The selected CSV has no records.";
    case "missing-headers":
      return "The selected CSV has no usable header fields.";
    case "invalid-header":
      return "Every CSV header must be a safe canonical identifier.";
    case "duplicate-header":
      return "CSV headers must be unique.";
    default: {
      const code = publicErrorCode(error);
      if (code?.startsWith("missing-header:")) {
        return `The CSV is missing required header ${code.slice("missing-header:".length)}.`;
      }
      return "The CSV import could not complete through the bounded public APIs.";
    }
  }
}

function throwIfAborted(signal: AbortSignal): void {
  if (signal.aborted) {
    throw Object.assign(new Error("CSV import was cancelled."), {
      code: "cancelled",
    });
  }
}
