/**
 * The reporting view as URL-serialisable state (ADR pending). Everything that
 * shapes what the reporting page shows — the selected scenario `view`, the
 * scenario filters, and the tag filter — lives here so it survives a refresh
 * and browser back/forward, and so the server (baseline queries) and client
 * (scenario overlay) read one canonical source: the URL.
 *
 * Pure: no DB, no React. Parsing is defensive — every value is sanitised so a
 * hand-edited or stale URL can never produce an invalid query.
 */
import { EPC_BANDS } from "@/lib/epc/bands";

export type IncludeMode = "any" | "all";

/** Which tags a home must / must not carry to appear in the view. */
export interface TagFilter {
  /** Tag ids (as strings — tag ids are bigints) the home must match. */
  include: string[];
  /** Tag ids the home must NOT carry. */
  exclude: string[];
  /** `any` = home has ≥1 included tag; `all` = home has every included tag. */
  includeMode: IncludeMode;
}

export type ReportView = "current-stock" | "recommended" | number;

export interface ReportingViewState {
  view: ReportView;
  hideNonCompliant: boolean;
  useLodgedBaseline: boolean;
  complianceWindow: { band: string; date: string } | null;
  tags: TagFilter;
}

/** Accepts either the client's URLSearchParams or the server's searchParams object. */
type ParamSource =
  | URLSearchParams
  | Record<string, string | string[] | undefined>;

function readParam(src: ParamSource, key: string): string | null {
  if (src instanceof URLSearchParams) return src.get(key);
  const v = src[key];
  if (Array.isArray(v)) return v[0] ?? null;
  return v ?? null;
}

/** Comma-separated numeric ids → deduped string[] (first-seen order). */
function parseIds(raw: string | null): string[] {
  if (!raw) return [];
  const seen = new Set<string>();
  const out: string[] = [];
  for (const part of raw.split(",")) {
    const id = part.trim();
    if (/^\d+$/.test(id) && !seen.has(id)) {
      seen.add(id);
      out.push(id);
    }
  }
  return out;
}

function parseView(raw: string | null): ReportView {
  if (raw === "recommended") return "recommended";
  if (raw && /^\d+$/.test(raw)) return Number(raw);
  return "current-stock";
}

export function emptyTagFilter(): TagFilter {
  return { include: [], exclude: [], includeMode: "any" };
}

export function emptyViewState(): ReportingViewState {
  return {
    view: "current-stock",
    hideNonCompliant: false,
    useLodgedBaseline: false,
    complianceWindow: null,
    tags: emptyTagFilter(),
  };
}

export function isTagFilterActive(tags: TagFilter): boolean {
  return tags.include.length > 0 || tags.exclude.length > 0;
}

function sameIdSet(a: string[], b: string[]): boolean {
  if (a.length !== b.length) return false;
  const seen = new Set(b);
  return a.every((id) => seen.has(id));
}

/**
 * Whether two tag filters produce the same reporting view — i.e. the same
 * server query. Used to decide how a view-state change is applied: a changed
 * tag filter must re-render the server-side baseline (`router.replace`), while
 * view/scenario-filter changes are client-only (`history.replaceState`).
 *
 * Order-insensitive within include/exclude (the sets, not their order, decide
 * the query), and `includeMode` only counts when it bites — with fewer than two
 * included tags "any" and "all" are indistinguishable, so a flip between them
 * must not be treated as a change and trigger a needless round-trip.
 */
export function tagFiltersEqual(a: TagFilter, b: TagFilter): boolean {
  if (!sameIdSet(a.include, b.include)) return false;
  if (!sameIdSet(a.exclude, b.exclude)) return false;
  if (a.include.length >= 2 && a.includeMode !== b.includeMode) return false;
  return true;
}

export function parseReportingViewState(src: ParamSource): ReportingViewState {
  const band = readParam(src, "complianceBand");
  const date = readParam(src, "complianceDate");
  const complianceWindow =
    band &&
    date &&
    (EPC_BANDS as readonly string[]).includes(band) &&
    /^\d{4}-\d{2}-\d{2}$/.test(date) &&
    !Number.isNaN(Date.parse(date))
      ? { band, date }
      : null;

  const include = parseIds(readParam(src, "includeTags"));
  const exclude = parseIds(readParam(src, "excludeTags"));
  // A tag can't be both included and excluded — exclude wins (the stricter,
  // safer intent), so it's dropped from the include set.
  const excludeSet = new Set(exclude);
  const includeMode = readParam(src, "includeMode") === "all" ? "all" : "any";

  return {
    view: parseView(readParam(src, "view")),
    hideNonCompliant: readParam(src, "hideNonCompliant") === "true",
    useLodgedBaseline: readParam(src, "useLodgedBaseline") === "true",
    complianceWindow,
    tags: {
      include: include.filter((id) => !excludeSet.has(id)),
      exclude,
      includeMode,
    },
  };
}

/** The tag-filter params only — the subset the reporting view currently drives via the URL. */
export function serializeTagFilter(
  tags: TagFilter,
  into: URLSearchParams = new URLSearchParams(),
): URLSearchParams {
  if (tags.include.length) into.set("includeTags", tags.include.join(","));
  if (tags.exclude.length) into.set("excludeTags", tags.exclude.join(","));
  // includeMode only bites with 2+ included tags; omit the default "any".
  if (tags.includeMode === "all" && tags.include.length >= 2) {
    into.set("includeMode", "all");
  }
  return into;
}

export function serializeReportingViewState(
  state: ReportingViewState,
): URLSearchParams {
  const p = new URLSearchParams();
  if (state.view !== "current-stock") p.set("view", String(state.view));
  if (state.hideNonCompliant) p.set("hideNonCompliant", "true");
  if (state.useLodgedBaseline) p.set("useLodgedBaseline", "true");
  if (state.complianceWindow) {
    p.set("complianceBand", state.complianceWindow.band);
    p.set("complianceDate", state.complianceWindow.date);
  }
  serializeTagFilter(state.tags, p);
  return p;
}
