import type {
  PluginNetworkApi,
  PluginNetworkResponseDto,
} from "@thortiq/plugin-api";
import {
  PROVIDER_BATCH_SIZE,
  PROVIDER_CONCURRENCY,
  checkpointBatchMap,
  chunkBounded,
  isCheckpointProjectionCurrent,
  issueChanges,
  mappingSignature,
  normalizeJiraKey,
  projectionFingerprint,
  providerBatchId,
  type JiraCheckpoint,
  type JiraCheckpointBatch,
  type JiraIssueProjection,
  type JiraNoteMapping,
  type JiraPropertyMappings,
} from "./workflow-core.js";

const MAX_PROVIDER_PAGES = 20;
const MAX_ISSUES_PER_PAGE = 100;
const MAX_PROVIDER_STRING = 512;
const MAX_RETRY_DELAY_MS = 30_000;
const RETRY_DELAYS_MS = Object.freeze([250, 1_000]);
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
const CONTROL_OR_UNSAFE =
  /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/u;

export type JiraProviderFailureCode =
  | "permission-denied"
  | "transport-unavailable"
  | "throttled"
  | "provider-failed"
  | "malformed-response"
  | "cancelled";

export interface JiraProviderFailure {
  readonly code: JiraProviderFailureCode;
  readonly keys: readonly string[];
  readonly retryable: boolean;
}

export interface JiraLoadResult {
  readonly issues: ReadonlyMap<string, JiraIssueProjection>;
  readonly missingKeys: readonly string[];
  readonly unchangedKeys: readonly string[];
  readonly failures: readonly JiraProviderFailure[];
  readonly checkpointBatches: readonly JiraCheckpointBatch[];
}

export interface JiraClientPorts {
  readonly network: Pick<PluginNetworkApi, "request">;
  readonly delay: (milliseconds: number, signal: AbortSignal) => Promise<void>;
}

interface JiraPage {
  readonly issues: readonly JiraIssueProjection[];
  readonly nextPageToken: string | null;
  readonly nextStartAt: number | null;
  readonly complete: boolean;
}

interface BatchSuccess {
  readonly kind: "success";
  readonly issues: readonly JiraIssueProjection[];
  readonly missing: readonly string[];
  readonly unchanged: readonly string[];
  readonly checkpoint: JiraCheckpointBatch | null;
}

interface BatchFailure {
  readonly kind: "failure";
  readonly failure: JiraProviderFailure;
}

type BatchResult = BatchSuccess | BatchFailure;

/**
 * Parses one complete host-network Jira page without duplicating provider DTO
 * validation in a runtime adapter. Multi-page/retry/checkpoint orchestration
 * remains in loadJiraIssues.
 */
export function parseJiraIssuePageResponse(
  body: unknown,
): readonly JiraIssueProjection[] | null {
  const page = parseJiraPage(body, 0);
  return page?.complete === true ? page.issues : null;
}

export async function loadJiraIssues(
  keys: readonly string[],
  options: {
    readonly ports: JiraClientPorts;
    readonly signal: AbortSignal;
    readonly mappings: JiraPropertyMappings;
    readonly notesByKey: ReadonlyMap<string, readonly JiraNoteMapping[]>;
    readonly checkpoint: JiraCheckpoint;
  },
): Promise<JiraLoadResult> {
  throwIfAborted(options.signal);
  const batches = chunkBounded([...new Set(keys)].sort(compareText), PROVIDER_BATCH_SIZE);
  const checkpointById = checkpointBatchMap(options.checkpoint);
  const results = await mapConcurrent(
    batches,
    PROVIDER_CONCURRENCY,
    async batch => loadJiraBatch(batch, {
      ...options,
      previous: checkpointById.get(providerBatchId(batch, options.mappings)) ?? null,
    }),
    options.signal,
  );

  const issues = new Map<string, JiraIssueProjection>();
  const missing = new Set<string>();
  const unchanged = new Set<string>();
  const failures: JiraProviderFailure[] = [];
  const checkpointBatches: JiraCheckpointBatch[] = [];
  for (const result of results) {
    if (result.kind === "failure") {
      failures.push(result.failure);
      continue;
    }
    for (const issue of result.issues) issues.set(issue.key, issue);
    for (const key of result.missing) missing.add(key);
    for (const key of result.unchanged) unchanged.add(key);
    if (result.checkpoint) checkpointBatches.push(result.checkpoint);
  }
  return Object.freeze({
    issues,
    missingKeys: Object.freeze([...missing].sort(compareText)),
    unchangedKeys: Object.freeze([...unchanged].sort(compareText)),
    failures: Object.freeze(failures),
    checkpointBatches: Object.freeze(checkpointBatches),
  });
}

async function loadJiraBatch(
  keys: readonly string[],
  options: {
    readonly ports: JiraClientPorts;
    readonly signal: AbortSignal;
    readonly mappings: JiraPropertyMappings;
    readonly notesByKey: ReadonlyMap<string, readonly JiraNoteMapping[]>;
    readonly checkpoint: JiraCheckpoint;
    readonly previous: JiraCheckpointBatch | null;
  },
): Promise<BatchResult> {
  const batchId = providerBatchId(keys, options.mappings);
  const conditionalEtag = options.previous?.mapping === mappingSignature(options.mappings)
    ? options.previous.etag
    : null;
  let conditional = conditionalEtag;

  for (;;) {
    const outcome = await requestBatchPages(keys, {
      ports: options.ports,
      signal: options.signal,
      etag: conditional,
    });
    if (outcome.kind === "failure") {
      return failure(keys, outcome.failure.code, outcome.failure.retryable);
    }
    if (outcome.notModified) {
      const subset = new Map(
        keys.map(key => [key, options.notesByKey.get(key) ?? Object.freeze([])]),
      );
      if (options.previous
        && isCheckpointProjectionCurrent(options.previous, subset, options.mappings)) {
        return Object.freeze({
          kind: "success",
          issues: Object.freeze([]),
          missing: options.previous.missing,
          unchanged: Object.freeze(keys.filter(key => !options.previous?.missing.includes(key))),
          checkpoint: options.previous,
        });
      }
      conditional = null;
      continue;
    }

    const requested = new Set(keys);
    const seen = new Set<string>();
    for (const issue of outcome.issues) {
      if (!requested.has(issue.key) || seen.has(issue.key)) {
        return failure(keys, "malformed-response", false);
      }
      seen.add(issue.key);
    }
    const missing = keys.filter(key => !seen.has(key));
    let checkpoint: JiraCheckpointBatch | null = null;
    if (outcome.etag && outcome.singlePage) {
      const projections: Record<string, string> = {};
      let updatedThrough: string | null = null;
      for (const issue of outcome.issues) {
        projections[issue.key] = projectionFingerprint(issueChanges(issue, options.mappings));
        if (updatedThrough === null || issue.updated > updatedThrough) {
          updatedThrough = issue.updated;
        }
      }
      checkpoint = Object.freeze({
        id: batchId,
        etag: outcome.etag,
        mapping: mappingSignature(options.mappings),
        projections: Object.freeze(projections),
        missing: Object.freeze(missing),
        updatedThrough,
      });
    }
    return Object.freeze({
      kind: "success",
      issues: outcome.issues,
      missing: Object.freeze(missing),
      unchanged: Object.freeze([]),
      checkpoint,
    });
  }
}

async function requestBatchPages(
  keys: readonly string[],
  options: {
    readonly ports: JiraClientPorts;
    readonly signal: AbortSignal;
    readonly etag: string | null;
  },
): Promise<
  | BatchFailure
  | {
    readonly kind: "pages";
    readonly issues: readonly JiraIssueProjection[];
    readonly etag: string | null;
    readonly singlePage: boolean;
    readonly notModified: boolean;
  }
> {
  const issues: JiraIssueProjection[] = [];
  let nextPageToken: string | null = null;
  let startAt: number | null = null;
  let firstEtag: string | null = null;
  for (let pageIndex = 0; pageIndex < MAX_PROVIDER_PAGES; pageIndex += 1) {
    throwIfAborted(options.signal);
    const response = await requestWithRetry(
      () => options.ports.network.request({
        connection: "atlassian",
        path: "/rest/api/3/search/jql",
        method: "GET",
        query: {
          jql: `key in (${keys.join(", ")})`,
          fields: ["key", "status", "assignee", "updated"],
          maxResults: String(MAX_ISSUES_PER_PAGE),
          ...(nextPageToken ? { nextPageToken } : {}),
          ...(startAt !== null ? { startAt: String(startAt) } : {}),
        },
        headers: {
          Accept: "application/json",
          ...(pageIndex === 0 && options.etag ? { "If-None-Match": options.etag } : {}),
        },
        responseType: "json",
        timeoutMs: 30_000,
        signal: options.signal,
        credential: {
          type: "basic",
          usernameSetting: "email",
          secret: "apiToken",
        },
      }),
      options,
    );
    if (response.kind === "failure") return response;
    if (response.response.status === 304) {
      if (pageIndex !== 0 || !options.etag) return failure(keys, "malformed-response", false);
      return Object.freeze({
        kind: "pages",
        issues: Object.freeze([]),
        etag: options.etag,
        singlePage: true,
        notModified: true,
      });
    }
    if (response.response.status === 401 || response.response.status === 403) {
      return failure(keys, "permission-denied", false);
    }
    if (response.response.status < 200 || response.response.status >= 300) {
      return failure(keys, "provider-failed", response.response.status >= 500);
    }
    const page = parseJiraPage(response.response.body, issues.length);
    if (!page) return failure(keys, "malformed-response", false);
    if (pageIndex === 0) firstEtag = validEtag(response.response.headers.etag);
    issues.push(...page.issues);
    if (page.complete) {
      return Object.freeze({
        kind: "pages",
        issues: Object.freeze(issues),
        etag: firstEtag,
        singlePage: pageIndex === 0,
        notModified: false,
      });
    }
    nextPageToken = page.nextPageToken;
    startAt = page.nextStartAt;
  }
  return failure(keys, "malformed-response", false);
}

async function requestWithRetry(
  request: () => Promise<PluginNetworkResponseDto>,
  options: {
    readonly ports: JiraClientPorts;
    readonly signal: AbortSignal;
  },
): Promise<
  | { readonly kind: "response"; readonly response: PluginNetworkResponseDto }
  | BatchFailure
> {
  for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
    throwIfAborted(options.signal);
    let response: PluginNetworkResponseDto;
    try {
      response = await request();
    } catch (error) {
      const code = publicErrorCode(error);
      if (code === "cancelled" || options.signal.aborted) {
        throwIfAborted(options.signal);
        return failure([], "cancelled", false);
      }
      const retryable = code === "timeout" || code === "rate-limit";
      if (retryable && attempt < RETRY_DELAYS_MS.length) {
        await options.ports.delay(RETRY_DELAYS_MS[attempt], options.signal);
        continue;
      }
      if (code === "permission-denied") return failure([], "permission-denied", false);
      if (code === "transport-unavailable") {
        return failure([], "transport-unavailable", false);
      }
      return failure([], "provider-failed", retryable);
    }
    if (!RETRYABLE_STATUS.has(response.status)) {
      return Object.freeze({ kind: "response", response });
    }
    if (attempt >= RETRY_DELAYS_MS.length) {
      return failure([], response.status === 429 ? "throttled" : "provider-failed", true);
    }
    const retryAfter = parseRetryAfter(response.headers["retry-after"]);
    await options.ports.delay(retryAfter ?? RETRY_DELAYS_MS[attempt], options.signal);
  }
  return failure([], "provider-failed", true);
}

function parseJiraPage(body: unknown, alreadyLoaded: number): JiraPage | null {
  if (!plainRecord(body) || !Array.isArray(body.issues)
    || body.issues.length > MAX_ISSUES_PER_PAGE) {
    return null;
  }
  const issues: JiraIssueProjection[] = [];
  for (const value of body.issues) {
    const issue = parseIssue(value);
    if (!issue) return null;
    issues.push(issue);
  }

  const nextPageToken = body.nextPageToken === undefined || body.nextPageToken === null
    ? null
    : safeProviderString(body.nextPageToken, MAX_PROVIDER_STRING);
  if (body.nextPageToken !== undefined && body.nextPageToken !== null && !nextPageToken) {
    return null;
  }
  const isLast = body.isLast === true;
  const startAt = body.startAt === undefined ? alreadyLoaded : safeInteger(body.startAt);
  const total = body.total === undefined ? null : safeInteger(body.total);
  if (startAt === null || (body.total !== undefined && total === null)) return null;

  if (nextPageToken) {
    return Object.freeze({
      issues: Object.freeze(issues),
      nextPageToken,
      nextStartAt: null,
      complete: false,
    });
  }
  if (isLast || total === null || alreadyLoaded + issues.length >= total) {
    return Object.freeze({
      issues: Object.freeze(issues),
      nextPageToken: null,
      nextStartAt: null,
      complete: true,
    });
  }
  if (issues.length === 0) return null;
  return Object.freeze({
    issues: Object.freeze(issues),
    nextPageToken: null,
    nextStartAt: startAt + issues.length,
    complete: false,
  });
}

function parseIssue(value: unknown): JiraIssueProjection | null {
  if (!plainRecord(value) || !plainRecord(value.fields)) return null;
  const key = normalizeJiraKey(value.key);
  const statusValue = value.fields.status;
  const assigneeValue = value.fields.assignee;
  const updatedValue = safeProviderString(value.fields.updated, MAX_PROVIDER_STRING);
  if (!key || !updatedValue) return null;
  const parsedUpdated = Date.parse(updatedValue);
  if (!Number.isFinite(parsedUpdated)) return null;

  let status: string | null = null;
  if (statusValue !== null && statusValue !== undefined) {
    if (!plainRecord(statusValue)) return null;
    status = safeProviderString(statusValue.name, MAX_PROVIDER_STRING);
    if (!status) return null;
  }

  let assignee: string | null = null;
  if (assigneeValue !== null && assigneeValue !== undefined) {
    if (!plainRecord(assigneeValue)) return null;
    const displayName = safeProviderString(assigneeValue.displayName, MAX_PROVIDER_STRING);
    const accountId = safeProviderString(assigneeValue.accountId, MAX_PROVIDER_STRING);
    assignee = displayName ?? accountId;
    if (!assignee) return null;
  }

  return Object.freeze({
    key,
    status,
    assignee,
    updated: new Date(parsedUpdated).toISOString(),
  });
}

function safeProviderString(value: unknown, maximum: number): string | null {
  return typeof value === "string"
    && value.length > 0
    && value.length <= maximum
    && !CONTROL_OR_UNSAFE.test(value)
    ? value
    : null;
}

function validEtag(value: unknown): string | null {
  return typeof value === "string"
    && value.length > 0
    && value.length <= 512
    && !CONTROL_OR_UNSAFE.test(value)
    ? value
    : null;
}

function safeInteger(value: unknown): number | null {
  return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : null;
}

function parseRetryAfter(value: unknown): number | null {
  if (typeof value !== "string" || !/^[0-9]{1,6}$/u.test(value)) return null;
  return Math.min(Number(value) * 1_000, MAX_RETRY_DELAY_MS);
}

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 failure(
  keys: readonly string[],
  code: JiraProviderFailureCode,
  retryable: boolean,
): BatchFailure {
  return Object.freeze({
    kind: "failure",
    failure: Object.freeze({
      code,
      keys: Object.freeze([...keys]),
      retryable,
    }),
  });
}

async function mapConcurrent<T, R>(
  values: readonly T[],
  concurrency: number,
  visit: (value: T) => Promise<R>,
  signal: AbortSignal,
): Promise<readonly R[]> {
  const results = new Array<R>(values.length);
  let next = 0;
  const workers = Array.from(
    { length: Math.min(concurrency, values.length) },
    async () => {
      for (;;) {
        throwIfAborted(signal);
        const index = next;
        next += 1;
        if (index >= values.length) return;
        results[index] = await visit(values[index]);
      }
    },
  );
  await Promise.all(workers);
  return Object.freeze(results);
}

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

function plainRecord(value: unknown): value is Record<string, unknown> {
  // Host-network results are admitted JSON DTOs. The runtime-neutral client
  // deliberately avoids prototype reflection, which is unavailable to the
  // live-sandboxed guest.
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

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