import {
  DynamicKeys,
  Scope,
  ValidationExceptionInterface,
  ValidationProblemFromMap,
  ViolationFromMap,
} from "../../types";

export type body = Record<string, boolean | string | number | null>;
export type fetchOption = Record<string, boolean | string | number | null>;
export type Primitive = string | number | boolean | null;
export type Params = Record<string, number | string>;

export interface Pagination {
  first: number;
  items_per_page: number;
  more: boolean;
  next: string | null;
  previous: string | null;
}

export interface PaginationResponse<T> {
  pagination: Pagination;
  results: T[];
  count: number;
}

export type FetchParams = Record<string, string | number | null>;

export interface TransportExceptionInterface {
  name: string;
}

export class ValidationException<
  M extends Record<string, Record<string, string | number>> = Record<
    string,
    Record<string, string | number>
  >,
>
  extends Error
  implements ValidationExceptionInterface<M>
{
  public readonly name = "ValidationException" as const;
  public readonly problems: ValidationProblemFromMap<M>;
  public readonly violations: string[];
  public readonly violationsList: ViolationFromMap<M>[];
  public readonly titles: string[];
  public readonly propertyPaths: DynamicKeys<M> & string[];
  public readonly byProperty: Record<Extract<keyof M, string>, string[]>;

  constructor(problem: ValidationProblemFromMap<M>) {
    const message = [problem.title, problem.detail].filter(Boolean).join(" — ");
    super(message);
    Object.setPrototypeOf(this, new.target.prototype);

    this.problems = problem;

    this.violationsList = problem.violations;
    this.violations = problem.violations.map(
      (v) => `${v.title}: ${v.propertyPath}`,
    );

    this.titles = problem.violations.map((v) => v.title);

    this.propertyPaths = problem.violations.map(
      (v) => v.propertyPath,
    ) as DynamicKeys<M> & string[];

    this.byProperty = problem.violations.reduce(
      (acc, v) => {
        const key = v.propertyPath.replace("/\[\d+\]$/", "") as Extract<
          keyof M,
          string
        >;
        (acc[key] ||= []).push(v.title);
        return acc;
      },
      {} as Record<Extract<keyof M, string>, string[]>,
    );

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, ValidationException);
    }
  }

  violationsByNormalizedProperty(
    property: Extract<keyof M, string>,
  ): ViolationFromMap<M>[] {
    return this.violationsList.filter(
      (v) => v.propertyPath.replace(/\[\d+\]$/, "") === property,
    );
  }

  violationsByNormalizedPropertyAndParams<
    P extends Extract<keyof M, string>,
    K extends Extract<keyof M[P], string>,
  >(property: P, param: K, param_value: M[P][K]): ViolationFromMap<M>[] {
    const list = this.violationsByNormalizedProperty(property);

    return list.filter(
      (v): boolean =>
        !!v.parameters &&
        // `with_parameter in v.parameters` check indexing
        param in v.parameters &&
        // the cast is safe, because we have overloading that bind the types
        (v.parameters as M[P])[param] === param_value,
    );
  }
}

/**
 * Check that the exception is a ValidationExceptionInterface
 * @param x
 */
export function isValidationException<
  M extends Record<string, Record<string, string | number>>,
>(x: unknown): x is ValidationExceptionInterface<M> {
  return (
    x instanceof ValidationException ||
    (typeof x === "object" &&
      x !== null &&
      (x as any).name === "ValidationException")
  );
}

export function isValidationProblem(x: unknown): x is {
  type: string;
  title: string;
  violations: { propertyPath: string; title: string }[];
} {
  if (!x || typeof x !== "object") return false;
  const o = x as any;
  return (
    typeof o.type === "string" &&
    typeof o.title === "string" &&
    Array.isArray(o.violations) &&
    o.violations.every(
      (v: any) =>
        v &&
        typeof v === "object" &&
        typeof v.propertyPath === "string" &&
        typeof v.title === "string",
    )
  );
}

export interface AccessExceptionInterface extends TransportExceptionInterface {
  name: "AccessException";
  violations: string[];
}

export interface NotFoundExceptionInterface extends TransportExceptionInterface {
  name: "NotFoundException";
}

export interface ServerExceptionInterface extends TransportExceptionInterface {
  name: "ServerException";
  message: string;
  code: number;
  body: string;
}

export interface ConflictHttpExceptionInterface extends TransportExceptionInterface {
  name: "ConflictHttpException";
  violations: string[];
}

/**
 * Generic api method that can be adapted to any fetch request.
 *
 * What this does
 * - Performs a single HTTP request using fetch and returns the parsed JSON as Output.
 * - Interprets common API errors and throws typed exceptions you can catch in your UI.
 * - When the server returns a Symfony validation problem (HTTP 422), the error is
 *   rethrown as a typed ValidationException that is aware of your Violation Map (see below).
 *
 * Important: For GET endpoints that return lists, prefer using fetchResults, which
 * handles pagination and aggregation for you.
 *
 * Violation Map (M): make your 422 errors strongly typed
 * ------------------------------------------------------
 * Symfony’s validation problem+json payload looks like this (simplified):
 *
 *   {
 *     "type": "https://symfony.com/errors/validation",
 *     "title": "Validation Failed",
 *     "violations": [
 *       {
 *         "propertyPath": "mobilenumber",
 *         "title": "This value is not a valid phone number.",
 *         "parameters": {
 *           "{{ value }}": "+33 1 02 03 04 05",
 *           "{{ types }}": "mobile number"
 *         },
 *         "type": "urn:uuid:..."
 *       }
 *     ]
 *   }
 *
 * The makeFetch generic type parameter M lets you describe, field by field, which
 * parameters may appear for each propertyPath. Doing so gives you full type-safety when
 * consuming ValidationException in your UI code.
 *
 * How to build M (Violation Map)
 * - M is a map where each key is a server-side propertyPath (string), and the value is a
 *   record describing the allowed keys in the parameters object for that property.
 * - Keys in parameters are the exact strings you receive from Symfony, including the
 *   curly-braced placeholders such as "{{ value }}", "{{ types }}", etc.
 *
 * Example from Person creation (WritePersonViolationMap)
 * -----------------------------------------------------
 * In ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts you’ll find:
 *
 *   export type WritePersonViolationMap = {
 *     gender: {
 *       "{{ value }}": string | null;
 *     };
 *     mobilenumber: {
 *       "{{ types }}": string;          // ex: "mobile number"
 *       "{{ value }}": string;          // ex: "+33 1 02 03 04 05"
 *     };
 *   };
 *
 * This means:
 * - If the server reports a violation for propertyPath "gender", the parameters object
 *   is expected to contain a key "{{ value }}" with a string or null value.
 * - If the server reports a violation for propertyPath "mobilenumber", the parameters
 *   may include "{{ value }}" and "{{ types }}" as strings.
 *
 * How makeFetch uses M
 * - When the response has status 422 and the payload matches a Symfony validation
 *   problem, makeFetch casts it to ValidationProblemFromMap<M> and throws a
 *   ValidationException<M>.
 * - The ValidationException exposes helpful, pre-computed fields:
 *   - exception.problem: the full typed payload
 *   - exception.violations: ["Title: propertyPath", ...]
 *   - exception.titles: ["Title 1", "Title 2", ...]
 *   - exception.propertyPaths: ["gender", "mobilenumber", ...] (typed from M)
 *   - exception.byProperty: { gender: [titles...], mobilenumber: [titles...] }
 *
 * Typical usage patterns
 * ----------------------
 * 1) GET without Validation Map (no 422 expected):
 *
 *   const centers = await makeFetch<null, { showCenters: boolean; centers: Center[] }>(
 *     "GET",
 *     "/api/1.0/person/creation/authorized-centers",
 *     null
 *   );
 *
 * 2) POST with body and Violation Map:
 *
 *   type WritePersonViolationMap = {
 *     gender: { "{{ value }}": string | null };
 *     mobilenumber: { "{{ types }}": string; "{{ value }}": string };
 *   };
 *
 *   try {
 *     const created = await makeFetch<PersonWrite, Person, WritePersonViolationMap>(
 *       "POST",
 *       "/api/1.0/person/person.json",
 *       personPayload
 *     );
 *     // Success path
 *   } catch (e) {
 *     if (isValidationException(e)) {
 *       // Fully typed:
 *       e.propertyPaths.includes("mobilenumber");
 *       const firstTitleForMobile = e.byProperty.mobilenumber?.[0];
 *       // You can also inspect parameter values:
 *       const v = e.problem.violations.find(v => v.propertyPath === "mobilenumber");
 *       const rawValue = v?.parameters?.["{{ value }}"]; // typed as string
 *     } else {
 *       // Other error handling (AccessException, ConflictHttpException, etc.)
 *     }
 *   }
 *
 * Tips to design your Violation Map
 * - Use exact propertyPath strings as exposed by the API (they usually match your
 *   DTO field names or entity property paths used by the validator).
 * - Inside each property, list only the placeholders that you actually read in the UI
 *   (you can always add more later). This keeps your types strict but pragmatic.
 * - If a field may not include parameters at all, you can set it to an empty object {}.
 * - If you don’t care about parameter typing, you can omit M entirely and rely on the
 *   default loose typing (Record<string, Primitive>), but you’ll lose safety.
 *
 * Error taxonomy thrown by makeFetch
 * - ValidationException<M> when status = 422 and payload is a validation problem.
 * - AccessException when status = 403.
 * - ConflictHttpException when status = 409.
 * - A generic error object for other non-ok statuses.
 *
 * @typeParam Input  - Shape of the request body you send (if any)
 * @typeParam Output - Shape of the successful JSON response you expect
 * @typeParam M      - Violation Map describing the per-field parameters you expect
 *                     in Symfony validation violations. See examples above.
 *
 * @param method  The HTTP method to use (POST, GET, PUT, PATCH, DELETE)
 * @param url     The absolute or relative URL to call
 * @param body    The request payload. If null/undefined, no body is sent
 * @param options Extra fetch options/headers merged into the request
 *
 * @returns The parsed JSON response typed as Output. For 204 No Content, resolves
 *          with undefined (void).
 */
export const makeFetch = async <
  Input,
  Output,
  M extends Record<string, Record<string, string | number>> = Record<
    string,
    Record<string, string | number>
  >,
>(
  method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE",
  url: string,
  body?: body | Input | null,
  options?: FetchParams,
): Promise<Output> => {
  let opts = {
    method: method,
    headers: {
      "Content-Type": "application/json;charset=utf-8",
    },
  };

  if (body !== null && typeof body !== "undefined") {
    Object.assign(opts, { body: JSON.stringify(body) });
  }

  if (typeof options !== "undefined") {
    opts = Object.assign(opts, options);
  }

  return fetch(url, opts).then(async (response) => {
    if (response.status === 204) {
      return Promise.resolve();
    }

    if (response.ok) {
      return response.json();
    }

    if (response.status === 422) {
      // Unprocessable Entity -> payload de validation Symfony
      const json = await response.json().catch(() => undefined);

      if (isValidationProblem(json)) {
        // On ré-interprète le payload selon M (ParamMap) pour typer les violations
        const problem = json as unknown as ValidationProblemFromMap<M>;
        throw new ValidationException<M>(problem);
      }

      const err = new Error(
        "Validation failed but payload is not a ValidationProblem",
      );
      (err as any).raw = json;
      throw err;
    }

    if (response.status === 403) {
      throw AccessException(response);
    }

    if (response.status === 409) {
      throw ConflictHttpException(response);
    }

    throw {
      name: "Exception",
      sta: response.status,
      txt: response.statusText,
      err: new Error(),
      violations: response.body,
    };
  });
};

/**
 * Fetch results with certain parameters
 */
function _fetchAction<T>(
  page: number,
  uri: string,
  params?: FetchParams,
): Promise<PaginationResponse<T>> {
  const item_per_page = 50;

  const searchParams = new URLSearchParams();
  searchParams.append("item_per_page", item_per_page.toString());
  searchParams.append("page", page.toString());

  if (params !== undefined) {
    Object.keys(params).forEach((key) => {
      const v = params[key];
      if (typeof v === "string") {
        searchParams.append(key, v);
      } else if (typeof v === "number") {
        searchParams.append(key, v.toString());
      } else if (v === null) {
        searchParams.append(key, "");
      }
    });
  }

  const url = uri + "?" + searchParams.toString();

  return fetch(url, {
    method: "GET",
    headers: {
      "Content-Type": "application/json;charset=utf-8",
    },
  })
    .then((response) => {
      if (response.ok) {
        return response.json();
      }

      if (response.status === 404) {
        throw NotFoundException(response);
      }

      if (response.status === 403) {
        throw AccessException(response);
      }

      if (response.status >= 500) {
        return response.text().then((body) => {
          throw ServerException(response.status, body);
        });
      }

      throw new Error("other network error");
    })
    .catch(
      (
        reason:
          | NotFoundExceptionInterface
          | ServerExceptionInterface
          | ValidationExceptionInterface
          | TransportExceptionInterface,
      ) => {
        console.error(reason);
        throw reason;
      },
    );
}

export const fetchResults = async <T>(
  uri: string,
  params?: FetchParams,
): Promise<T[]> => {
  const promises: Promise<T[]>[] = [];
  let page = 1;
  const firstData: PaginationResponse<T> = (await _fetchAction(
    page,
    uri,
    params,
  )) as PaginationResponse<T>;

  promises.push(Promise.resolve(firstData.results));

  if (firstData.pagination.more) {
    do {
      page = ++page;
      promises.push(
        _fetchAction<T>(page, uri, params).then((r) =>
          Promise.resolve(r.results),
        ),
      );
    } while (page * firstData.pagination.items_per_page < firstData.count);
  }

  return Promise.all(promises).then((values) => values.flat());
};

export const fetchScopes = (): Promise<Scope[]> => {
  return fetchResults("/api/1.0/main/scope.json");
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const AccessException = (response: Response): AccessExceptionInterface => {
  const error = {} as AccessExceptionInterface;
  error.name = "AccessException";
  error.violations = ["You are not allowed to perform this action"];

  return error;
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const NotFoundException = (response: Response): NotFoundExceptionInterface => {
  const error = {} as NotFoundExceptionInterface;
  error.name = "NotFoundException";

  return error;
};

const ServerException = (
  code: number,
  body: string,
): ServerExceptionInterface => {
  const error = {} as ServerExceptionInterface;
  error.name = "ServerException";
  error.code = code;
  error.body = body;

  return error;
};

const ConflictHttpException = (
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  response: Response,
): ConflictHttpExceptionInterface => {
  const error = {} as ConflictHttpExceptionInterface;

  error.name = "ConflictHttpException";
  error.violations = [
    "Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again",
  ];

  return error;
};
