import { z } from "zod";
import { CollectorFatalError } from "../errors";

export type KanboardProcedure =
  | "getProjectByIdentifier"
  | "createProject"
  | "updateProject"
  | "getColumns"
  | "updateColumn"
  | "addColumn"
  | "changeColumnPosition"
  | "getAllSwimlanes"
  | "getActiveSwimlanes"
  | "getSwimlaneByName"
  | "addSwimlane"
  | "updateSwimlane"
  | "enableSwimlane"
  | "changeSwimlanePosition"
  | "getBoard"
  | "getAllTasks"
  | "overdeck.listTasksBounded"
  | "getTask"
  | "getTaskByReference"
  | "createTask"
  | "updateTask"
  | "moveTaskPosition"
  | "closeTask"
  | "removeTask"
  | "openTask"
  | "getTaskMetadata"
  | "saveTaskMetadata"
  | "getAllComments"
  | "createComment"
  | "getUserByName"
  | "createUser";

export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;

type KanboardRpcErrorCode =
  | "NON_LOOPBACK_URL"
  | "MISSING_FETCH_IMPL"
  | "HTTP_STATUS"
  | "REDIRECT_RESPONSE"
  | "TRANSPORT_ERROR"
  | "RESPONSE_TIMEOUT"
  | "MALFORMED_JSON"
  | "RESPONSE_SCHEMA_INVALID"
  | "RESPONSE_ID_MISSING"
  | "RESPONSE_ID_MISMATCH"
  | "JSON_RPC_ERROR"
  | "RPC_FALSE_RESULT"
  | "RPC_NULL_RESULT"
  | "BATCH_RESPONSE_MISSING"
  | "BATCH_RESPONSE_DUPLICATE_ID"
  | "BATCH_NOT_ARRAY";

export class KanboardRpcError extends Error {
  constructor(
    public readonly code: KanboardRpcErrorCode,
    message: string,
    public readonly details?: unknown,
  ) {
    super(message);
    this.name = "KanboardRpcError";
  }
}

function extractIssuePaths(error: z.ZodError): string[] {
  const paths = new Set<string>();
  for (const issue of error.issues) {
    const path = issue.path.length === 0 ? ["root"] : issue.path.map(String);
    paths.add(path.join("."));
  }
  return [...paths];
}

type StringNumber = z.ZodType<number, z.ZodTypeDef, string | number>;
const StringNumberSchema: StringNumber = z
  .union([z.number().int(), z.string().regex(/^-?\d+$/)])
  .transform((value): number => (typeof value === "string" ? Number(value) : value))
  .pipe(z.number().int());

const BoolFlagSchema = z
  .union([z.boolean(), z.number().int().min(0).max(1)])
  .transform((value): boolean => (typeof value === "boolean" ? value : value === 1));

const ProjectSchema = z
  .object({
    id: StringNumberSchema,
    identifier: z.string(),
    name: z.string(),
    is_private: BoolFlagSchema.optional(),
    is_public: BoolFlagSchema.optional(),
    is_active: BoolFlagSchema.optional(),
    per_swimlane_task_limits: BoolFlagSchema.optional(),
    enable_global_tags: BoolFlagSchema.optional(),
    hide_in_dashboard: BoolFlagSchema.optional(),
    description: z.string().nullable().optional(),
    email: z.string().nullable().optional(),
    token: z.unknown().optional(),
    url: z.unknown().optional(),
    last_modified: StringNumberSchema.optional(),
    priority_start: StringNumberSchema.optional(),
    priority_end: StringNumberSchema.optional(),
    priority_default: StringNumberSchema.optional(),
  })
  .passthrough();

const CreatedIdSchema = StringNumberSchema;

const ColumnSchema = z
  .object({
    id: StringNumberSchema,
    title: z.string(),
    position: StringNumberSchema.optional(),
    project_id: StringNumberSchema.optional(),
    task_limit: StringNumberSchema.optional(),
    description: z.string().nullable().optional(),
    hide_in_dashboard: BoolFlagSchema.optional(),
  })
  .passthrough();

const SwimlaneSchema = z
  .object({
    id: StringNumberSchema,
    name: z.string(),
    position: StringNumberSchema.optional(),
    is_active: BoolFlagSchema,
    project_id: StringNumberSchema.optional(),
    description: z.string().nullable().optional(),
    task_limit: StringNumberSchema.optional(),
  })
  .passthrough();

const UserSchema = z
  .object({
    id: StringNumberSchema,
  })
  .passthrough();

const TaskColorSchema = z
  .object({
    name: z.string(),
    background: z.string(),
    border: z.string(),
  })
  .passthrough();

const TaskSchema = z
  .object({
    id: StringNumberSchema,
    title: z.string(),
    description: z.string(),
    date_creation: StringNumberSchema,
    color_id: z.string(),
    project_id: StringNumberSchema,
    column_id: StringNumberSchema,
    owner_id: StringNumberSchema,
    position: StringNumberSchema,
    is_active: BoolFlagSchema,
    date_completed: z.union([StringNumberSchema, z.null()]),
    score: StringNumberSchema,
    date_due: StringNumberSchema,
    category_id: StringNumberSchema,
    creator_id: StringNumberSchema,
    date_modification: StringNumberSchema,
    reference: z.string(),
    date_started: StringNumberSchema,
    time_spent: z.union([StringNumberSchema, z.null()]),
    time_estimated: z.union([StringNumberSchema, z.null()]),
    swimlane_id: StringNumberSchema,
    date_moved: StringNumberSchema,
    recurrence_status: z.union([StringNumberSchema, z.null()]).optional(),
    recurrence_counter: z.union([StringNumberSchema, z.null()]).optional(),
    recurrence_parent: z.union([StringNumberSchema, z.null()]).optional(),
    priority: StringNumberSchema,
    external_provider: z.union([z.string(), z.null()]).optional(),
    external_uri: z.union([z.string(), z.null()]).optional(),
    url: z.string(),
    color: TaskColorSchema,
  })
  .passthrough();

const BoundedTaskPageSchema = z.object({
  tasks: z.array(TaskSchema),
  high_water: z.number().int().nonnegative(),
  next_cursor: z.number().int().positive().nullable(),
  exhausted: z.boolean(),
  complete: z.literal(true),
  order: z.literal("id_desc"),
});

const CommentSchema = z
  .object({
    id: StringNumberSchema,
    date_creation: StringNumberSchema,
    date_modification: StringNumberSchema,
    task_id: StringNumberSchema,
    user_id: StringNumberSchema,
    comment: z.string(),
    visibility: z.string(),
    username: z.string(),
    name: z.union([z.string(), z.null()]),
    email: z.union([z.string(), z.null()]),
    avatar_path: z.union([z.string(), z.null()]),
  })
  .passthrough();

type ResultTypeByMethod = {
  getProjectByIdentifier: z.infer<typeof ProjectSchema>;
  createProject: number;
  updateProject: true;
  getColumns: z.infer<typeof ColumnSchema>[];
  updateColumn: true;
  addColumn: number;
  changeColumnPosition: true;
  getAllSwimlanes: z.infer<typeof SwimlaneSchema>[];
  getActiveSwimlanes: z.infer<typeof SwimlaneSchema>[];
  getSwimlaneByName: z.infer<typeof SwimlaneSchema>;
  addSwimlane: number;
  updateSwimlane: true;
  enableSwimlane: true;
  changeSwimlanePosition: true;
  getBoard: unknown;
  getAllTasks: z.infer<typeof TaskSchema>[];
  "overdeck.listTasksBounded": z.infer<typeof BoundedTaskPageSchema>;
  getTask: z.infer<typeof TaskSchema>;
  getTaskByReference: z.infer<typeof TaskSchema>;
  createTask: number;
  updateTask: unknown;
  moveTaskPosition: unknown;
  closeTask: unknown;
  removeTask: unknown;
  openTask: unknown;
  getTaskMetadata: Record<string, string>;
  saveTaskMetadata: unknown;
  getAllComments: z.infer<typeof CommentSchema>[];
  createComment: number;
  getUserByName: z.infer<typeof UserSchema>;
  createUser: number;
};

export type KanboardResult<M extends KanboardProcedure> =
  M extends keyof ResultTypeByMethod ? ResultTypeByMethod[M] : unknown;

type ParamsByMethod = {
  getProjectByIdentifier: { readonly identifier: string };
  createProject: {
    readonly name: string;
    readonly identifier: string;
    readonly priority_start?: number;
    readonly priority_end?: number;
    readonly priority_default?: number;
  };
  updateProject: {
    readonly project_id: number;
    readonly name?: string;
    readonly priority_start?: number;
    readonly priority_end?: number;
    readonly priority_default?: number;
  };
  getColumns: { readonly project_id: number };
  updateColumn: { readonly column_id: number; readonly title: string };
  addColumn: { readonly project_id: number; readonly title: string; readonly position?: number };
  changeColumnPosition: { readonly project_id: number; readonly column_id: number; readonly position: number };
  getAllSwimlanes: { readonly project_id: number };
  getActiveSwimlanes: { readonly project_id: number };
  getSwimlaneByName: { readonly project_id: number; readonly name: string };
  addSwimlane: { readonly project_id: number; readonly name: string };
  updateSwimlane: { readonly project_id: number; readonly swimlane_id: number; readonly name: string };
  enableSwimlane: { readonly project_id: number; readonly swimlane_id: number };
  changeSwimlanePosition: { readonly project_id: number; readonly swimlane_id: number; readonly position: number };
  getBoard: { readonly project_id: number };
  getAllTasks: { readonly project_id: number; readonly status_id: number };
  "overdeck.listTasksBounded": {
    readonly projectId: number;
    readonly statusId: number | null;
    readonly limit: number;
    readonly cursor: number | null;
    readonly highWater: number | null;
    readonly query: string;
    readonly priority: number | null;
  };
  getTask: { readonly task_id: number };
  getTaskByReference: { readonly project_id: number; readonly reference: string };
  createTask: {
    readonly project_id: number;
    readonly column_id: number;
    readonly title: string;
    readonly description?: string;
    readonly swimlane_id?: number;
    readonly priority?: number;
    readonly reference?: string;
  };
  updateTask: { readonly id: number; readonly reference: string };
  moveTaskPosition: {
    readonly project_id: number;
    readonly task_id: number;
    readonly column_id: number;
    readonly position: number;
    readonly swimlane_id: number;
  };
  closeTask: { readonly task_id: number };
  removeTask: { readonly task_id: number };
  openTask: { readonly task_id: number };
  getTaskMetadata: { readonly task_id: number };
  saveTaskMetadata: { readonly task_id: number; readonly values: Record<string, string> };
  getAllComments: { readonly task_id: number };
  createComment: { readonly task_id: number; readonly content: string; readonly user_id: number };
  getUserByName: { readonly username: string };
  createUser: {
    readonly username: string;
    readonly password: string;
    readonly name: string;
  };
};

export type KanboardParams<M extends KanboardProcedure> = M extends keyof ParamsByMethod
  ? ParamsByMethod[M]
  : Record<string, unknown>;

export type KanboardCall = {
  [M in KanboardProcedure]: {
    method: M;
    params: KanboardParams<M>;
  };
}[KanboardProcedure];

export type KanboardBatchResult<C extends readonly KanboardCall[]> = {
  [I in keyof C]: C[I] extends { method: infer M extends KanboardProcedure } ? KanboardResult<M> : never;
};

export interface KanboardRpcClient {
  call<M extends KanboardProcedure>(method: M, params: KanboardParams<M>): Promise<KanboardResult<M>>;
  batch<C extends readonly KanboardCall[]>(calls: C): Promise<KanboardBatchResult<C>>;
}

interface JsonRpcEnvelope {
  jsonrpc: "2.0";
  id: string | number;
  result?: unknown;
  error?: unknown;
}

const CONNECT_TIMEOUT_MS = 3_000;
const RESPONSE_TIMEOUT_MS = 10_000;
const BASIC_PREFIX = "jsonrpc:";
const JSONRPC_VERSION = "2.0";

function makeRequestId(): string {
  if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
    return crypto.randomUUID();
  }
  return `kanboard-${Math.random().toString(16).slice(2)}-${Date.now()}`;
}

function isLoopbackHost(hostname: string): boolean {
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
}

function isRpcErrorEnvelope(body: JsonRpcEnvelope): body is JsonRpcEnvelope & { error: unknown } {
  return body.error !== undefined;
}

function isRpcSuccessEnvelope(body: JsonRpcEnvelope): body is JsonRpcEnvelope & { result: unknown } {
  return body.result !== undefined;
}

function parseResultSchema(method: KanboardProcedure): z.ZodType<unknown> {
  switch (method) {
    case "getProjectByIdentifier":
      return ProjectSchema;
    case "createProject":
      return CreatedIdSchema;
    case "updateProject":
      return z.literal(true);
    case "getColumns":
      return z.array(ColumnSchema);
    case "updateColumn":
      return z.literal(true);
    case "addColumn":
      return CreatedIdSchema;
    case "changeColumnPosition":
      return z.literal(true);
    case "getAllSwimlanes":
      return z.array(SwimlaneSchema);
    case "getActiveSwimlanes":
      return z.array(SwimlaneSchema);
    case "getSwimlaneByName":
      return SwimlaneSchema;
    case "addSwimlane":
      return CreatedIdSchema;
    case "updateSwimlane":
      return z.literal(true);
    case "enableSwimlane":
      return z.literal(true);
    case "changeSwimlanePosition":
      return z.literal(true);
    case "getUserByName":
      return UserSchema;
    case "createUser":
      return CreatedIdSchema;
    case "getBoard":
      return z.unknown();
    case "getAllTasks":
      return z.array(TaskSchema);
    case "overdeck.listTasksBounded":
      return BoundedTaskPageSchema;
    case "getTask":
      return TaskSchema;
    case "getTaskByReference":
      return TaskSchema;
    case "createTask":
      return CreatedIdSchema;
    case "updateTask":
      return z.unknown();
    case "moveTaskPosition":
      return z.unknown();
    case "closeTask":
      return z.unknown();
    case "openTask":
      return z.unknown();
    case "getTaskMetadata":
      return z.record(z.string());
    case "saveTaskMetadata":
      return z.unknown();
    case "getAllComments":
      return z.array(CommentSchema);
    case "createComment":
      return CreatedIdSchema;
    default:
      return z.unknown();
  }
}

function parseBatchEnvelopeBody(body: unknown, methodCount: number): JsonRpcEnvelope[] {
  if (!Array.isArray(body)) {
    throw new KanboardRpcError("BATCH_NOT_ARRAY", "batch response is malformed");
  }
  if (body.length !== methodCount) {
    throw new KanboardRpcError(
      "BATCH_RESPONSE_MISSING",
      "batch response has incorrect length",
    );
  }
  const envelopes: JsonRpcEnvelope[] = [];
  for (const item of body) {
    const parsed = z
      .object({
        jsonrpc: z.literal("2.0"),
        id: z.union([z.string(), z.number()]).optional(),
        result: z.unknown().optional(),
        error: z.unknown().optional(),
      })
      .passthrough()
      .safeParse(item);
    if (!parsed.success) {
      throw new KanboardRpcError(
        "RESPONSE_SCHEMA_INVALID",
        "batch response has invalid envelope",
        extractIssuePaths(parsed.error),
      );
    }
    if (parsed.data.id === undefined || parsed.data.id === null) {
      throw new KanboardRpcError("RESPONSE_ID_MISSING", "batch response missing id");
    }
    envelopes.push(parsed.data as JsonRpcEnvelope);
  }
  return envelopes;
}

function requestEnvelope<M extends KanboardProcedure>(
  method: M,
  params: KanboardParams<M>,
  id: string,
): { jsonrpc: "2.0"; method: M; params: KanboardParams<M>; id: string } {
  return {
    jsonrpc: JSONRPC_VERSION,
    method,
    params,
    id,
  };
}

async function parseJsonOrThrow(response: Response): Promise<unknown> {
  const raw = await response.text();
  try {
    return JSON.parse(raw);
  } catch (error) {
    throw new KanboardRpcError("MALFORMED_JSON", `invalid JSON response: ${(error as Error).message}`);
  }
}

function ensureRpcEnvelope(body: unknown, requestId: string, method: string): JsonRpcEnvelope {
  const parsed = z
    .object({
      jsonrpc: z.literal("2.0"),
      id: z.union([z.string(), z.number()]).optional(),
      result: z.unknown().optional(),
      error: z.unknown().optional(),
    })
    .passthrough()
    .safeParse(body);
  if (!parsed.success) {
    throw new KanboardRpcError(
      "RESPONSE_SCHEMA_INVALID",
      `invalid json-rpc response for ${method}`,
      extractIssuePaths(parsed.error),
    );
  }
  const envelope = parsed.data as JsonRpcEnvelope;
  if (envelope.id === undefined || envelope.id === null) {
    throw new KanboardRpcError("RESPONSE_ID_MISSING", `json-rpc response id missing for ${method}`);
  }
  if (String(envelope.id) !== String(requestId)) {
    throw new KanboardRpcError("RESPONSE_ID_MISMATCH", `json-rpc response id mismatch for ${method}`);
  }
  return envelope;
}

function validateResult<M extends KanboardProcedure>(method: M, rawResult: unknown): KanboardResult<M> {
  if (rawResult === null) {
    throw new KanboardRpcError("RPC_NULL_RESULT", `json-rpc method ${method} returned null`);
  }
  if (rawResult === false) {
    throw new KanboardRpcError("RPC_FALSE_RESULT", `json-rpc method ${method} returned failure`);
  }
  const parsed = parseResultSchema(method).safeParse(rawResult);
  if (!parsed.success) {
    throw new KanboardRpcError(
      "RESPONSE_SCHEMA_INVALID",
      `invalid json-rpc result for ${method}`,
      extractIssuePaths(parsed.error),
    );
  }
  return parsed.data as KanboardResult<M>;
}

function parseJsonRpcError(rawError: unknown): never {
  const parsed = z
    .object({ code: z.number(), message: z.string(), data: z.unknown().optional() })
    .passthrough()
    .safeParse(rawError);
  const message =
    parsed.success && typeof parsed.data === "object" && parsed.data !== null && "message" in parsed.data
      ? `json-rpc error ${(parsed.data as { code: number; message: string }).code}: ${(parsed.data as { message: string }).message}`
      : "json-rpc error";
  throw new KanboardRpcError("JSON_RPC_ERROR", message, rawError);
}

export interface CreateKanboardClientOptions {
  baseUrl: string;
  token: string;
  fetchImpl?: FetchLike;
}

export function createKanboardClient(options: CreateKanboardClientOptions): KanboardRpcClient {
  if (!options.fetchImpl) {
    throw new CollectorFatalError("KANBOARD_FETCH_MISSING", "fetch implementation is required");
  }

  const trimmedToken = options.token.trim();
  let endpoint: URL;
  try {
    endpoint = new URL(options.baseUrl);
  } catch {
    throw new CollectorFatalError("KANBOARD_INVALID_BASE_URL", "baseUrl is not a valid URL");
  }
  if (!isLoopbackHost(endpoint.hostname)) {
    throw new CollectorFatalError("KANBOARD_NON_LOOPBACK", "baseUrl must be loopback-hosted for kanboard");
  }
  if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") {
    throw new CollectorFatalError("KANBOARD_INVALID_BASE_URL", "baseUrl must use HTTP(S)");
  }

  const authHeader = `Basic ${Buffer.from(`${BASIC_PREFIX}${trimmedToken}`).toString("base64")}`;
  const headers = {
    "content-type": "application/json",
    authorization: authHeader,
  };

  const sendRequest = async (payload: unknown, method?: string): Promise<Response> => {
    const controller = new AbortController();
    let timeoutKind: "connect" | "response" | null = null;
    const timeout = setTimeout(() => {
      timeoutKind = "response";
      controller.abort(new Error("response timeout"));
    }, RESPONSE_TIMEOUT_MS);
    const connectTimeout = setTimeout(() => {
      timeoutKind = "connect";
      controller.abort(new Error("connect timeout"));
    }, CONNECT_TIMEOUT_MS);
    try {
      return await options.fetchImpl!(options.baseUrl, {
        method: "POST",
        headers,
        body: JSON.stringify(payload),
        redirect: "manual",
        signal: controller.signal,
      });
    } catch (error) {
      const wrapped = error instanceof Error ? error.message : String(error);
      if (timeoutKind !== null) {
        throw new KanboardRpcError("RESPONSE_TIMEOUT", `json-rpc request timed out (${timeoutKind})`, method);
      }
      if (error instanceof KanboardRpcError) {
        throw error;
      }
      throw new KanboardRpcError(
        "TRANSPORT_ERROR",
        `json-rpc transport failure${method ? ` for ${method}` : ""}`,
        wrapped,
      );
    } finally {
      clearTimeout(timeout);
      clearTimeout(connectTimeout);
    }
  };

  const parseSingle = async <M extends KanboardProcedure>(method: M, payload: {
    method: M;
    params: KanboardParams<M>;
  }): Promise<KanboardResult<M>> => {
    const requestId = makeRequestId();
    const response = await sendRequest(requestEnvelope(method, payload.params, requestId), method);

    if (response.status >= 300 && response.status < 400) {
      throw new KanboardRpcError("REDIRECT_RESPONSE", `json-rpc request was redirected for ${method}`);
    }
    if (!response.ok) {
      throw new KanboardRpcError("HTTP_STATUS", `json-rpc request failed with status ${response.status}`);
    }

    const raw = await parseJsonOrThrow(response);
    const envelope = ensureRpcEnvelope(raw, requestId, method);
    if (isRpcErrorEnvelope(envelope)) {
      parseJsonRpcError(envelope.error);
    }
    if (!isRpcSuccessEnvelope(envelope)) {
      throw new KanboardRpcError("RESPONSE_SCHEMA_INVALID", `json-rpc response has no result for ${method}`);
    }
    return validateResult(method, envelope.result);
  };

  const parseBatch = async <C extends readonly KanboardCall[]>(calls: C): Promise<KanboardBatchResult<C>> => {
    const callsById = new Map<string, KanboardCall>();
    const requestPayloads = calls.map((call) => {
      const id = makeRequestId();
      callsById.set(id, call);
      return requestEnvelope(call.method, call.params, id);
    });
    const response = await sendRequest(requestPayloads);
    if (response.status >= 300 && response.status < 400) {
      throw new KanboardRpcError("REDIRECT_RESPONSE", "json-rpc batch request was redirected");
    }
    if (!response.ok) {
      throw new KanboardRpcError("HTTP_STATUS", `json-rpc batch request failed with status ${response.status}`);
    }

    const raw = await parseJsonOrThrow(response);
    const envelopes = parseBatchEnvelopeBody(raw, calls.length);

    const seen = new Set<string>();
    const byId = new Map<string, JsonRpcEnvelope>();
    for (const envelope of envelopes) {
      if (seen.has(String(envelope.id))) {
        throw new KanboardRpcError("BATCH_RESPONSE_DUPLICATE_ID", "batch response has duplicate ids");
      }
      seen.add(String(envelope.id));
      if (!callsById.has(String(envelope.id))) {
        throw new KanboardRpcError("BATCH_RESPONSE_MISSING", "batch response contains an unknown id");
      }
      byId.set(String(envelope.id), envelope);
    }

    if (byId.size !== callsById.size) {
      throw new KanboardRpcError("BATCH_RESPONSE_MISSING", "batch response is missing one or more calls");
    }

    const output: unknown[] = [];
    for (const [id, call] of callsById.entries()) {
      const envelope = byId.get(id);
      if (!envelope) {
        throw new KanboardRpcError("BATCH_RESPONSE_MISSING", "batch response is missing one or more ids");
      }
      if (isRpcErrorEnvelope(envelope)) {
        parseJsonRpcError(envelope.error);
      }
      if (!isRpcSuccessEnvelope(envelope)) {
        throw new KanboardRpcError("RESPONSE_SCHEMA_INVALID", `json-rpc response has no result for ${call.method}`);
      }
      output.push(validateResult(call.method, envelope.result));
    }
    return output as KanboardBatchResult<C>;
  };

  return {
    call: async <M extends KanboardProcedure>(method: M, params: KanboardParams<M>): Promise<KanboardResult<M>> => {
      return parseSingle(method, { method, params });
    },
    batch: async <C extends readonly KanboardCall[]>(calls: C): Promise<KanboardBatchResult<C>> => {
      if (calls.length === 0) return [] as KanboardBatchResult<C>;
      return parseBatch(calls);
    },
  };
}
