import type {
  AllLayoutsMetadataResponse,
  CreateResponse,
  DeleteResponse,
  GetResponse,
  LayoutMetadataResponse,
  PortalRanges,
  RawFMResponse,
  ScriptResponse,
  ScriptsMetadataResponse,
  UpdateResponse,
} from "../client-types.js";
import { FileMakerError } from "../client-types.js";
import type {
  Adapter,
  BaseRequest,
  ContainerUploadOptions,
  CreateOptions,
  DeleteOptions,
  ExecuteScriptOptions,
  FindOptions,
  GetOptions,
  LayoutMetadataOptions,
  ListOptions,
  UpdateOptions,
} from "./core.js";
import type { BaseFetchAdapterOptions, GetTokenArguments } from "./fetch-base-types.js";

export class BaseFetchAdapter implements Adapter {
  protected server: string;
  protected db: string;
  private readonly refreshToken: boolean;
  baseUrl: URL;

  constructor(options: BaseFetchAdapterOptions & { refreshToken?: boolean }) {
    this.server = options.server;
    this.db = options.db;
    this.refreshToken = options.refreshToken ?? false;
    this.baseUrl = new URL(`${this.server}/fmi/data/vLatest/databases/${this.db}`);

    if (this.db === "") {
      throw new Error("Database name is required");
    }
  }

  protected getToken = (_args?: GetTokenArguments): Promise<string> => {
    // method must be implemented in subclass
    return Promise.reject(new Error("getToken method not implemented by Fetch Adapter"));
  };

  protected request = async (params: {
    url: string;
    body?: object | FormData;
    query?: Record<string, string>;
    method?: string;
    retry?: boolean;
    portalRanges?: PortalRanges;
    timeout?: number;
    fetchOptions?: RequestInit;
  }): Promise<unknown> => {
    const { query, body, method = "GET", retry = false, fetchOptions = {} } = params;

    const url = new URL(`${this.baseUrl}${params.url}`);

    if (query) {
      const { _sort, ...rest } = query;
      // Filter out undefined/null values before creating URLSearchParams
      const filteredRest = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined && v !== null));
      const searchParams = new URLSearchParams(filteredRest as Record<string, string>);
      if (query.portalRanges && typeof query.portalRanges === "object") {
        for (const [portalName, value] of Object.entries(query.portalRanges as PortalRanges)) {
          if (value) {
            if (value.offset && value.offset > 0) {
              searchParams.set(`_offset.${portalName}`, value.offset.toString());
            }
            if (value.limit) {
              searchParams.set(`_limit.${portalName}`, value.limit.toString());
            }
          }
        }
      }
      if (_sort) {
        searchParams.set("_sort", JSON.stringify(_sort));
      }
      searchParams.delete("portalRanges");
      url.search = searchParams.toString();
    }

    if (body && "portalRanges" in body) {
      for (const [portalName, value] of Object.entries(body.portalRanges as PortalRanges)) {
        if (value) {
          if (value.offset && value.offset > 0) {
            url.searchParams.set(`_offset.${portalName}`, value.offset.toString());
          }
          if (value.limit) {
            url.searchParams.set(`_limit.${portalName}`, value.limit.toString());
          }
        }
      }
      body.portalRanges = undefined;
    }

    const controller = new AbortController();
    let timeout: NodeJS.Timeout | null = null;
    if (params.timeout) {
      timeout = setTimeout(() => controller.abort(), params.timeout);
    }

    const token = await this.getToken({ refresh: retry });

    const headers = new Headers(fetchOptions?.headers);
    headers.set("Authorization", `Bearer ${token}`);

    // Only set Content-Type for JSON bodies
    if (!(body instanceof FormData)) {
      headers.set("Content-Type", "application/json");
    }

    let requestBody: string | FormData | undefined;
    if (body instanceof FormData) {
      requestBody = body;
    } else if (body) {
      requestBody = JSON.stringify(body);
    } else {
      requestBody = undefined;
    }

    const res = await fetch(url.toString(), {
      ...fetchOptions,
      method,
      body: requestBody,
      headers,
      signal: controller.signal,
    });

    if (timeout) {
      clearTimeout(timeout);
    }

    let respData: RawFMResponse;
    try {
      respData = await res.json();
    } catch {
      respData = {};
    }

    if (!res.ok) {
      if (respData?.messages?.[0].code === "952" && !retry && this.refreshToken) {
        // token expired, get new token and retry once
        return this.request({ ...params, retry: true });
      }
      throw new FileMakerError(
        respData?.messages?.[0].code ?? "500",
        `Filemaker Data API failed with (${res.status}): ${JSON.stringify(respData, null, 2)}`,
      );
    }

    return respData.response;
  };

  list = async (opts: ListOptions): Promise<GetResponse> => {
    const { data, layout } = opts;

    const resp = await this.request({
      url: `/layouts/${layout}/records`,
      query: data as Record<string, string>,
      fetchOptions: opts.fetch,
      timeout: opts.timeout,
    });
    return resp as GetResponse;
  };

  get = async (opts: GetOptions): Promise<GetResponse> => {
    const { data, layout } = opts;
    const resp = await this.request({
      url: `/layouts/${layout}/records/${data.recordId}`,
      fetchOptions: opts.fetch,
      timeout: opts.timeout,
    });
    return resp as GetResponse;
  };

  find = async (opts: FindOptions): Promise<GetResponse> => {
    const { data, layout } = opts;
    const resp = await this.request({
      url: `/layouts/${layout}/_find`,
      body: data,
      method: "POST",
      fetchOptions: opts.fetch,
      timeout: opts.timeout,
    });
    return resp as GetResponse;
  };

  create = async (opts: CreateOptions): Promise<CreateResponse> => {
    const { data, layout } = opts;
    const resp = await this.request({
      url: `/layouts/${layout}/records`,
      body: data,
      method: "POST",
      fetchOptions: opts.fetch,
      timeout: opts.timeout,
    });
    return resp as CreateResponse;
  };

  update = async (opts: UpdateOptions): Promise<UpdateResponse> => {
    const {
      data: { recordId, ...data },
      layout,
    } = opts;
    const resp = await this.request({
      url: `/layouts/${layout}/records/${recordId}`,
      body: data,
      method: "PATCH",
      fetchOptions: opts.fetch,
      timeout: opts.timeout,
    });
    return resp as UpdateResponse;
  };

  delete = async (opts: DeleteOptions): Promise<DeleteResponse> => {
    const { data, layout } = opts;
    const resp = await this.request({
      url: `/layouts/${layout}/records/${data.recordId}`,
      method: "DELETE",
      fetchOptions: opts.fetch,
      timeout: opts.timeout,
    });
    return resp as DeleteResponse;
  };

  layoutMetadata = async (opts: LayoutMetadataOptions): Promise<LayoutMetadataResponse> => {
    return (await this.request({
      url: `/layouts/${opts.layout}`,
      fetchOptions: opts.fetch,
      timeout: opts.timeout,
    })) as LayoutMetadataResponse;
  };

  /**
   * Execute a script within the database
   */
  executeScript = async (opts: ExecuteScriptOptions) => {
    const { script, scriptParam, layout } = opts;
    const resp = await this.request({
      url: `/layouts/${layout}/script/${script}`,
      query: scriptParam ? { "script.param": scriptParam } : undefined,
      fetchOptions: opts.fetch,
      timeout: opts.timeout,
    });
    return resp as ScriptResponse;
  };

  /**
   * Returns a list of available layouts on the database.
   */
  layouts = async (opts?: Omit<BaseRequest, "layout">) => {
    return (await this.request({
      url: "/layouts",
      fetchOptions: opts?.fetch,
      timeout: opts?.timeout,
    })) as AllLayoutsMetadataResponse;
  };

  /**
   * Returns a list of available scripts on the database.
   */
  scripts = async (opts?: Omit<BaseRequest, "layout">) => {
    return (await this.request({
      url: "/scripts",
      fetchOptions: opts?.fetch,
      timeout: opts?.timeout,
    })) as ScriptsMetadataResponse;
  };

  containerUpload = async (opts: ContainerUploadOptions) => {
    let url = `/layouts/${opts.layout}/records/${opts.data.recordId}/containers/${opts.data.containerFieldName}`;
    if (opts.data.repetition) {
      url += `/${opts.data.repetition}`;
    }
    const formData = new FormData();
    formData.append("upload", opts.data.file);

    await this.request({
      url,
      method: "POST",
      body: formData,
      timeout: opts.timeout,
      fetchOptions: opts.fetch,
    });
  };

  /**
   * Set global fields for the current session
   */
  globals = async (
    opts: Omit<BaseRequest, "layout"> & {
      globalFields: Record<string, string | number>;
    },
  ) => {
    return (await this.request({
      url: "/globals",
      method: "PATCH",
      body: { globalFields: opts.globalFields },
      fetchOptions: opts?.fetch,
      timeout: opts?.timeout,
    })) as Record<string, never>;
  };
}
