// Shared config save utilities.
//
// Provides the core per-section save logic (buildOverrides, sanitize, restart
// detection, update-topic resolution) used by both the individual per-section
// Save button in BaseSection and the global "Save All" coordinator in Settings.

import get from "lodash/get";
import cloneDeep from "lodash/cloneDeep";
import unset from "lodash/unset";
import isEqual from "lodash/isEqual";
import mergeWith from "lodash/mergeWith";
import set from "lodash/set";
import { isJsonObject } from "@/lib/utils";
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
import { applySchemaDefaults } from "@/lib/config-schema";
import { normalizeConfigValue } from "@/hooks/use-config-override";
import {
  modifySchemaForSection,
  getEffectiveDefaultsForSection,
  sanitizeOverridesForSection,
} from "@/components/config-form/sections/section-special-cases";
import type { RJSFSchema } from "@rjsf/utils";
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type {
  ConfigSectionData,
  HiddenFieldContext,
  JsonObject,
  JsonValue,
} from "@/types/configForm";
import type { SectionConfig } from "../components/config-form/sections/BaseSection";
import { sectionConfigs } from "../components/config-form/sectionConfigs";

/**
 * Recursively strip any key whose value is the redaction sentinel from a
 * config_data payload. Use just before sending to /config/set so untouched
 * credential placeholder fields don't clobber the saved YAML value. Mutates
 * and returns the input.
 */
export function stripRedactedCredentials<T>(value: T): T {
  if (Array.isArray(value)) {
    for (const item of value) {
      stripRedactedCredentials(item);
    }
    return value;
  }
  if (value && typeof value === "object") {
    const obj = value as Record<string, unknown>;
    for (const key of Object.keys(obj)) {
      const v = obj[key];
      if (v === REDACTED_CREDENTIAL_SENTINEL) {
        delete obj[key];
      } else if (v && typeof v === "object") {
        stripRedactedCredentials(v);
      }
    }
  }
  return value;
}

// ---------------------------------------------------------------------------
// cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics
// ---------------------------------------------------------------------------

export const cameraUpdateTopicMap: Record<string, string> = {
  detect: "detect",
  record: "record",
  snapshots: "snapshots",
  motion: "motion",
  objects: "objects",
  review: "review",
  audio: "audio",
  notifications: "notifications",
  live: "live",
  timestamp_style: "timestamp_style",
  audio_transcription: "audio_transcription",
  birdseye: "birdseye",
  face_recognition: "face_recognition",
  ffmpeg: "ffmpeg",
  lpr: "lpr",
  semantic_search: "semantic_search",
  mqtt: "mqtt",
  onvif: "onvif",
  ui: "ui",
  zones: "zones",
};

// Sections where global config serves as the default for per-camera config.
// Global updates to these sections are fanned out to all cameras via wildcard.
export const globalCameraDefaultSections = new Set([
  "detect",
  "objects",
  "motion",
  "record",
  "snapshots",
  "review",
  "audio",
  "notifications",
  "ffmpeg",
]);

// ---------------------------------------------------------------------------
// Profile helpers
// ---------------------------------------------------------------------------

/**
 * Get the base (pre-profile) value for a camera section.
 *
 * When a profile is active the API populates `base_config` with original
 * section values.  This helper returns that value when available, falling
 * back to the top-level (effective) value otherwise.
 */
export function getBaseCameraSectionValue(
  config: FrigateConfig | undefined,
  cameraName: string | undefined,
  sectionPath: string,
): unknown {
  if (!config || !cameraName) return undefined;
  const cam = config.cameras?.[cameraName];
  if (!cam) return undefined;
  const base = cam.base_config?.[sectionPath];
  return base !== undefined ? base : get(cam, sectionPath);
}

// mergeWith customizer that replaces arrays wholesale instead of merging them
// positionally by index. Used when the source value is meant to fully replace
// the destination (e.g. profile overrides, section config overrides), so an
// empty source array correctly clears the destination array.
const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => {
  if (Array.isArray(objValue) || Array.isArray(srcValue)) {
    return srcValue !== undefined ? srcValue : objValue;
  }
  return undefined;
};

// Merge profile overrides on top of base config values. Matches the backend's
// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by
// the profile's value rather than merged positionally, so an empty array in a
// profile clears the base array instead of leaving stale entries behind.
export function mergeProfileOverrides<T extends object>(
  baseValue: T,
  profileOverrides: object,
): T {
  return mergeWith(
    cloneDeep(baseValue),
    cloneDeep(profileOverrides),
    replaceArraysCustomizer,
  ) as T;
}

/** Sections that can appear inside a camera profile definition. */
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
  "audio",
  "birdseye",
  "detect",
  "face_recognition",
  "lpr",
  "motion",
  "notifications",
  "objects",
  "record",
  "review",
  "snapshots",
]);

/**
 * Parse a section path that may encode a profile reference.
 *
 * Examples:
 *   "detect"                 → { isProfile: false, actualSection: "detect" }
 *   "profiles.armed.detect"  → { isProfile: true, profileName: "armed", actualSection: "detect" }
 */
export function parseProfileFromSectionPath(sectionPath: string): {
  isProfile: boolean;
  profileName?: string;
  actualSection: string;
} {
  const match = sectionPath.match(/^profiles\.([^.]+)\.(.+)$/);
  if (match) {
    return { isProfile: true, profileName: match[1], actualSection: match[2] };
  }
  return { isProfile: false, actualSection: sectionPath };
}

// ---------------------------------------------------------------------------
// buildOverrides — pure recursive diff of current vs stored config & defaults
// ---------------------------------------------------------------------------

// Recursively compare `current` (pending form data) against `base` (persisted
// config) and `defaults` (schema defaults) to produce a minimal overrides
// payload.
//
// - Returns `undefined` when the value matches `base` (or `defaults` when
//   `base` is absent), indicating no override is needed.
// - For objects, recurses per-key; deleted keys (present in `base` but absent
//   in `current`) are represented as `""`.
// - For arrays, returns the full array when it differs.

export function buildOverrides(
  current: unknown,
  base: unknown,
  defaults: unknown,
): unknown | undefined {
  if (current === null || current === undefined || current === "") {
    return undefined;
  }

  if (Array.isArray(current)) {
    if (
      current.length === 0 &&
      (base === undefined || base === null) &&
      (defaults === undefined || defaults === null)
    ) {
      return undefined;
    }
    if (
      (base === undefined &&
        defaults !== undefined &&
        isEqual(current, defaults)) ||
      isEqual(current, base)
    ) {
      return undefined;
    }
    return current;
  }

  if (isJsonObject(current)) {
    const currentObj = current;
    const baseObj = isJsonObject(base) ? base : undefined;
    const defaultsObj = isJsonObject(defaults) ? defaults : undefined;

    const result: JsonObject = {};
    for (const [key, value] of Object.entries(currentObj)) {
      if (
        (value === undefined || value === null) &&
        baseObj &&
        baseObj[key] !== undefined &&
        baseObj[key] !== null
      ) {
        result[key] = "";
        continue;
      }
      const overrideValue = buildOverrides(
        value,
        baseObj ? baseObj[key] : undefined,
        defaultsObj ? defaultsObj[key] : undefined,
      );
      if (overrideValue !== undefined) {
        result[key] = overrideValue as JsonValue;
      }
    }

    if (baseObj) {
      for (const [key, baseValue] of Object.entries(baseObj)) {
        if (Object.prototype.hasOwnProperty.call(currentObj, key)) {
          continue;
        }
        if (baseValue === undefined) {
          continue;
        }
        result[key] = "";
      }
    }

    return Object.keys(result).length > 0 ? result : undefined;
  }

  if (
    base === undefined &&
    defaults !== undefined &&
    isEqual(current, defaults)
  ) {
    return undefined;
  }

  if (isEqual(current, base)) {
    return undefined;
  }

  return current;
}

// ---------------------------------------------------------------------------
// flattenOverrides — turn an overrides object into a list of leaf paths
// ---------------------------------------------------------------------------

// Walks a nested overrides value and produces a flat list of `{ path, value }`
// entries, one per leaf.  Used by save/preview UIs to enumerate the individual
// fields that will be changed.
export function flattenOverrides(
  value: JsonValue | undefined,
  path: string[] = [],
): Array<{ path: string; value: JsonValue }> {
  if (value === undefined) return [];
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    return [{ path: path.join("."), value }];
  }

  const entries = Object.entries(value);
  if (entries.length === 0) {
    return [{ path: path.join("."), value: {} }];
  }

  return entries.flatMap(([key, entryValue]) =>
    flattenOverrides(entryValue, [...path, key]),
  );
}

// ---------------------------------------------------------------------------
// sanitizeSectionData — normalize config values and strip hidden fields
// ---------------------------------------------------------------------------

// Normalize raw config data (strip internal fields) and remove any paths
// listed in `hiddenFields` so they are not included in override computation.

// lodash `unset` treats `*` as a literal key.  This helper expands wildcard
// segments so that e.g. `"filters.*.mask"` unsets `filters.<each key>.mask`.
export function unsetWithWildcard(
  obj: Record<string, unknown>,
  path: string,
): void {
  if (!path.includes("*")) {
    unset(obj, path);
    return;
  }
  const segments = path.split(".");
  const starIndex = segments.indexOf("*");
  const prefix = segments.slice(0, starIndex).join(".");
  const suffix = segments.slice(starIndex + 1).join(".");
  const parent = prefix ? get(obj, prefix) : obj;
  if (parent && typeof parent === "object") {
    for (const key of Object.keys(parent as Record<string, unknown>)) {
      const fullPath = suffix ? `${key}.${suffix}` : key;
      unsetWithWildcard(parent as Record<string, unknown>, fullPath);
    }
  }
}

export function sanitizeSectionData(
  data: ConfigSectionData,
  hiddenFields?: string[],
): ConfigSectionData {
  const normalized = normalizeConfigValue(data) as ConfigSectionData;
  if (!hiddenFields || hiddenFields.length === 0) {
    return normalized;
  }
  const cleaned = cloneDeep(normalized) as ConfigSectionData;
  hiddenFields.forEach((path) => {
    if (!path) return;
    unsetWithWildcard(cleaned as Record<string, unknown>, path);
  });
  return cleaned;
}

// ---------------------------------------------------------------------------
// buildConfigDataForPath — convert dotted path to nested config_data payload
// ---------------------------------------------------------------------------

// Converts a dotted path (e.g. "cameras.front_door.detect") and a value into
// a properly nested config_data object (e.g. { cameras: { front_door: { detect: value } } }).
// This ensures the backend's flatten_config_data function can correctly distinguish
// between path separators (dots in the path) and literal dots in keys
// (e.g. "frigate.foo.bar" in logger.logs).
export function buildConfigDataForPath(
  path: string,
  value: unknown,
): Record<string, unknown> {
  const configData: Record<string, unknown> = {};
  set(configData, path, value);
  return configData;
}

// ---------------------------------------------------------------------------
// requiresRestartForOverrides — determine whether a restart is needed
// ---------------------------------------------------------------------------

// Check whether the given overrides include fields that require a Frigate
// restart.  When `restartRequired` is `undefined` the caller's default is
// used; an empty array means "never restart"; otherwise the function checks
// if any of the listed field paths are present in the overrides object.

function hasMatchAtPath(value: unknown, pathSegments: string[]): boolean {
  if (pathSegments.length === 0) {
    return value !== undefined;
  }

  if (value === undefined || value === null) {
    return false;
  }

  const [segment, ...rest] = pathSegments;

  if (segment === "*") {
    if (Array.isArray(value)) {
      return value.some((item) => hasMatchAtPath(item, rest));
    }

    if (isJsonObject(value)) {
      return Object.values(value).some((item) => hasMatchAtPath(item, rest));
    }

    return false;
  }

  if (Array.isArray(value)) {
    const index = Number(segment);
    if (!Number.isInteger(index)) {
      return false;
    }
    return hasMatchAtPath(value[index], rest);
  }

  if (isJsonObject(value)) {
    return hasMatchAtPath(value[segment], rest);
  }

  return false;
}

export function requiresRestartForOverrides(
  overrides: unknown,
  restartRequired: string[] | undefined,
  defaultRequiresRestart: boolean = true,
): boolean {
  if (restartRequired === undefined) {
    return defaultRequiresRestart;
  }
  if (restartRequired.length === 0) {
    return false;
  }
  if (!overrides || typeof overrides !== "object") {
    return false;
  }
  return restartRequired.some((path) => {
    if (!path) {
      return false;
    }

    if (!path.includes("*")) {
      return get(overrides as JsonObject, path) !== undefined;
    }

    return hasMatchAtPath(overrides, path.split("."));
  });
}

export function requiresRestartForFieldPath(
  fieldPath: Array<string | number>,
  restartRequired: string[] | undefined,
  defaultRequiresRestart: boolean = true,
): boolean {
  if (restartRequired === undefined) {
    return defaultRequiresRestart;
  }

  if (restartRequired.length === 0) {
    return false;
  }

  if (fieldPath.length === 0) {
    return false;
  }

  const probe: Record<string, unknown> = {};
  set(
    probe,
    fieldPath.map((segment) => String(segment)),
    true,
  );

  return requiresRestartForOverrides(
    probe,
    restartRequired,
    defaultRequiresRestart,
  );
}

// ---------------------------------------------------------------------------
// SectionSavePayload — data produced by prepareSectionSavePayload
// ---------------------------------------------------------------------------

// Ready-to-PUT payload for a single config section.

export interface SectionSavePayload {
  basePath: string;
  sanitizedOverrides: JsonObject;
  updateTopic: string | undefined;
  needsRestart: boolean;
  pendingDataKey: string;
}

// ---------------------------------------------------------------------------
// extractSectionSchema — resolve a section schema from the full config schema
// ---------------------------------------------------------------------------

import { resolveAndCleanSchema } from "@/lib/config-schema";

type SchemaWithDefinitions = RJSFSchema & {
  $defs?: Record<string, RJSFSchema>;
  definitions?: Record<string, RJSFSchema>;
  properties?: Record<string, RJSFSchema>;
};

function getSchemaDefinitions(schema: RJSFSchema): Record<string, RJSFSchema> {
  return (
    (schema as SchemaWithDefinitions).$defs ||
    (schema as SchemaWithDefinitions).definitions ||
    {}
  );
}

function extractSectionSchema(
  schema: RJSFSchema,
  sectionPath: string,
  level: "global" | "camera",
): RJSFSchema | null {
  const defs = getSchemaDefinitions(schema);
  const schemaObj = schema as SchemaWithDefinitions;
  let sectionDef: RJSFSchema | null = null;

  if (level === "camera") {
    const cameraConfigDef = defs.CameraConfig;
    if (cameraConfigDef?.properties) {
      const sectionProp = cameraConfigDef.properties[sectionPath];
      if (sectionProp && typeof sectionProp === "object") {
        if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
          const refPath = sectionProp.$ref
            .replace(/^#\/\$defs\//, "")
            .replace(/^#\/definitions\//, "");
          sectionDef = defs[refPath] || null;
        } else {
          sectionDef = sectionProp;
        }
      }
    }
  } else {
    if (schemaObj.properties) {
      const sectionProp = schemaObj.properties[sectionPath];
      if (sectionProp && typeof sectionProp === "object") {
        if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") {
          const refPath = sectionProp.$ref
            .replace(/^#\/\$defs\//, "")
            .replace(/^#\/definitions\//, "");
          sectionDef = defs[refPath] || null;
        } else {
          sectionDef = sectionProp;
        }
      }
    }
  }

  if (!sectionDef) return null;

  const schemaWithDefs: RJSFSchema = { ...sectionDef, $defs: defs };
  return resolveAndCleanSchema(schemaWithDefs);
}

// ---------------------------------------------------------------------------
// prepareSectionSavePayload — build the PUT payload for a single section
// ---------------------------------------------------------------------------

// Given a pending-data key (e.g. `"detect"` or `"front_door::detect"`), its
// dirty form data, the current stored config, and the full JSON Schema,
// produce a `SectionSavePayload` that can be sent directly to
// `PUT config/set`.  Returns `null` when there are no effective overrides.

export function prepareSectionSavePayload(opts: {
  pendingDataKey: string;
  pendingData: unknown;
  config: FrigateConfig;
  fullSchema: RJSFSchema;
}): SectionSavePayload | null {
  const { pendingDataKey, pendingData, config, fullSchema } = opts;

  if (!pendingData) return null;

  // Parse pendingDataKey → sectionPath, level, cameraName
  let sectionPath: string;
  let level: "global" | "camera";
  let cameraName: string | undefined;

  if (pendingDataKey.includes("::")) {
    const idx = pendingDataKey.indexOf("::");
    cameraName = pendingDataKey.slice(0, idx);
    sectionPath = pendingDataKey.slice(idx + 2);
    level = "camera";
  } else {
    sectionPath = pendingDataKey;
    level = "global";
  }

  // Detect profile-encoded section paths (e.g., "profiles.armed.detect")
  const profileInfo = parseProfileFromSectionPath(sectionPath);
  const schemaSection = profileInfo.actualSection;

  // Resolve section config using the actual section name (not the profile path)
  const sectionConfig = getSectionConfig(schemaSection, level);

  // Resolve section schema using the actual section name
  const sectionSchema = extractSectionSchema(fullSchema, schemaSection, level);
  if (!sectionSchema) return null;

  const modifiedSchema = modifySchemaForSection(
    schemaSection,
    level,
    sectionSchema,
    config
      ? {
          fullConfig: config,
          fullCameraConfig:
            level === "camera" && cameraName
              ? config.cameras?.[cameraName]
              : undefined,
          level,
          cameraName,
        }
      : undefined,
  );

  // Compute rawFormData (the current stored value for this section)
  // For profiles, merge base camera config with profile overrides (matching
  // what BaseSection displays in the form) so the diff only contains actual
  // user changes, not every field from the merged view.
  let rawSectionValue: unknown;
  if (level === "camera" && cameraName) {
    if (profileInfo.isProfile) {
      const baseValue = getBaseCameraSectionValue(
        config,
        cameraName,
        profileInfo.actualSection,
      );
      const profileOverrides = get(config.cameras?.[cameraName], sectionPath);
      if (
        profileOverrides &&
        typeof profileOverrides === "object" &&
        baseValue &&
        typeof baseValue === "object"
      ) {
        rawSectionValue = mergeProfileOverrides(
          baseValue as object,
          profileOverrides as object,
        );
      } else {
        rawSectionValue = baseValue;
      }
    } else {
      // Use base (pre-profile) value so the diff matches what the form shows
      rawSectionValue = getBaseCameraSectionValue(
        config,
        cameraName,
        sectionPath,
      );
    }
  } else {
    rawSectionValue = get(config, sectionPath);
  }
  const rawFormData =
    rawSectionValue === undefined || rawSectionValue === null
      ? {}
      : rawSectionValue;

  // For profile sections, also hide restart-required fields to match
  // effectiveHiddenFields in BaseSection (prevents spurious deletion markers
  // for fields that are hidden from the form during profile editing).
  const resolvedHidden = resolveHiddenFieldEntries(sectionConfig.hiddenFields, {
    fullConfig: config,
    fullCameraConfig:
      level === "camera" && cameraName
        ? config.cameras?.[cameraName]
        : undefined,
    level,
    cameraName,
    formData: rawFormData as ConfigSectionData,
  });
  const hiddenFieldsForSanitize =
    profileInfo.isProfile && sectionConfig.restartRequired?.length
      ? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
      : resolvedHidden;

  // Sanitize raw form data
  const rawData = sanitizeSectionData(
    rawFormData as ConfigSectionData,
    hiddenFieldsForSanitize,
  );

  // Compute schema defaults
  const schemaDefaults = modifiedSchema
    ? applySchemaDefaults(modifiedSchema, {})
    : {};
  const effectiveDefaults = getEffectiveDefaultsForSection(
    schemaSection,
    level,
    modifiedSchema ?? undefined,
    schemaDefaults,
  );

  // Build overrides
  const overrides = buildOverrides(pendingData, rawData, effectiveDefaults);
  const sanitizedOverrides = sanitizeOverridesForSection(
    schemaSection,
    level,
    overrides,
  );

  if (
    !sanitizedOverrides ||
    typeof sanitizedOverrides !== "object" ||
    Object.keys(sanitizedOverrides as JsonObject).length === 0
  ) {
    return null;
  }

  // Compute basePath
  const basePath =
    level === "camera" && cameraName
      ? `cameras.${cameraName}.${sectionPath}`
      : sectionPath;

  // Compute updateTopic — profile definitions don't trigger hot-reload
  let updateTopic: string | undefined;
  if (profileInfo.isProfile) {
    updateTopic = undefined;
  } else if (level === "camera" && cameraName) {
    const topic = cameraUpdateTopicMap[sectionPath];
    updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined;
  } else if (globalCameraDefaultSections.has(sectionPath)) {
    const topic = cameraUpdateTopicMap[sectionPath];
    updateTopic = topic ? `config/cameras/*/${topic}` : `config/${sectionPath}`;
  } else {
    updateTopic = `config/${sectionPath}`;
  }

  // Restart detection — profile definitions never need restart
  const needsRestart = profileInfo.isProfile
    ? false
    : requiresRestartForOverrides(
        sanitizedOverrides,
        sectionConfig.restartRequired,
        true,
      );

  return {
    basePath,
    sanitizedOverrides: sanitizedOverrides as JsonObject,
    updateTopic,
    needsRestart,
    pendingDataKey,
  };
}

const mergeSectionConfig = (
  base: SectionConfig | undefined,
  overrides: Partial<SectionConfig> | undefined,
): SectionConfig =>
  mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
    const arrayResult = replaceArraysCustomizer(objValue, srcValue);
    if (arrayResult !== undefined) return arrayResult;

    if (key === "uiSchema") {
      if (objValue && srcValue) {
        return mergeWith({}, objValue, srcValue, replaceArraysCustomizer);
      }
      return srcValue ?? objValue;
    }

    return undefined;
  });

export function getSectionConfig(
  sectionKey: string,
  level: "global" | "camera" | "replay",
): SectionConfig {
  const entry = sectionConfigs[sectionKey];
  if (!entry) {
    return {};
  }

  const overrides =
    level === "global"
      ? entry.global
      : level === "replay"
        ? entry.replay
        : entry.camera;
  return mergeSectionConfig(entry.base, overrides);
}

/**
 * Resolve the effective attribute label set at a given scope. At camera
 * (and replay) scope on a dedicated LPR camera (`camera.type === "lpr"`),
 * `license_plate` is treated as a regular tracked object — not an
 * attribute — to match the backend's per-camera carve-out in
 * `frigate/video/detect.py`. Returns the full attribute list at global
 * scope and for non-LPR cameras.
 */
export function getEffectiveAttributeLabels(
  fullConfig: FrigateConfig | undefined,
  fullCameraConfig: CameraConfig | undefined,
  level: "global" | "camera" | "replay" | undefined,
): string[] {
  const all = fullConfig?.model?.all_attributes ?? [];
  if (level !== "global" && fullCameraConfig?.type === "lpr") {
    return all.filter((attr) => attr !== "license_plate");
  }
  return all;
}

/**
 * Build a `HiddenFieldContext` for the common case where a callsite has
 * `config`, an optional `cameraName`, and a level, but no per-section
 * saved form data to thread through. Resolvers that don't read `formData`
 * (which is most of them) just fall through to `fullCameraConfig` /
 * `fullConfig`.
 */
export function buildHiddenFieldContext(
  config: FrigateConfig | undefined,
  level: "global" | "camera" | "replay",
  cameraName?: string,
): HiddenFieldContext | undefined {
  if (!config) return undefined;
  return {
    fullConfig: config,
    fullCameraConfig:
      level !== "global" && cameraName
        ? config.cameras?.[cameraName]
        : undefined,
    level,
    cameraName,
  };
}

/**
 * Resolve the effective hidden-field patterns for a section. Each entry in
 * `hiddenFields` is either a literal pattern or a function that produces
 * patterns from the loaded config and scope (e.g. `filters.<attr>` for each
 * `model.all_attributes` entry on the objects section, gated by the
 * effective `objects.track` list at the current scope).
 */
export function getEffectiveHiddenFields(
  sectionKey: string,
  level: "global" | "camera" | "replay",
  ctx: HiddenFieldContext | undefined,
): string[] {
  return resolveHiddenFieldEntries(
    getSectionConfig(sectionKey, level).hiddenFields,
    ctx,
  );
}

export function resolveHiddenFieldEntries(
  entries: SectionConfig["hiddenFields"] | undefined,
  ctx: HiddenFieldContext | undefined,
): string[] {
  if (!entries || entries.length === 0) return [];
  const result: string[] = [];
  for (const entry of entries) {
    if (typeof entry === "function") {
      if (ctx) result.push(...entry(ctx));
    } else {
      result.push(entry);
    }
  }
  return result;
}

/**
 * Match a delta path against a hidden-field pattern. Supports literal prefixes
 * (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
 * matching exactly one path segment (e.g. "filters.*.mask").
 */
export function pathMatchesHiddenPattern(
  path: string,
  pattern: string,
): boolean {
  if (!pattern) return false;
  if (!pattern.includes("*")) {
    return path === pattern || path.startsWith(`${pattern}.`);
  }
  const patternSegments = pattern.split(".");
  const pathSegments = path.split(".");
  if (pathSegments.length < patternSegments.length) return false;
  for (let i = 0; i < patternSegments.length; i += 1) {
    if (patternSegments[i] === "*") continue;
    if (patternSegments[i] !== pathSegments[i]) return false;
  }
  return true;
}
