import type {
  JsonValue,
  PluginJobRunContext,
  PluginNetworkApi,
  PluginNotesApi,
  PluginPropertyMutationResult,
  PluginSearchApi,
  PluginSecretsApi,
  PluginSettingsApi,
  PluginStorageNamespaceApi,
} from "@thortiq/plugin-api";
import {
  loadJiraIssues,
  type JiraClientPorts,
  type JiraProviderFailure,
} from "./jira-client.js";
import {
  SEARCH_PAGE_SIZE,
  SEARCH_QUERY,
  UPDATE_BATCH_SIZE,
  buildBoundedPreview,
  chunkBounded,
  collectJiraNotes,
  configuredPropertyNames,
  mergeCheckpointBatches,
  normalizePropertyMappings,
  parseCheckpoint,
  planJiraProjections,
  summarizeApplyResults,
  toPropertyBatchOperations,
  type ApplySummary,
  type JiraCheckpoint,
  type JiraProjectionPlan,
  type JiraPropertyMappings,
} from "./workflow-core.js";

export const STATUS_STORAGE_KEY = "jira-status";
export const AUTO_APPLY_CONSENT_KEY = "jira-auto-apply-consent";
export const MAX_SEARCH_ROWS = 10_000;

const TRANSPORT_GUIDANCE =
  "The PWA cannot reach arbitrary Atlassian tenant origins. Use Thortiq on a registered native-capable platform, then approve the exact Atlassian connection.";
const PERMISSION_GUIDANCE =
  "Review the Jira token permissions and the device-local approval for the exact Atlassian connection.";

export type JiraRunReason = PluginJobRunContext["reason"];

export interface JiraWorkflowStatus {
  readonly version: 1;
  readonly outcome:
    | "applied"
    | "preview"
    | "cancelled"
    | "configuration-error"
    | "transport-unavailable"
    | "partial"
    | "failed";
  readonly reason: JiraRunReason;
  readonly finishedAt: string;
  readonly guidance: string | null;
  readonly counts: {
    readonly searched: number;
    readonly validNotes: number;
    readonly invalidNotes: number;
    readonly uniqueKeys: number;
    readonly duplicateKeys: number;
    readonly missingIssues: number;
    readonly providerFailures: number;
    readonly proposed: number;
    readonly updated: number;
    readonly unchanged: number;
    readonly stale: number;
    readonly denied: number;
    readonly failed: number;
  };
  readonly preview: readonly string[];
  readonly providerUpdatedThrough: string | null;
}

export interface JiraWorkflowPorts {
  readonly search: Pick<PluginSearchApi, "query">;
  readonly network: Pick<PluginNetworkApi, "request">;
  readonly notes: Pick<PluginNotesApi, "updatePropertiesBatch">;
  readonly settings: Pick<PluginSettingsApi, "get">;
  readonly secrets: Pick<PluginSecretsApi, "has">;
  readonly statusStorage: Pick<PluginStorageNamespaceApi, "set">;
  readonly delay: JiraClientPorts["delay"];
  readonly now: () => string;
  readonly confirmPreview?: (preview: {
    readonly message: string;
    readonly items: readonly string[];
    readonly proposed: number;
  }) => Promise<boolean>;
}

export interface JiraWorkflowOptions {
  readonly reason: JiraRunReason;
  readonly signal: AbortSignal;
  readonly checkpoint: PluginJobRunContext["checkpoint"];
  readonly scheduledAutoApplyConfirmed: boolean;
}

interface JiraConfiguration {
  readonly mappings: JiraPropertyMappings;
  readonly autoApply: boolean;
}

export async function runJiraWorkflow(
  ports: JiraWorkflowPorts,
  options: JiraWorkflowOptions,
): Promise<JiraWorkflowStatus> {
  const base = emptyCounts();
  try {
    throwIfAborted(options.signal);
    const configuration = await readConfiguration(ports);
    if (configuration.kind === "error") {
      return publishStatus(ports, status({
        outcome: "configuration-error",
        reason: options.reason,
        finishedAt: ports.now(),
        guidance: configuration.guidance,
        counts: base,
      }));
    }

    const searchResult = await searchAllJiraNotes(ports.search, options.signal);
    const collected = collectJiraNotes(searchResult.rows);
    const searchedCounts = {
      ...base,
      searched: searchResult.rows.length,
      validNotes: collected.notes.length,
      invalidNotes: collected.invalid.length,
      uniqueKeys: collected.keys.length,
      duplicateKeys: collected.duplicateKeys.length,
    };
    if (collected.keys.length === 0) {
      return publishStatus(ports, status({
        outcome: "preview",
        reason: options.reason,
        finishedAt: ports.now(),
        guidance: searchResult.truncated
          ? "The bounded search limit was reached; narrow the vault query before refreshing again."
          : null,
        counts: searchedCounts,
      }));
    }

    const checkpoint = parseCheckpoint(await options.checkpoint.get());
    const provider = await loadJiraIssues(collected.keys, {
      ports: { network: ports.network, delay: ports.delay },
      signal: options.signal,
      mappings: configuration.value.mappings,
      notesByKey: collected.notesByKey,
      checkpoint,
    });
    if (provider.checkpointBatches.length > 0) {
      const nextCheckpoint = mergeCheckpointBatches(
        checkpoint,
        provider.checkpointBatches,
      );
      await options.checkpoint.set(checkpointJson(nextCheckpoint));
    }

    const plans = planJiraProjections(
      collected.notes,
      provider.issues,
      configuration.value.mappings,
    );
    const preview = buildBoundedPreview(
      plans,
      collected.duplicateKeys,
      collected.invalid.length,
      provider.missingKeys,
      provider.failures.length,
    );
    const providerUpdatedThrough = newestProviderTimestamp(
      [...provider.issues.values()].map(issue => issue.updated),
      provider.checkpointBatches.map(batch => batch.updatedThrough),
    );
    const plannedCounts = {
      ...searchedCounts,
      missingIssues: provider.missingKeys.length,
      providerFailures: provider.failures.length,
      proposed: plans.length,
    };

    const failureGuidance = guidanceForFailures(provider.failures);
    if (provider.issues.size === 0
      && provider.failures.some(failure => failure.code === "transport-unavailable")) {
      return publishStatus(ports, status({
        outcome: "transport-unavailable",
        reason: options.reason,
        finishedAt: ports.now(),
        guidance: TRANSPORT_GUIDANCE,
        counts: plannedCounts,
        preview: preview.items,
        providerUpdatedThrough,
      }));
    }

    if (plans.length === 0) {
      return publishStatus(ports, status({
        outcome: provider.failures.length > 0 ? "partial" : "preview",
        reason: options.reason,
        finishedAt: ports.now(),
        guidance: failureGuidance ?? (
          searchResult.truncated
            ? "The bounded search limit was reached; remaining notes were not refreshed."
            : null
        ),
        counts: plannedCounts,
        preview: preview.items,
        providerUpdatedThrough,
      }));
    }

    let apply = false;
    if (options.reason === "manual") {
      apply = await ports.confirmPreview?.({
        message: preview.message,
        items: preview.items,
        proposed: plans.length,
      }) ?? false;
      throwIfAborted(options.signal);
      if (!apply) {
        return publishStatus(ports, status({
          outcome: "cancelled",
          reason: options.reason,
          finishedAt: ports.now(),
          guidance: null,
          counts: plannedCounts,
          preview: preview.items,
          providerUpdatedThrough,
        }));
      }
    } else {
      apply = configuration.value.autoApply && options.scheduledAutoApplyConfirmed;
    }

    if (!apply) {
      return publishStatus(ports, status({
        outcome: provider.failures.length > 0 ? "partial" : "preview",
        reason: options.reason,
        finishedAt: ports.now(),
        guidance: configuration.value.autoApply
          ? "Scheduled writes remain disabled until the separate device-local side-effect warning is accepted."
          : "Scheduled refresh updated this preview only. Enable and separately confirm auto-apply to write on the designated runner.",
        counts: plannedCounts,
        preview: preview.items,
        providerUpdatedThrough,
      }));
    }

    const applied = await applyProjectionPlans(ports.notes, plans, options.signal);
    const finalCounts = {
      ...plannedCounts,
      ...applied.counts,
    };
    const partial = provider.failures.length > 0
      || applied.counts.stale > 0
      || applied.counts.denied > 0
      || applied.counts.failed > 0
      || searchResult.truncated;
    return publishStatus(ports, status({
      outcome: partial ? "partial" : "applied",
      reason: options.reason,
      finishedAt: ports.now(),
      guidance: failureGuidance ?? (
        partial
          ? "Stale, denied, or failed notes were left untouched. Refresh again after resolving the reported condition."
          : null
      ),
      counts: finalCounts,
      preview: preview.items,
      providerUpdatedThrough,
    }));
  } catch (error) {
    const cancelled = options.signal.aborted || publicErrorCode(error) === "cancelled";
    return publishStatus(ports, status({
      outcome: cancelled ? "cancelled" : "failed",
      reason: options.reason,
      finishedAt: ports.now(),
      guidance: cancelled ? null : safeWorkflowGuidance(error),
      counts: base,
    }));
  }
}

export async function applyProjectionPlans(
  notes: Pick<PluginNotesApi, "updatePropertiesBatch">,
  plans: readonly JiraProjectionPlan[],
  signal: AbortSignal,
): Promise<ApplySummary> {
  const operations = toPropertyBatchOperations(plans);
  const results: PluginPropertyMutationResult[] = [];
  for (const batch of chunkBounded(operations, UPDATE_BATCH_SIZE)) {
    throwIfAborted(signal);
    const first = await notes.updatePropertiesBatch(batch, {
      operation: "refresh-jira",
    });
    if (first.length !== batch.length) {
      throw workflowError("invalid-batch-result");
    }
    const final = [...first];
    const retryIndexes = first
      .map((result, index) => ({ result, index }))
      .filter(({ result }) => result.status === "failed" && result.error?.retryable === true)
      .map(({ index }) => index);
    if (retryIndexes.length > 0) {
      throwIfAborted(signal);
      const retryOperations = retryIndexes.map(index => batch[index]);
      const retry = await notes.updatePropertiesBatch(retryOperations, {
        operation: "refresh-jira",
      });
      if (retry.length !== retryOperations.length) {
        throw workflowError("invalid-batch-result");
      }
      retry.forEach((result, index) => {
        final[retryIndexes[index]] = result;
      });
    }
    results.push(...final);
  }
  return summarizeApplyResults(results);
}

async function searchAllJiraNotes(
  search: Pick<PluginSearchApi, "query">,
  signal: AbortSignal,
): Promise<{
  readonly rows: readonly Awaited<ReturnType<PluginSearchApi["query"]>>["rows"][number][];
  readonly truncated: boolean;
}> {
  const rows: Awaited<ReturnType<PluginSearchApi["query"]>>["rows"][number][] = [];
  const cursors = new Set<string>();
  let cursor: string | null = null;
  let truncated = false;
  do {
    throwIfAborted(signal);
    const page = await search.query(SEARCH_QUERY, {
      includeBare: false,
      includeAssets: false,
      limit: SEARCH_PAGE_SIZE,
      cursor,
      signal,
    });
    if (page.diagnostics.some(item => item.severity === "error")) {
      throw workflowError("search-diagnostic");
    }
    const remaining = MAX_SEARCH_ROWS - rows.length;
    rows.push(...page.rows.slice(0, remaining));
    if (page.rows.length > remaining || (rows.length >= MAX_SEARCH_ROWS && page.cursor)) {
      truncated = true;
      break;
    }
    cursor = page.cursor;
    if (cursor) {
      if (cursors.has(cursor)) throw workflowError("search-cursor-loop");
      cursors.add(cursor);
    }
  } while (cursor);
  return Object.freeze({ rows: Object.freeze(rows), truncated });
}

async function readConfiguration(
  ports: JiraWorkflowPorts,
): Promise<
  | { readonly kind: "success"; readonly value: JiraConfiguration }
  | { readonly kind: "error"; readonly guidance: string }
> {
  const [
    siteUrl,
    email,
    tokenPresent,
    statusProperty,
    assigneeProperty,
    lastSyncedProperty,
    autoApply,
  ] = await Promise.all([
    ports.settings.get("siteUrl"),
    ports.settings.get("email"),
    ports.secrets.has("apiToken"),
    ports.settings.get("statusProperty"),
    ports.settings.get("assigneeProperty"),
    ports.settings.get("lastSyncedProperty"),
    ports.settings.get("autoApply"),
  ]);
  if (typeof siteUrl !== "string" || siteUrl.length === 0
    || typeof email !== "string" || email.length === 0 || !tokenPresent) {
    return Object.freeze({
      kind: "error",
      guidance: "Configure the Atlassian site, account email, and encrypted API token before refreshing.",
    });
  }
  const normalized = normalizePropertyMappings({
    status: statusProperty ?? "JiraStatus",
    assignee: assigneeProperty ?? "JiraAssignee",
    lastSynced: lastSyncedProperty ?? "JiraLastSynced",
  });
  if (!normalized.mappings
    || configuredPropertyNames(normalized.mappings).length === 0) {
    return Object.freeze({
      kind: "error",
      guidance: "Configure at least one unique, safe Jira output property mapping.",
    });
  }
  return Object.freeze({
    kind: "success",
    value: Object.freeze({
      mappings: normalized.mappings,
      autoApply: autoApply === true,
    }),
  });
}

function emptyCounts(): JiraWorkflowStatus["counts"] {
  return {
    searched: 0,
    validNotes: 0,
    invalidNotes: 0,
    uniqueKeys: 0,
    duplicateKeys: 0,
    missingIssues: 0,
    providerFailures: 0,
    proposed: 0,
    updated: 0,
    unchanged: 0,
    stale: 0,
    denied: 0,
    failed: 0,
  };
}

function status(input: {
  readonly outcome: JiraWorkflowStatus["outcome"];
  readonly reason: JiraRunReason;
  readonly finishedAt: string;
  readonly guidance: string | null;
  readonly counts: JiraWorkflowStatus["counts"];
  readonly preview?: readonly string[];
  readonly providerUpdatedThrough?: string | null;
}): JiraWorkflowStatus {
  return Object.freeze({
    version: 1,
    outcome: input.outcome,
    reason: input.reason,
    finishedAt: safeTimestamp(input.finishedAt),
    guidance: input.guidance,
    counts: Object.freeze({ ...input.counts }),
    preview: Object.freeze([...(input.preview ?? [])].slice(0, 25)),
    providerUpdatedThrough: input.providerUpdatedThrough ?? null,
  });
}

async function publishStatus(
  ports: JiraWorkflowPorts,
  value: JiraWorkflowStatus,
): Promise<JiraWorkflowStatus> {
  await ports.statusStorage.set(STATUS_STORAGE_KEY, statusJson(value));
  return value;
}

function statusJson(value: JiraWorkflowStatus): JsonValue {
  return {
    version: value.version,
    outcome: value.outcome,
    reason: value.reason,
    finishedAt: value.finishedAt,
    guidance: value.guidance,
    counts: { ...value.counts },
    preview: [...value.preview],
    providerUpdatedThrough: value.providerUpdatedThrough,
  };
}

function checkpointJson(checkpoint: JiraCheckpoint): JsonValue {
  return {
    version: 1,
    batches: checkpoint.batches.map(batch => ({
      id: batch.id,
      etag: batch.etag,
      mapping: batch.mapping,
      projections: { ...batch.projections },
      missing: [...batch.missing],
      updatedThrough: batch.updatedThrough,
    })),
  };
}

function newestProviderTimestamp(
  direct: readonly string[],
  checkpoint: readonly (string | null)[],
): string | null {
  const values = [...direct, ...checkpoint.filter((value): value is string => value !== null)]
    .sort();
  return values.at(-1) ?? null;
}

function guidanceForFailures(failures: readonly JiraProviderFailure[]): string | null {
  if (failures.some(failure => failure.code === "transport-unavailable")) {
    return TRANSPORT_GUIDANCE;
  }
  if (failures.some(failure => failure.code === "permission-denied")) {
    return PERMISSION_GUIDANCE;
  }
  if (failures.some(failure => failure.code === "throttled")) {
    return "Jira continued throttling after bounded retries. No unavailable issue was projected.";
  }
  if (failures.length > 0) {
    return "One or more bounded Jira batches failed. Successful batches remain previewable and retry safely.";
  }
  return null;
}

function safeWorkflowGuidance(error: unknown): string {
  const code = publicErrorCode(error);
  if (code === "transport-unavailable") return TRANSPORT_GUIDANCE;
  if (code === "permission-denied") return PERMISSION_GUIDANCE;
  if (code === "size-limit" || code === "result-too-large") {
    return "The host or provider response exceeded a public API bound; no unvalidated response content was stored.";
  }
  return "Jira refresh failed with a bounded, redacted workflow error. Retry after reviewing plugin status and settings.";
}

function safeTimestamp(value: string): string {
  const parsed = Date.parse(value);
  return Number.isFinite(parsed) ? new Date(parsed).toISOString() : "1970-01-01T00:00:00.000Z";
}

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

function workflowError(code: string): Error {
  return Object.assign(new Error("Jira workflow failed."), { code });
}

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