// date-range-picker-pure.ts — pure helpers for the symmetric date-range
// picker (t-paliad-248). No DOM access; runnable under `bun test`. The
// picker's boot client (date-range-picker.ts) drives the popover, but
// every interesting decision — what does "Letzte 7 Tage" mean today,
// what URL params should land, when is a custom range valid — lives
// here so it can be tested without a browser.
//
// The Go side (internal/services/view_service.go:computeViewSpecBounds)
// is the canonical materializer; horizonBounds() below MUST stay in
// step with it. The bounds test in pure-tests pins the shape so a
// divergent change to one side breaks the assertions on the other.

import type { I18nKey } from "../i18n-keys";

/**
 * TimeHorizon — the full 14-value union the symmetric picker can emit.
 * Mirrors `internal/services/filter_spec.go` TimeHorizon.
 *
 * The fan chips: 6 past + 6 next + the ALLES centre (`any`) + custom.
 * `all` is the legacy bidirectional-unbounded value, gated to
 * scope=explicit by the validator (Q26); the picker doesn't surface it
 * but parseURL accepts it for back-compat with saved Custom Views.
 */
export type TimeHorizon =
  | "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all"
  | "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all"
  | "any" | "all" | "custom";

/**
 * TimeSpec — the wire shape mirrored from the Go FilterSpec.TimeSpec.
 * `from`/`to` are ISO YYYY-MM-DD strings — UTC dates, not timestamps.
 * Times-of-day intentionally absent from the picker's contract.
 */
export interface TimeSpec {
  horizon: TimeHorizon;
  from?: string;
  to?: string;
}

/**
 * The full list of horizon values the picker is willing to render
 * as chips. Order is the picker's reading order — past edge → past
 * → ALLES → next → next edge, with `custom` last because it lives
 * below the chip rows in the popover, not in the row itself.
 */
export const ALL_HORIZONS: readonly TimeHorizon[] = [
  "past_all",
  "past_90d",
  "past_30d",
  "past_14d",
  "past_7d",
  "past_1d",
  "any",
  "next_1d",
  "next_7d",
  "next_14d",
  "next_30d",
  "next_90d",
  "next_all",
  "custom",
];

// Strict-validity set. Includes the legacy bidirectional-unbounded `all`
// horizon so a saved Custom View JSON ({"horizon":"all", …}) deserializes
// without falling back to the surface default. The picker UI itself
// doesn't surface a chip for `all` — it's read in, kept as state, but
// the chip the user sees light up is `any` (the centre ALLES button).
const ALL_HORIZONS_SET: ReadonlySet<string> = new Set([...ALL_HORIZONS, "all"]);

/**
 * Past chips, in reading order (outermost → innermost). The picker
 * renders this left-to-right in the popover's past fan.
 */
export const PAST_HORIZONS: readonly TimeHorizon[] = [
  "past_all",
  "past_90d",
  "past_30d",
  "past_14d",
  "past_7d",
  "past_1d",
];

/**
 * Future chips, in reading order (innermost → outermost). The picker
 * renders this left-to-right in the popover's future fan.
 */
export const NEXT_HORIZONS: readonly TimeHorizon[] = [
  "next_1d",
  "next_7d",
  "next_14d",
  "next_30d",
  "next_90d",
  "next_all",
];

/**
 * The i18n key for the closed-button label and chip text of every
 * horizon. Lives here (not in the TSX) so a single dictionary lookup
 * sites can hand back a translated string at any point.
 */
export const HORIZON_LABEL_KEY: Record<TimeHorizon, I18nKey> = {
  past_all: "date_range.horizon.past_all",
  past_90d: "date_range.horizon.past_90d",
  past_30d: "date_range.horizon.past_30d",
  past_14d: "date_range.horizon.past_14d",
  past_7d:  "date_range.horizon.past_7d",
  past_1d:  "date_range.horizon.past_1d",
  any:      "date_range.horizon.any",
  next_1d:  "date_range.horizon.next_1d",
  next_7d:  "date_range.horizon.next_7d",
  next_14d: "date_range.horizon.next_14d",
  next_30d: "date_range.horizon.next_30d",
  next_90d: "date_range.horizon.next_90d",
  next_all: "date_range.horizon.next_all",
  all:      "date_range.horizon.any", // legacy alias — surfaces "Alles" in the closed label
  custom:   "date_range.horizon.custom",
};

/**
 * Bounds for a given horizon, anchored at `now`. Pure function: the
 * caller passes the clock so tests can pin a specific day without
 * mocking Date. Bounds are UTC dates; the `to` bound is exclusive
 * (start-of-day-after) so "past 7d" includes today.
 *
 * Returns `{}` for `any` / `all` / `custom` — the picker's surface
 * lifts the from/to out of TimeSpec directly when horizon === custom,
 * and treats unbounded values as "no narrowing in that direction".
 */
export function horizonBounds(
  horizon: TimeHorizon,
  now: Date,
): { from?: Date; to?: Date } {
  const day = new Date(Date.UTC(
    now.getUTCFullYear(),
    now.getUTCMonth(),
    now.getUTCDate(),
  ));
  const offset = (days: number): Date =>
    new Date(day.getTime() + days * 86_400_000);

  switch (horizon) {
    case "past_1d":  return { from: offset(-1),  to: offset(1) };
    case "past_7d":  return { from: offset(-7),  to: offset(1) };
    case "past_14d": return { from: offset(-14), to: offset(1) };
    case "past_30d": return { from: offset(-30), to: offset(1) };
    case "past_90d": return { from: offset(-90), to: offset(1) };
    case "past_all": return {                    to: offset(1) };
    case "next_1d":  return { from: day,         to: offset(1) };
    case "next_7d":  return { from: day,         to: offset(7) };
    case "next_14d": return { from: day,         to: offset(14) };
    case "next_30d": return { from: day,         to: offset(30) };
    case "next_90d": return { from: day,         to: offset(90) };
    case "next_all": return { from: day                       };
    case "any":
    case "all":
    case "custom":
      return {};
  }
}

/**
 * isValidHorizon — narrows an unknown string to a TimeHorizon, used
 * by parseURL and by surface-side URL alias adapters.
 */
export function isValidHorizon(s: unknown): s is TimeHorizon {
  return typeof s === "string" && ALL_HORIZONS_SET.has(s);
}

const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
 * isValidISODate — `YYYY-MM-DD` shape check plus a real-date validity
 * check (rejects 2026-02-30). Doesn't enforce timezone or floor at any
 * particular date.
 */
export function isValidISODate(s: unknown): s is string {
  if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false;
  const ms = Date.parse(`${s}T00:00:00Z`);
  if (Number.isNaN(ms)) return false;
  // Reject 2026-02-30 etc. — Date.parse accepts those by rolling over.
  return new Date(ms).toISOString().slice(0, 10) === s;
}

/**
 * Validate a custom range. Returns null on success, an i18n key
 * pointing at the error message on failure.
 *
 * Rules:
 *   - Both `from` and `to` must be valid ISO YYYY-MM-DD.
 *   - `to` must be strictly after `from` (single-day ranges use
 *     `from=2026-05-25&to=2026-05-26`, NOT `from=to=2026-05-25`).
 */
export function validateCustomRange(
  from: string | undefined,
  to: string | undefined,
): I18nKey | null {
  if (!from || !to) return "date_range.custom.invalid_missing";
  if (!isValidISODate(from) || !isValidISODate(to)) return "date_range.custom.invalid_format";
  if (Date.parse(`${from}T00:00:00Z`) >= Date.parse(`${to}T00:00:00Z`)) {
    return "date_range.custom.invalid";
  }
  return null;
}

/**
 * URLContract — the picker's stable URL serialization. Surfaces can
 * override the param name via `key` so two pickers on the same page
 * (rare) don't collide.
 */
export interface URLContract {
  /** Base param name, defaults to "horizon". */
  key?: string;
  /** Default value omitted from URL (matches surface's natural default). */
  default?: TimeHorizon;
}

/**
 * parseURL — reads a URL search-params object into a TimeSpec.
 *
 *   ?horizon=past_30d                       → {horizon:"past_30d"}
 *   ?horizon=custom&from=2026-03-15&to=…    → {horizon:"custom",from,to}
 *   (no params)                             → {horizon: contract.default ?? "any"}
 *
 * Unknown / malformed values fall back to the default. Out-of-shape
 * custom dates clamp to {horizon: default} — the picker never lands
 * in a half-custom state from a URL.
 */
export function parseURL(
  params: URLSearchParams,
  contract: URLContract = {},
): TimeSpec {
  const key = contract.key ?? "horizon";
  const fallback: TimeHorizon = contract.default ?? "any";

  const raw = params.get(key);
  if (raw === null) return { horizon: fallback };
  if (!isValidHorizon(raw)) return { horizon: fallback };
  if (raw !== "custom") return { horizon: raw };

  const from = params.get(`${key}_from`) ?? undefined;
  const to = params.get(`${key}_to`) ?? undefined;
  if (validateCustomRange(from, to) !== null) {
    return { horizon: fallback };
  }
  return { horizon: "custom", from, to };
}

/**
 * serializeURL — writes a TimeSpec into the URL search-params object,
 * mutating the passed-in instance. Values equal to the surface
 * default are OMITTED — the canonical URL stays short.
 *
 * Always deletes `horizon`, `<key>_from`, `<key>_to` first so a
 * re-serialise after the picker reverts to default cleans up rather
 * than accumulating stale entries.
 */
export function serializeURL(
  spec: TimeSpec,
  params: URLSearchParams,
  contract: URLContract = {},
): void {
  const key = contract.key ?? "horizon";
  const fromKey = `${key}_from`;
  const toKey = `${key}_to`;

  params.delete(key);
  params.delete(fromKey);
  params.delete(toKey);

  if (spec.horizon === (contract.default ?? "any") && spec.horizon !== "custom") {
    return;
  }

  if (spec.horizon === "custom") {
    params.set(key, "custom");
    if (spec.from) params.set(fromKey, spec.from);
    if (spec.to) params.set(toKey, spec.to);
    return;
  }

  params.set(key, spec.horizon);
}

/**
 * isDefault — used by surfaces to decide whether to render the
 * "value is non-default" dot on the closed button.
 */
export function isDefault(spec: TimeSpec, defaultHorizon: TimeHorizon): boolean {
  if (spec.horizon !== defaultHorizon) return false;
  if (spec.horizon === "custom") return false;
  return true;
}
