internal/dashboard/frontend/src/store.ts

import { useThrottleFn } from "@vueuse/core";
import { defineStore } from "pinia";
import { isIpInSubnet } from "utils/filters";
import { formatDateTimeForAPI } from "utils/formatting";
import { URL } from "utils/utils";
import { computed, reactive, ref, watch, type Reactive } from "vue";
import type { DashboardStats } from "./types";

// Generic helpers for array state updates
const addToArray = <T>(arr: T[], val: T) => {
  if (!arr.includes(val)) arr.push(val);
};

const removeFromArray = <T>(arr: T[], val: T) => {
  const index = arr.indexOf(val);
  if (index !== -1) arr.splice(index, 1);
};

export interface FilterState {
  limit: number;
  offset: number;
  order_direction: string;
  remote_addr: string[];
  dst_port: string[]; // string is necessary for exclusion (!port)
  time_start: string;
  time_end: string;
  type: string[];
  event: string[];
  asn: string[];
  country: string[];
  city: string[];
  domain: string[];
  fqdn: string[];
  resolve_ips?: boolean;
  expand_details?: boolean;
  columns: string[];
  id?: number;
  x_range?: string[];
  y_range?: string[];
  sync_time_with_chart?: boolean;
  sync_ports_with_chart?: boolean;
  json_fields: Record<string, string[]>;
  lat_range?: string[];
  lon_range?: string[];
}

export const createFilterState = (
  initial?: Partial<FilterState>,
): FilterState => {
  const state: FilterState = {
    limit: 50,
    offset: 0,
    order_direction: "desc",
    remote_addr: [],
    dst_port: [],
    time_start: "",
    time_end: "",
    type: [],
    event: [],
    asn: [],
    country: [],
    city: [],
    domain: [],
    fqdn: [],
    resolve_ips: false,
    expand_details: false,
    columns: [],
    id: 0,
    x_range: [],
    y_range: [],
    sync_time_with_chart: false,
    sync_ports_with_chart: false,
    json_fields: {},
    lat_range: [],
    lon_range: [],
  };

  if (initial) {
    Object.entries(initial).forEach(([key, val]) => {
      if (val !== undefined) {
        (state as any)[key] = val;
      }
    });
  }

  return state;
};

export function buildQueryStringFromState(state: FilterState) {
  const params = new URLSearchParams({
    limit: state.limit.toString(),
    offset: state.offset.toString(),
    order_direction: state.order_direction,
  });

  const arrayFields: Record<string, string[]> = {
    type: state.type,
    columns: state.columns,
    dst_port: state.dst_port,
    remote_addr: state.remote_addr,
    asn: state.asn,
    country: state.country,
    city: state.city,
    domain: state.domain,
    fqdn: state.fqdn,
  };

  Object.entries(arrayFields).forEach(([key, val]) => {
    val?.forEach((v) => params.append(key, v));
  });

  if (state.id && state.id !== 0) params.set("id", state.id.toString());

  const events = [...(state.event || [])];
  events.forEach((e) => params.append("event", e));

  if (state.time_start)
    params.set("time_start", formatDateTimeForAPI(state.time_start));
  if (state.time_end)
    params.set("time_end", formatDateTimeForAPI(state.time_end));

  // Add dynamic JSON fields
  Object.entries(state.json_fields || {}).forEach(([key, values]) => {
    values.forEach((v) => params.append("f:" + key, v));
  });

  return params.toString();
}

export type FilterActions = {
  state: FilterState;
  addRemoteAddr: (addr: string) => void;
  removeRemoteAddr: (addr: string) => void;
  addDstPort: (port: string) => void;
  setDstPort: (port: string) => void;
  setTimeStart: (start: string) => void;
  setTimeEnd: (end: string) => void;
  removeDstPort: (port: string) => void;
  addHoneypotType: (honeypot: string) => void;
  removeHoneypotType: (honeypot: string) => void;
  addEvent: (event: string) => void;
  removeEvent: (event: string) => void;
  addAsn: (asn: string) => void;
  removeAsn: (asn: string) => void;
  addCountry: (country: string) => void;
  removeCountry: (country: string) => void;
  addCity: (city: string) => void;
  removeCity: (city: string) => void;
  addDomain: (domain: string) => void;
  removeDomain: (domain: string) => void;
  addFqdn: (fqdn: string) => void;
  removeFqdn: (fqdn: string) => void;
  addJsonField: (key: string, value: string) => void;
  removeJsonField: (key: string, value: string) => void;
  setJsonField: (key: string, values: string[]) => void;
  getQueryString: () => string;
  reset: (initial?: Partial<FilterState>) => void;
};

export const useFilterActions = (
  initialState?: FilterState | Reactive<FilterState>,
): FilterActions => {
  const state = reactive(initialState || createFilterState());
  return {
    state,
    addRemoteAddr: (addr: string) => addToArray(state.remote_addr, addr),
    removeRemoteAddr: (addr: string) =>
      removeFromArray(state.remote_addr, addr),
    addDstPort: (port: string) => addToArray(state.dst_port, port),
    setDstPort: (port: string) => {
      state.dst_port = [port];
    },
    setTimeStart: (start: string) => {
      state.time_start = start;
    },
    setTimeEnd: (end: string) => {
      state.time_end = end;
    },
    removeDstPort: (port: string) => removeFromArray(state.dst_port, port),
    addHoneypotType: (honeypot: string) => addToArray(state.type, honeypot),
    removeHoneypotType: (honeypot: string) =>
      removeFromArray(state.type, honeypot),
    addEvent: (event: string) => addToArray(state.event, event),
    removeEvent: (event: string) => removeFromArray(state.event, event),
    addAsn: (asn: string) => addToArray(state.asn, asn),
    removeAsn: (asn: string) => removeFromArray(state.asn, asn),
    addCountry: (country: string) => addToArray(state.country, country),
    removeCountry: (country: string) => removeFromArray(state.country, country),
    addCity: (city: string) => addToArray(state.city, city),
    removeCity: (city: string) => removeFromArray(state.city, city),
    addDomain: (domain: string) => addToArray(state.domain, domain),
    removeDomain: (domain: string) => removeFromArray(state.domain, domain),
    addFqdn: (fqdn: string) => addToArray(state.fqdn, fqdn),
    removeFqdn: (fqdn: string) => removeFromArray(state.fqdn, fqdn),
    addJsonField: (key: string, value: string) => {
      if (!state.json_fields[key]) state.json_fields[key] = [];
      addToArray(state.json_fields[key], value);
    },
    removeJsonField: (key: string, value: string) => {
      if (state.json_fields[key]) {
        removeFromArray(state.json_fields[key], value);
      }
    },
    setJsonField: (key: string, values: string[]) => {
      state.json_fields[key] = values;
    },
    getQueryString: () => buildQueryStringFromState(state),
    reset: (initial?: Partial<FilterState>) => {
      Object.assign(state, createFilterState(initial));
    },
  };
};

export const useFilterStore = defineStore("filter", () => useFilterActions());

export const useChartStore = defineStore("chart", () =>
  useFilterActions(
    reactive(
      createFilterState({
        limit: 10000,
        columns: ["time", "remote_addr", "dst_port", "type", "event"],
      }),
    ),
  ),
);

export const useUIStore = defineStore("ui", () => {
  const selectedIP = ref<string | undefined>(undefined);
  const selectedPort = ref<number | undefined>(undefined);
  const selectedCountry = ref<string | undefined>(undefined);
  const selectedCount = ref(0);
  const selectedIPCount = ref(0);
  const mapType = ref<"scattergeo" | "scattermap">("scattergeo");

  function resetSelection() {
    selectedIP.value = undefined;
    selectedPort.value = undefined;
    selectedCountry.value = undefined;
    selectedCount.value = 0;
    selectedIPCount.value = 0;
  }

  return {
    selectedIP,
    selectedPort,
    selectedCountry,
    selectedCount,
    selectedIPCount,
    resetSelection,
    mapType,
  };
});

// Generic lookup logic composable
function useQueuedLookup<T, R>(options: {
  name: string;
  apiUrl: string;
  mapResponse: (data: R) => { found: Record<string, T>; notFound: string[] };
  maxRetries?: number;
}) {
  const cached = ref<Record<string, T>>({});
  const failed = ref<Record<string, number>>({});
  const queue = ref<string[]>([]);
  const inProgress = new Set<string>();
  const maxRetries = options.maxRetries ?? 2;

  const lookup = useThrottleFn(
    async () => {
      const ids = Array.from(
        new Set(
          queue.value.filter(
            (id) =>
              !cached.value[id] &&
              (failed.value[id] ?? 0) < maxRetries &&
              !inProgress.has(id),
          ),
        ),
      );
      if (!ids.length) return;

      ids.forEach((id) => inProgress.add(id));
      try {
        const res = await fetch(
          `${URL()}${options.apiUrl}?ip=${ids.join(",")}`,
        );
        const data = (await res.json()) as R;
        const { found, notFound } = options.mapResponse(data);

        Object.entries(found).forEach(([id, val]) => {
          cached.value[id] = val;
          delete failed.value[id];
        });
        notFound?.forEach(
          (id) => (failed.value[id] = (failed.value[id] ?? 0) + 1),
        );
      } catch (e) {
        console.error(`${options.name} lookup failed`, e);
      } finally {
        const processed = new Set(ids);
        queue.value = queue.value.filter((id) => !processed.has(id));
        ids.forEach((id) => inProgress.delete(id));
      }
    },
    500,
    true,
    true,
  );

  watch(
    () => queue.value.length,
    (len) => len > 0 && lookup(),
  );

  return {
    cached,
    failed,
    queue,
    getById: (id: string) =>
      computed(() => {
        if (cached.value[id]) return cached.value[id];
        if (
          !queue.value.includes(id) &&
          (failed.value[id] ?? 0) < maxRetries &&
          !inProgress.has(id)
        ) {
          queue.value.push(id);
          lookup();
        }
        return undefined;
      }),
  };
}

export const useIPInfoStore = defineStore("ipinfo", () => {
  const { queue, getById } = useQueuedLookup<GeoData, any>({
    name: "ipinfo",
    apiUrl: "/api/ipinfo",
    mapResponse: (data) => ({
      found: data.results || {},
      notFound: data.not_found || [],
    }),
  });

  return {
    getIPInfo: (ip: string) => getById(ip),
    ipInfoIsLoading: (ip: string) => queue.value.includes(ip),
    getDomainByIp: (ip: string) =>
      computed(
        () => getById(ip).value?.fqdn || getById(ip).value?.domain || ip,
      ),
  };
});

export type GeoData = {
  asn: {
    autonomous_system_number: number;
    autonomous_system_organization: string;
  } | null;
  city: {
    name: string;
    country: string;
  } | null;
  country: { iso_code: string } | null;
  domain?: string;
  fqdn?: string;
};

type BlocklistEntry = {
  address: string;
  timestamp: string;
  expires: string;
  reason: string;
};

type HoneypotType = {
  name: string;
  label: string;
  ports: number[];
};

export const useBlocklistStore = defineStore("blocklist", () => {
  const blocklist = ref<BlocklistEntry[]>([]);
  const loading = ref(false);

  const fetchBlocklist = async () => {
    loading.value = true;
    try {
      const res = await fetch(`${URL()}/api/blocklist-entries`);
      const data = await res.json();
      blocklist.value = data ?? [];
    } finally {
      loading.value = false;
    }
  };

  const getBlocklistEntryByIp = computed(() => (ip: string) => {
    return blocklist.value.find((entry) => entry.address === ip);
  });

  const isInBotnet = computed(() => (ip: string) => {
    const botnets = blocklist.value.filter((entry) =>
      entry.reason.includes("botnet"),
    );
    return botnets.some((entry) => isIpInSubnet(ip, entry.address));
  });

  const getBotnetEntryByIp = computed(() => (ip: string) => {
    return blocklist.value.find(
      (entry) =>
        entry.reason.includes("botnet") && isIpInSubnet(ip, entry.address),
    );
  });

  const getTagsByIp = computed(() => (ip: string) => {
    const entry = getBlocklistEntryByIp.value(ip);
    const tags = entry?.reason
      ? entry.reason.split(",").map((s) => s.trim())
      : [];
    if (isInBotnet.value(ip) && !tags.includes("botnet")) {
      tags.push("botnet");
    }
    return tags;
  });

  return {
    blocklist,
    loading,
    fetchBlocklist,
    getBlocklistEntryByIp,
    isInBotnet,
    getBotnetEntryByIp,
    getTagsByIp,
  };
});

export const useDashboardStore = defineStore("dashboard", () => {
  const stats = ref<DashboardStats | null>(null);
  const loading = ref(false);

  const fetchStats = async () => {
    loading.value = true;
    try {
      const response = await fetch(`${URL()}/api/stats`);
      const data = await response.json();
      stats.value = data ?? null;
    } catch (e) {
      console.error("Failed to fetch stats", e);
    } finally {
      loading.value = false;
    }
  };

  return {
    stats,
    loading,
    fetchStats,
  };
});

export const honeypotTypes = ref<HoneypotType[]>([]);

export const fetchHoneypotTypes = async () => {
  const res = await fetch(`${URL()}/api/active-honeypots`);
  const data = await res.json();
  honeypotTypes.value = data;
};

export const getHoneypot = (name: string) => {
  return honeypotTypes.value.find(
    (hp) => hp.name.toLowerCase() === name.toLowerCase(),
  );
};

export const useAuthStore = defineStore("auth", () => {
  const authRequired = ref(false);
  const authenticated = ref(false);
  const statusChecked = ref(false);

  const fetchAuthStatus = async () => {
    try {
      const res = await fetch(`${URL()}/api/auth-status`);
      const data = await res.json();
      authRequired.value = data.auth_required;
      authenticated.value = data.authenticated;
    } catch (e) {
      console.error("Failed to fetch auth status", e);
    } finally {
      statusChecked.value = true;
    }
  };

  const login = async (password: string) => {
    const res = await fetch(`${URL()}/api/login`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ password }),
    });

    if (res.ok) {
      authenticated.value = true;
      return true;
    }
    return false;
  };

  const logout = async () => {
    await fetch(`${URL()}/api/logout`, { method: "POST" });
    authenticated.value = false;
  };

  return {
    authRequired,
    authenticated,
    statusChecked,
    fetchAuthStatus,
    login,
    logout,
  };
});