/**
 * eTerra ANCPI client — server-side only.
 *
 * Authenticates via form-post, keeps a JSESSIONID cookie jar,
 * and exposes typed wrappers for count / list / fetchAll / parcel-detail
 * queries against the eTerra ArcGIS-REST API.
 *
 * Ported from the standalone ParcelSync project and adapted for
 * ArchiTools module architecture.
 */

import axios, { type AxiosError, type AxiosInstance } from "axios";
import crypto from "crypto";
import { wrapper } from "axios-cookiejar-support";
import { CookieJar } from "tough-cookie";

/* ------------------------------------------------------------------ */
/*  Types                                                              */
/* ------------------------------------------------------------------ */

export type EsriGeometry = { rings: number[][][] };

export type EsriFeature = {
  attributes: Record<string, unknown>;
  geometry?: EsriGeometry;
};

export type LayerEndpoint = "aut" | "all";

export type LayerConfig = {
  id: string;
  name: string;
  endpoint: LayerEndpoint;
  subLayerId?: number;
  whereTemplate?: string;
  spatialFilter?: boolean;
};

type EsriQueryResponse = {
  error?: { message?: string; details?: string[] };
  count?: number;
  objectIds?: number[];
  objectIdFieldName?: string;
  features?: EsriFeature[];
  exceededTransferLimit?: boolean;
};

type EsriLayerInfo = {
  fields?: { name: string }[];
  error?: { message?: string; details?: string[] };
};

type ProgressCallback = (downloaded: number, total?: number) => void;

/* ------------------------------------------------------------------ */
/*  Constants                                                          */
/* ------------------------------------------------------------------ */

const BASE_URL = "https://eterra.ancpi.ro/eterra";
const LOGIN_URL = `${BASE_URL}/api/authentication`;

const DEFAULT_TIMEOUT_MS = 120_000;
const DEFAULT_PAGE_SIZE = 1000;
const PAGE_SIZE_FALLBACKS = [500, 200];
const MAX_RETRIES = 2;
const SESSION_TTL_MS = 9 * 60 * 1000;
const MAX_URL_LENGTH = 1500;

/* ------------------------------------------------------------------ */
/*  Session cache (global singleton)                                   */
/* ------------------------------------------------------------------ */

type SessionEntry = {
  jar: CookieJar;
  client: AxiosInstance;
  createdAt: number;
  lastUsed: number;
};

const globalStore = globalThis as {
  __eterraClientCache?: Map<string, SessionEntry>;
  __eterraClientCleanupTimer?: ReturnType<typeof setInterval>;
};
const sessionStore =
  globalStore.__eterraClientCache ?? new Map<string, SessionEntry>();
globalStore.__eterraClientCache = sessionStore;

// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
if (!globalStore.__eterraClientCleanupTimer) {
  globalStore.__eterraClientCleanupTimer = setInterval(() => {
    const now = Date.now();
    for (const [key, entry] of sessionStore.entries()) {
      if (now - entry.lastUsed > 9 * 60_000) {
        sessionStore.delete(key);
      }
    }
  }, 5 * 60_000);
}

const makeCacheKey = (u: string, p: string) =>
  crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");

/* ------------------------------------------------------------------ */
/*  Helpers                                                            */
/* ------------------------------------------------------------------ */

const isTransient = (error: unknown) => {
  const err = error as AxiosError;
  if (!err) return false;
  if (
    err.code === "ECONNRESET" ||
    err.code === "ETIMEDOUT" ||
    err.code === "ECONNABORTED"
  )
    return true;
  const status = err.response?.status ?? 0;
  return status >= 500;
};

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

/* ------------------------------------------------------------------ */
/*  Client                                                             */
/* ------------------------------------------------------------------ */

export class EterraClient {
  private client: AxiosInstance;
  private jar: CookieJar;
  private timeoutMs: number;
  private maxRetries: number;
  private username: string;
  private password: string;
  private cacheKey: string;
  private layerFieldsCache = new Map<string, string[]>();

  private constructor(
    client: AxiosInstance,
    jar: CookieJar,
    timeoutMs: number,
    username: string,
    password: string,
    maxRetries: number,
  ) {
    this.client = client;
    this.jar = jar;
    this.timeoutMs = timeoutMs;
    this.username = username;
    this.password = password;
    this.maxRetries = maxRetries;
    this.cacheKey = makeCacheKey(username, password);
  }

  /* ---- Factory --------------------------------------------------- */

  static async create(
    username: string,
    password: string,
    options?: { timeoutMs?: number; maxRetries?: number; useCache?: boolean },
  ) {
    const useCache = options?.useCache !== false;
    const now = Date.now();
    const cacheKey = makeCacheKey(username, password);
    const cached = sessionStore.get(cacheKey);

    if (useCache && cached && now - cached.lastUsed < SESSION_TTL_MS) {
      cached.lastUsed = now;
      return new EterraClient(
        cached.client,
        cached.jar,
        options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
        username,
        password,
        options?.maxRetries ?? MAX_RETRIES,
      );
    }

    const jar = new CookieJar();
    const client = wrapper(
      axios.create({
        jar,
        withCredentials: true,
        maxContentLength: Infinity,
        maxBodyLength: Infinity,
        headers: {
          Accept: "application/json, text/plain, */*",
          "User-Agent":
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
          Referer: `${BASE_URL}/`,
        },
      }),
    );

    const instance = new EterraClient(
      client,
      jar,
      options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
      username,
      password,
      options?.maxRetries ?? MAX_RETRIES,
    );
    await instance.login(username, password);
    sessionStore.set(cacheKey, { jar, client, createdAt: now, lastUsed: now });
    return instance;
  }

  /* ---- Auth ------------------------------------------------------ */

  private async login(u: string, p: string) {
    const body = new URLSearchParams();
    body.set("j_username", u);
    body.set("j_password", p);
    body.set("j_uuid", "undefined");
    body.set("j_isRevoked", "undefined");
    body.set("_spring_security_remember_me", "true");
    body.set("submit", "Login");

    try {
      await this.requestWithRetry(() =>
        this.client.post(LOGIN_URL, body.toString(), {
          headers: { "Content-Type": "application/x-www-form-urlencoded" },
          timeout: this.timeoutMs,
        }),
      );
    } catch (error) {
      const err = error as AxiosError;
      if (err?.response?.status === 401)
        throw new Error("Login failed (invalid credentials)");
      throw error;
    }

    const cookies = await this.jar.getCookies(LOGIN_URL);
    if (!cookies.some((c) => c.key === "JSESSIONID"))
      throw new Error("Login failed / session not set");

    // Activate RGI module context by loading the RGI page
    // This may set additional session attributes needed for document downloads
    try {
      await this.client.get(`${BASE_URL}/`, { timeout: 10_000 });
    } catch {
      // Non-critical
    }
  }

  /* ---- High-level parcels --------------------------------------- */

  async countParcels(siruta: string) {
    return this.countLayer(
      {
        id: "TERENURI_ACTIVE",
        name: "TERENURI_ACTIVE",
        endpoint: "aut",
        whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
      },
      siruta,
    );
  }

  async fetchAllParcels(
    siruta: string,
    options?: {
      pageSize?: number;
      total?: number;
      onProgress?: ProgressCallback;
      delayMs?: number;
    },
  ) {
    return this.fetchAllLayer(
      {
        id: "TERENURI_ACTIVE",
        name: "TERENURI_ACTIVE",
        endpoint: "aut",
        whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
      },
      siruta,
      options,
    );
  }

  /* ---- Generic layer methods ------------------------------------ */

  async countLayer(layer: LayerConfig, siruta: string) {
    const where = await this.buildWhere(layer, siruta);
    return this.countLayerByWhere(layer, where);
  }

  async countLayerByWhere(layer: LayerConfig, where: string) {
    const params = new URLSearchParams();
    params.set("f", "json");
    params.set("where", where);
    return this.countLayerWithParams(layer, params, false);
  }

  async countLayerByGeometry(layer: LayerConfig, geometry: EsriGeometry) {
    const params = new URLSearchParams();
    params.set("f", "json");
    params.set("where", "1=1");
    this.applyGeometryParams(params, geometry);
    return this.countLayerWithParams(layer, params, true);
  }

  /* ---- Incremental sync: fetch only OBJECTIDs -------------------- */

  async fetchObjectIds(layer: LayerConfig, siruta: string): Promise<number[]> {
    const where = await this.buildWhere(layer, siruta);
    return this.fetchObjectIdsByWhere(layer, where);
  }

  async fetchObjectIdsByWhere(
    layer: LayerConfig,
    where: string,
  ): Promise<number[]> {
    const params = new URLSearchParams();
    params.set("f", "json");
    params.set("where", where);
    params.set("returnIdsOnly", "true");
    const data = await this.queryLayer(layer, params, false);
    return data.objectIds ?? [];
  }

  async fetchObjectIdsByGeometry(
    layer: LayerConfig,
    geometry: EsriGeometry,
  ): Promise<number[]> {
    const params = new URLSearchParams();
    params.set("f", "json");
    params.set("where", "1=1");
    params.set("returnIdsOnly", "true");
    this.applyGeometryParams(params, geometry);
    const data = await this.queryLayer(layer, params, true);
    return data.objectIds ?? [];
  }

  /* ---- Fetch specific features by OBJECTID list ------------------- */

  async fetchFeaturesByObjectIds(
    layer: LayerConfig,
    objectIds: number[],
    options?: {
      baseWhere?: string;
      outFields?: string;
      returnGeometry?: boolean;
      onProgress?: ProgressCallback;
      delayMs?: number;
    },
  ): Promise<EsriFeature[]> {
    if (objectIds.length === 0) return [];
    const chunkSize = 500;
    const all: EsriFeature[] = [];
    const total = objectIds.length;
    for (let i = 0; i < objectIds.length; i += chunkSize) {
      const chunk = objectIds.slice(i, i + chunkSize);
      const idList = chunk.join(",");
      const idWhere = `OBJECTID IN (${idList})`;
      const where = options?.baseWhere
        ? `(${options.baseWhere}) AND ${idWhere}`
        : idWhere;
      try {
        const features = await this.fetchAllLayerByWhere(layer, where, {
          outFields: options?.outFields ?? "*",
          returnGeometry: options?.returnGeometry ?? true,
          delayMs: options?.delayMs ?? 200,
        });
        all.push(...features);
      } catch (err) {
        // Log but continue with remaining chunks — partial results better than none
        const msg = err instanceof Error ? err.message : String(err);
        console.warn(
          `[fetchFeaturesByObjectIds] Chunk ${Math.floor(i / chunkSize) + 1} failed (${chunk.length} IDs): ${msg}`,
        );
      }
      options?.onProgress?.(all.length, total);
    }
    return all;
  }

  async listLayer(
    layer: LayerConfig,
    siruta: string,
    options?: { limit?: number; outFields?: string },
  ) {
    const where = await this.buildWhere(layer, siruta);
    return this.listLayerByWhere(layer, where, options);
  }

  async listLayerByWhere(
    layer: LayerConfig,
    where: string,
    options?: { limit?: number; outFields?: string },
  ) {
    const params = new URLSearchParams();
    params.set("f", "json");
    params.set("where", where);
    params.set("outFields", options?.outFields ?? "*");
    params.set("returnGeometry", "false");
    params.set("resultRecordCount", String(options?.limit ?? 200));
    params.set("resultOffset", "0");
    const data = await this.queryLayer(layer, params, false);
    return data.features ?? [];
  }

  async fetchAllLayer(
    layer: LayerConfig,
    siruta: string,
    options?: {
      pageSize?: number;
      total?: number;
      onProgress?: ProgressCallback;
      delayMs?: number;
      outFields?: string;
      returnGeometry?: boolean;
    },
  ) {
    const where = await this.buildWhere(layer, siruta);
    return this.fetchAllLayerByWhere(layer, where, options);
  }

  async fetchAllLayerByWhere(
    layer: LayerConfig,
    where: string,
    options?: {
      pageSize?: number;
      total?: number;
      onProgress?: ProgressCallback;
      delayMs?: number;
      outFields?: string;
      returnGeometry?: boolean;
      geometry?: EsriGeometry;
    },
  ) {
    let pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE;
    const total = options?.total;
    const onProgress = options?.onProgress;
    const delayMs = options?.delayMs ?? 0;
    const outFields = options?.outFields ?? "*";
    const returnGeometry = options?.returnGeometry ?? true;
    let offset = 0;
    const all: EsriFeature[] = [];

    // ArcGIS servers have a maxRecordCount (typically 1000).
    // If we request 2000 but get exactly 1000, we hit the server cap.
    // Track this so we continue paginating instead of stopping.
    let serverMaxRecordCount: number | null = null;
    let retried = false; // one retry per page for transient ArcGIS errors

    while (true) {
      const params = new URLSearchParams();
      params.set("f", "json");
      params.set("where", where);
      if (options?.geometry) this.applyGeometryParams(params, options.geometry);
      params.set("outFields", outFields);
      params.set("returnGeometry", returnGeometry ? "true" : "false");
      if (returnGeometry) params.set("outSR", "3844");
      params.set("resultRecordCount", String(pageSize));
      params.set("resultOffset", String(offset));

      let data: EsriQueryResponse;
      try {
        data = await this.queryLayer(layer, params, Boolean(options?.geometry));
      } catch (err) {
        const cause = err instanceof Error ? err.message : String(err);
        const isQueryError = cause.includes("Error performing query");

        // ArcGIS "Error performing query" — retry same page size first
        // (often a transient server-side timeout), then try smaller.
        if (isQueryError && !retried) {
          retried = true;
          await sleep(2000);
          continue;
        }
        retried = false;

        // Try next smaller page size
        const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize);
        if (nextSize) {
          pageSize = nextSize;
          await sleep(1000);
          continue;
        }
        throw new Error(`Failed to fetch layer ${layer.name}: ${cause}`);
      }
      retried = false; // reset on success

      const features = data.features ?? [];
      if (features.length === 0) {
        const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize);
        if (total && all.length < total && nextSize) {
          pageSize = nextSize;
          continue;
        }
        break;
      }

      // Detect server maxRecordCount cap:
      // If we asked for more than we got AND the result is a round number
      // (1000, 2000), the server likely capped us. Adjust pageSize to match.
      if (
        serverMaxRecordCount === null &&
        features.length < pageSize &&
        features.length > 0 &&
        features.length % 500 === 0 // round cap: 500, 1000, 1500, 2000...
      ) {
        serverMaxRecordCount = features.length;
        pageSize = serverMaxRecordCount;
        // Don't break — this is a full page at server's cap, continue
      }

      all.push(...features);
      offset += features.length;
      if (onProgress) onProgress(all.length, total);
      if (total && all.length >= total) break;

      // End of data: fewer features than the effective page size
      const effectivePageSize = serverMaxRecordCount ?? pageSize;
      if (features.length < effectivePageSize) {
        const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize);
        if (total && all.length < total && nextSize) {
          pageSize = nextSize;
          continue;
        }
        break;
      }

      if (delayMs > 0) await sleep(delayMs);
    }

    return all;
  }

  async fetchAllLayerByGeometry(
    layer: LayerConfig,
    geometry: EsriGeometry,
    options?: {
      pageSize?: number;
      total?: number;
      onProgress?: ProgressCallback;
      delayMs?: number;
      outFields?: string;
      returnGeometry?: boolean;
    },
  ) {
    return this.fetchAllLayerByWhere(layer, "1=1", { ...options, geometry });
  }

  async getLayerFieldNames(layer: LayerConfig) {
    return this.getLayerFields(layer);
  }

  /* ---- Magic-mode methods (eTerra application APIs) ------------- */

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetchImmAppsByImmovable(
    immovableId: string | number,
    workspaceId: string | number,
  ): Promise<any[]> {
    const url = `${BASE_URL}/api/immApps/byImm/list/${immovableId}/${workspaceId}`;
    return this.getRawJson(url);
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetchParcelFolosinte(
    workspaceId: string | number,
    immovableId: string | number,
    applicationId: string | number,
    page = 1,
  ): Promise<any[]> {
    const url = `${BASE_URL}/api/immApps/parcels/list/${workspaceId}/${immovableId}/${applicationId}/${page}`;
    return this.getRawJson(url);
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetchImmovableListByAdminUnit(
    workspaceId: string | number,
    adminUnitId: string | number,
    page = 0,
    size = 200,
    includeInscrisCF = true,
  ): Promise<any> {
    const url = `${BASE_URL}/api/immovable/list`;
    const filters: Array<{
      value: string | number;
      type: "NUMBER" | "STRING";
      key: string;
      op: string;
    }> = [
      {
        value: Number(workspaceId),
        type: "NUMBER",
        key: "workspace.nomenPk",
        op: "=",
      },
      {
        value: Number(adminUnitId),
        type: "NUMBER",
        key: "adminUnit.nomenPk",
        op: "=",
      },
      { value: "C", type: "STRING", key: "immovableType", op: "<>C" },
    ];
    if (includeInscrisCF) {
      filters.push({ value: -1, type: "NUMBER", key: "inscrisCF", op: "=" });
    }
    const payload = { filters, nrElements: size, page, sorters: [] };
    return this.requestRaw(() =>
      this.client.post(url, payload, {
        headers: { "Content-Type": "application/json;charset=UTF-8" },
        timeout: this.timeoutMs,
      }),
    );
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetchDocumentationData(
    workspaceId: string | number,
    immovableIds: Array<string | number>,
  ): Promise<any> {
    const url = `${BASE_URL}/api/documentation/data/`;
    const payload = {
      workflowCode: "EXPLORE_DATABASE",
      activityCode: "EXPLORE",
      applicationId: 0,
      workspaceId: Number(workspaceId),
      immovables: immovableIds.map((id) => Number(id)).filter(Number.isFinite),
    };
    return this.requestRaw(() =>
      this.client.post(url, payload, {
        headers: { "Content-Type": "application/json;charset=UTF-8" },
        timeout: this.timeoutMs,
      }),
    );
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetchImmovableParcelDetails(
    workspaceId: string | number,
    immovableId: string | number,
    page = 1,
    size = 20,
  ): Promise<any[]> {
    const url = `${BASE_URL}/api/immovable/details/parcels/list/${workspaceId}/${immovableId}/${page}/${size}`;
    return this.getRawJson(url);
  }

  /**
   * Check if an immovable is an "Imobil Electronic" (IE) in eTerra.
   * Uses the same endpoint the eTerra UI calls when searching by
   * topNo + paperCfNo.
   *
   * @returns true if the immovable is registered as IE, false otherwise
   */
  async checkIfIsIE(
    adminUnitId: string | number,
    paperCadNo: number | null,
    topNo: string | number,
    paperCfNo: string | number,
  ): Promise<boolean> {
    const url = `${BASE_URL}/api/immovable/checkIfIsIE`;
    const payload = {
      adminUnitId: Number(adminUnitId),
      paperCadNo: Number(paperCadNo ?? 0),
      topNo:
        typeof topNo === "string"
          ? Number(topNo.split(",")[0]) || 0
          : Number(topNo),
      paperCfNo: Number(paperCfNo),
    };
    try {
      const result = await this.requestRaw<boolean | number | string>(() =>
        this.client.post(url, payload, {
          headers: { "Content-Type": "application/json;charset=UTF-8" },
          timeout: this.timeoutMs,
        }),
      );
      // API may return boolean, number (1/0), or string
      return result === true || result === 1 || String(result) === "true";
    } catch {
      return false;
    }
  }

  /**
   * Build the URL for downloading a CF (carte funciară) extract PDF.
   * The eTerra UI calls this to get the landbook/CF PDF blob.
   *
   * @returns URL string (caller needs an authenticated session to fetch)
   */
  getCfExtractUrl(
    immovablePk: string | number,
    workspaceId: string | number,
  ): string {
    return `${BASE_URL}/api/cf/landbook/copycf/get/${immovablePk}/${workspaceId}/0/true`;
  }

  /**
   * Search immovable list by exact cadastral number (identifierDetails).
   * This is the eTerra application API that the web UI uses when you type
   * a cadastral number in the search box.
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async searchImmovableByIdentifier(
    workspaceId: string | number,
    adminUnitId: string | number,
    identifierDetails: string,
    page = 0,
    size = 10,
  ): Promise<any> {
    const url = `${BASE_URL}/api/immovable/list`;
    const filters: Array<{
      value: string | number;
      type: "NUMBER" | "STRING";
      key: string;
      op: string;
    }> = [
      {
        value: Number(workspaceId),
        type: "NUMBER",
        key: "workspace.nomenPk",
        op: "=",
      },
      {
        value: Number(adminUnitId),
        type: "NUMBER",
        key: "adminUnit.nomenPk",
        op: "=",
      },
      {
        value: identifierDetails,
        type: "STRING",
        key: "identifierDetails",
        op: "=",
      },
      { value: -1, type: "NUMBER", key: "inscrisCF", op: "=" },
      { value: "P", type: "STRING", key: "immovableType", op: "<>C" },
    ];
    const payload = { filters, nrElements: size, page, sorters: [] };
    return this.requestRaw(() =>
      this.client.post(url, payload, {
        headers: { "Content-Type": "application/json;charset=UTF-8" },
        timeout: this.timeoutMs,
      }),
    );
  }

  /**
   * Search immovable list by owner/titular name.
   *
   * Uses the same `/api/immovable/list` endpoint, with a `personName`
   * filter key. eTerra supports partial matching on this field.
   *
   * Falls back to `titularName` key if personName returns empty.
   *
   * @returns Paginated response with `content[]`, `totalPages`, `totalElements`
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async searchImmovableByOwnerName(
    workspaceId: string | number,
    adminUnitId: string | number,
    ownerName: string,
    page = 0,
    size = 20,
  ): Promise<any> {
    const url = `${BASE_URL}/api/immovable/list`;
    const baseFilters: Array<{
      value: string | number;
      type: "NUMBER" | "STRING";
      key: string;
      op: string;
    }> = [
      {
        value: Number(workspaceId),
        type: "NUMBER",
        key: "workspace.nomenPk",
        op: "=",
      },
      {
        value: Number(adminUnitId),
        type: "NUMBER",
        key: "adminUnit.nomenPk",
        op: "=",
      },
      { value: -1, type: "NUMBER", key: "inscrisCF", op: "=" },
    ];

    // Try primary key: personName (used in some eTerra versions)
    const keysToTry = ["personName", "titularName", "ownerName"];
    for (const filterKey of keysToTry) {
      try {
        const filters = [
          ...baseFilters,
          {
            value: ownerName,
            type: "STRING" as const,
            key: filterKey,
            op: "=",
          },
        ];
        const payload = { filters, nrElements: size, page, sorters: [] };
        const result = await this.requestRaw(() =>
          this.client.post(url, payload, {
            headers: { "Content-Type": "application/json;charset=UTF-8" },
            timeout: this.timeoutMs,
          }),
        );
        // If we got content back (even empty), it means the key is valid
        if (result && typeof result === "object" && "content" in result) {
          console.log(
            `[searchByOwner] Key "${filterKey}" worked — ${(result as any).totalElements ?? 0} results`,
          );
          return result;
        }
      } catch (e) {
        console.log(
          `[searchByOwner] Key "${filterKey}" failed:`,
          e instanceof Error ? e.message : e,
        );
        // Try next key
      }
    }

    // All keys failed — return empty result
    return { content: [], totalElements: 0, totalPages: 0 };
  }

  /**
   * Fetch all counties (workspaces) from eTerra nomenclature.
   * Returns array of { nomenPk, name, parentNomenPk, ... }
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetchCounties(): Promise<any[]> {
    const url = `${BASE_URL}/api/adm/nomen/COUNTY/list`;
    return this.getRawJson(url);
  }

  /**
   * Fetch a single nomenclature entry by nomenPk.
   * Returns { nomenPk, name, parentNomenPk, nomenType, ... } or null
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetchNomenByPk(nomenPk: string | number): Promise<any> {
    const url = `${BASE_URL}/api/adm/nomen/${nomenPk}`;
    try {
      return await this.getRawJson(url);
    } catch {
      return null;
    }
  }

  /**
   * Fetch administrative units (UATs) under a county workspace.
   * Returns array of { nomenPk, name, parentNomenPk, ... }
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async fetchAdminUnitsByCounty(
    countyNomenPk: string | number,
  ): Promise<any[]> {
    const url = `${BASE_URL}/api/adm/nomen/ADMINISTRATIVEUNIT/filterByParent/${countyNomenPk}`;
    return this.getRawJson(url);
  }

  /* ---- Internals ------------------------------------------------ */

  private layerQueryUrl(layer: LayerConfig) {
    const suffix =
      typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : "";
    return `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}/query`;
  }

  private applyGeometryParams(params: URLSearchParams, geometry: EsriGeometry) {
    params.set("geometry", JSON.stringify(geometry));
    params.set("geometryType", "esriGeometryPolygon");
    params.set("spatialRel", "esriSpatialRelIntersects");
    params.set("inSR", "3844");
  }

  private async buildWhere(layer: LayerConfig, siruta: string) {
    const fields = await this.getLayerFields(layer);
    const adminField = this.findAdminField(fields);
    if (!adminField)
      throw new Error(`Layer ${layer.name} has no SIRUTA/ADMIN_UNIT field`);

    if (!layer.whereTemplate) return `${adminField}=${siruta}`;

    const hasIsActive = fields.some((f) => f.toUpperCase() === "IS_ACTIVE");
    if (layer.whereTemplate.includes("IS_ACTIVE") && !hasIsActive)
      return `${adminField}=${siruta}`;

    return layer.whereTemplate
      .replace(/\{\{adminField\}\}/g, adminField)
      .replace(/\{\{siruta\}\}/g, siruta);
  }

  private findAdminField(fields: string[]) {
    const preferred = [
      "ADMIN_UNIT_ID",
      "SIRUTA",
      "UAT_ID",
      "SIRUTA_UAT",
      "UAT_SIRUTA",
    ];
    const upper = fields.map((f) => f.toUpperCase());
    for (const key of preferred) {
      const idx = upper.indexOf(key);
      if (idx >= 0) return fields[idx];
    }
    return null;
  }

  private async getLayerFields(layer: LayerConfig) {
    const cacheKey = `${layer.endpoint}:${layer.name}`;
    const cached = this.layerFieldsCache.get(cacheKey);
    if (cached) return cached;

    const suffix =
      typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : "";
    const url = `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}?f=json`;

    let response;
    try {
      response = await this.requestWithRetry(() =>
        this.client.get(url, { timeout: this.timeoutMs }),
      );
    } catch (error) {
      const err = error as AxiosError;
      if (err?.response?.status === 401) {
        await this.login(this.username, this.password);
        response = await this.requestWithRetry(() =>
          this.client.get(url, { timeout: this.timeoutMs }),
        );
      } else throw error;
    }

    const data = response.data as EsriLayerInfo | string;
    if (typeof data === "string")
      throw new Error(`Layer info invalid for ${layer.name}`);
    if (data.error) {
      const details = data.error.details?.join(" ") ?? "";
      throw new Error(
        `${data.error.message ?? "Layer error"}${details ? ` (${details})` : ""}`,
      );
    }

    let fields = data.fields?.map((f) => f.name) ?? [];
    if (!fields.length) {
      const params = new URLSearchParams();
      params.set("f", "json");
      params.set("where", "1=1");
      params.set("outFields", "*");
      params.set("returnGeometry", "false");
      params.set("resultRecordCount", "1");
      params.set("resultOffset", "0");
      const sample = await this.getJson(
        `${this.layerQueryUrl(layer)}?${params}`,
      );
      fields = Object.keys(sample.features?.[0]?.attributes ?? {});
    }
    if (!fields.length)
      throw new Error(`Layer ${layer.name} returned no fields`);

    this.layerFieldsCache.set(cacheKey, fields);
    return fields;
  }

  private async getJson(url: string): Promise<EsriQueryResponse> {
    return this.requestJson(() =>
      this.client.get(url, { timeout: this.timeoutMs }),
    );
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private async getRawJson<T = any>(url: string): Promise<T> {
    return this.requestRaw(() =>
      this.client.get(url, { timeout: this.timeoutMs }),
    );
  }

  private async postJson(
    url: string,
    body: URLSearchParams,
  ): Promise<EsriQueryResponse> {
    return this.requestJson(() =>
      this.client.post(url, body.toString(), {
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        timeout: this.timeoutMs,
      }),
    );
  }

  /** Touch session TTL in global store (prevents expiry during long pagination) */
  private touchSession(): void {
    const cached = sessionStore.get(this.cacheKey);
    if (cached) cached.lastUsed = Date.now();
  }

  private async requestJson(
    request: () => Promise<{
      data: EsriQueryResponse | string;
      status: number;
    }>,
  ): Promise<EsriQueryResponse> {
    this.touchSession();
    let response;
    try {
      response = await this.requestWithRetry(request);
    } catch (error) {
      const err = error as AxiosError;
      if (err?.response?.status === 401) {
        // Always attempt relogin on 401 (session may expire multiple times during long syncs)
        await this.login(this.username, this.password);
        response = await this.requestWithRetry(request);
      } else throw error;
    }
    const data = response.data as EsriQueryResponse | string;
    if (typeof data === "string")
      throw new Error("Session expired or invalid response from eTerra");
    if (data.error) {
      const details = data.error.details?.join(" ") ?? "";
      throw new Error(
        `${data.error.message ?? "eTerra query error"}${details ? ` (${details})` : ""}`,
      );
    }
    return data;
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private async requestRaw<T = any>(
    request: () => Promise<{ data: T | string; status: number }>,
  ): Promise<T> {
    this.touchSession();
    let response;
    try {
      response = await this.requestWithRetry(request);
    } catch (error) {
      const err = error as AxiosError;
      if (err?.response?.status === 401) {
        await this.login(this.username, this.password);
        response = await this.requestWithRetry(request);
      } else throw error;
    }
    const data = response.data as T | string;
    if (typeof data === "string") {
      try {
        return JSON.parse(data) as T;
      } catch {
        throw new Error("Session expired or invalid response from eTerra");
      }
    }
    return data;
  }

  private async queryLayer(
    layer: LayerConfig,
    params: URLSearchParams,
    usePost: boolean,
  ): Promise<EsriQueryResponse> {
    const url = this.layerQueryUrl(layer);
    const qs = params.toString();
    const shouldPost = usePost || qs.length > MAX_URL_LENGTH;
    if (shouldPost) return this.postJson(url, params);
    try {
      return await this.getJson(`${url}?${qs}`);
    } catch (error) {
      const err = error as AxiosError;
      if (err?.response?.status === 414) return this.postJson(url, params);
      throw error;
    }
  }

  private async countLayerWithParams(
    layer: LayerConfig,
    params: URLSearchParams,
    usePost: boolean,
  ) {
    const cp = new URLSearchParams(params);
    cp.set("returnCountOnly", "true");
    const data = await this.queryLayer(layer, cp, usePost);
    if (typeof data.count === "number") return data.count;
    const message = data.error?.message;
    throw new Error(
      message
        ? `Count unavailable (${layer.name}): ${message}`
        : `Count unavailable (${layer.name})`,
    );
  }

  /* ── RGI (Registrul General de Intrare) API ───────────────── */

  /**
   * Generic RGI POST request with JSON body.
   * Uses the same JSESSIONID cookie jar as GIS queries.
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async rgiPost<T = any>(path: string, body?: unknown): Promise<T> {
    const url = `${BASE_URL}/api/${path}`;
    return this.requestRaw(() =>
      this.client.post(url, body ?? {}, {
        headers: { "Content-Type": "application/json;charset=UTF-8" },
        timeout: this.timeoutMs,
      }),
    );
  }

  /**
   * Generic RGI GET request.
   */
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  async rgiGet<T = any>(path: string): Promise<T> {
    const url = `${BASE_URL}/api/${path}`;
    return this.requestRaw(() =>
      this.client.get(url, { timeout: this.timeoutMs }),
    );
  }

  /**
   * Download a file from RGI (returns Buffer).
   */
  async rgiDownload(path: string): Promise<{ data: Buffer; contentType: string; filename: string }> {
    const url = `${BASE_URL}/api/${path}`;
    const doRequest = () =>
      this.client.get(url, {
        timeout: this.timeoutMs,
        responseType: "arraybuffer",
        // Override Accept header for binary download
        headers: { Accept: "*/*" },
        // Don't throw on any status — we'll check manually
        validateStatus: () => true,
      });

    let response = await doRequest();

    // If 401/302/404 (session expired → redirect to login), re-login and retry
    if (response.status === 401 || response.status === 302 || response.status === 404) {
      await this.login(this.username, this.password);
      response = await doRequest();
    }

    if (response.status !== 200) {
      throw new Error(`Download failed: HTTP ${response.status}`);
    }

    const headers = response.headers as Record<string, string> | undefined;
    const cd = headers?.["content-disposition"] ?? "";
    const match = /filename="?([^"]+)"?/.exec(cd);
    return {
      data: Buffer.from(response.data as ArrayBuffer),
      contentType: headers?.["content-type"] ?? "application/octet-stream",
      filename: match?.[1] ?? "document.pdf",
    };
  }

  private async requestWithRetry<T>(request: () => Promise<T>) {
    let attempt = 0;
    while (true) {
      try {
        return await request();
      } catch (error) {
        if (attempt >= this.maxRetries || !isTransient(error)) throw error;
        attempt += 1;
        await sleep(300 * attempt);
      }
    }
  }
}
