/**
 * Shared rules for Button block click tracking.
 *
 * The browser tracker in scripts/causewise.source.js exposes
 * window.causewise.track(eventName, properties) and window.causewise.version.
 * track sends the current URL itself. An empty name is stored as "custom".
 * Names are truncated to 64 characters. Properties must be a plain object;
 * arrays are ignored. track does not drop duplicate custom events, and it
 * does not queue calls made before causewise.js has loaded.
 */

export const TRACKED_BLOCKS = ["core/button", "core/buttons"] as const;
export const DEFAULT_EVENT_NAME = "button_click";
export const MAX_EVENT_NAME_LENGTH = 64;
export const MAX_PROPERTIES = 8;
export const MAX_PROPERTY_VALUE_LENGTH = 200;
export const INSTALL_FLAG = "__causewiseBlockTrackingInstalled";

const EVENT_NAME = /^[a-z][a-z0-9_]{0,63}$/;
const PROPERTY_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;

const DENIED_KEYS = new Set([
  "email",
  "e_mail",
  "mail",
  "name",
  "full_name",
  "first_name",
  "last_name",
  "firstname",
  "lastname",
  "fullname",
  "password",
  "passwd",
  "secret",
  "token",
  "api_key",
  "apikey",
  "session",
  "session_id",
  "cookie",
  "authorization",
  "auth",
  "phone",
  "address",
  "ssn",
  "credit_card",
  "card",
  "cvv",
  "cvc",
  "message",
  "body",
  "query",
  "user",
  "user_id",
  "username",
  "ip",
  "ip_address",
  "constructor",
  "prototype",
  "proto",
]);

const DENIED_KEY_PATTERN = /password|passwd|secret|token|email|ssn|cvv|cvc|credit|api_key|apikey/i;
const QUERY_SECRET = /[?&](?:access_token|id_token|token|password|passwd|secret|api_key)=/i;
const PUBLIC_KEY = /cw_[0-9a-f]{36}/i;
const JWT = /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;

export type PropertyRow = { key: string; value: string };

export type BlockTrackingAttributes = {
  causewiseTrack?: boolean;
  causewiseEvent?: string;
  causewiseProps?: PropertyRow[] | unknown;
};

export type EventNameResult =
  | { ok: true; value: string }
  | { ok: false; value: ""; message: string };

export type PropertyIssue = { index: number; message: string };

export function supportsBlock(name: string): boolean {
  return name === "core/button" || name === "core/buttons";
}

export function attributeSchema() {
  return {
    causewiseTrack: { type: "boolean" as const, default: false },
    causewiseEvent: { type: "string" as const, default: "" },
    causewiseProps: {
      type: "array" as const,
      default: [] as PropertyRow[],
      items: {
        type: "object" as const,
        properties: {
          key: { type: "string" as const },
          value: { type: "string" as const },
        },
      },
    },
  };
}

export function extendBlockSettings<T extends { attributes?: Record<string, unknown> }>(
  settings: T,
  name: string,
): T {
  if (!supportsBlock(name) || !settings || typeof settings !== "object") return settings;
  const attributes = { ...(settings.attributes ?? {}) };
  const schema = attributeSchema();
  for (const key of Object.keys(schema) as (keyof ReturnType<typeof attributeSchema>)[]) {
    if (!attributes[key]) attributes[key] = schema[key];
  }
  return { ...settings, attributes };
}

export function normalizeEventNameInput(input: string): string {
  return String(input || "")
    .toLowerCase()
    .replace(/[^a-z0-9_]/g, "")
    .slice(0, MAX_EVENT_NAME_LENGTH);
}

export function sanitizeEventName(input: unknown): EventNameResult {
  const raw = typeof input === "string" ? input.trim().toLowerCase() : "";
  if (!raw) {
    return { ok: false, value: "", message: "Enter an event name, such as button_click." };
  }
  if (raw === "pageview") {
    return {
      ok: false,
      value: "",
      message: "Page views are already tracked. Choose a name for this click, such as button_click.",
    };
  }
  if (raw.length > MAX_EVENT_NAME_LENGTH || !EVENT_NAME.test(raw)) {
    return {
      ok: false,
      value: "",
      message: "Start with a letter and use only letters, numbers, and underscores (64 characters or fewer).",
    };
  }
  return { ok: true, value: raw };
}

export function normalizePropertyKeyInput(input: string): string {
  return String(input || "")
    .replace(/[^A-Za-z0-9_]/g, "")
    .slice(0, 64);
}

export function normalizePropertyValueInput(input: string): string {
  return Array.from(String(input || "").replace(/[\u0000-\u001F]/g, ""))
    .slice(0, MAX_PROPERTY_VALUE_LENGTH)
    .join("");
}

export function isDeniedPropertyKey(key: string): boolean {
  const normalized = key.toLowerCase();
  if (DENIED_KEYS.has(normalized)) return true;
  return DENIED_KEY_PATTERN.test(normalized);
}

export function isSensitivePropertyValue(value: string): boolean {
  const trimmed = value.trim();
  if (!trimmed) return false;
  if (trimmed.includes("@")) return true;
  if (trimmed.includes("<") || trimmed.includes(">")) return true;
  const digits = trimmed.replace(/[\s-]/g, "");
  if (/^\d{13,19}$/.test(digits)) return true;
  if (/cw_api_/i.test(trimmed)) return true;
  if (PUBLIC_KEY.test(trimmed)) return true;
  if (JWT.test(trimmed)) return true;
  if (QUERY_SECRET.test(trimmed)) return true;
  return false;
}

export function normalizeStoredRows(input: unknown): PropertyRow[] {
  if (!Array.isArray(input)) return [];
  const rows: PropertyRow[] = [];
  for (const item of input) {
    if (!item || typeof item !== "object") continue;
    const record = item as { key?: unknown; value?: unknown };
    rows.push({
      key: typeof record.key === "string" ? record.key : "",
      value: typeof record.value === "string" ? record.value : "",
    });
  }
  return rows;
}

function rowsFromUnknown(input: unknown): PropertyRow[] {
  if (Array.isArray(input)) return normalizeStoredRows(input);
  if (!input || typeof input !== "object") return [];
  const rows: PropertyRow[] = [];
  for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
    if (typeof value === "string" || (typeof value === "number" && Number.isFinite(value)) || typeof value === "boolean") {
      rows.push({ key, value: typeof value === "boolean" ? (value ? "true" : "false") : String(value) });
    }
  }
  return rows;
}

export function sanitizeProperties(input: unknown): {
  props: Record<string, string> | null;
  issues: PropertyIssue[];
} {
  const rows = rowsFromUnknown(input);
  const props: Record<string, string> = {};
  const issues: PropertyIssue[] = [];
  const seen = new Set<string>();

  rows.forEach((row, index) => {
    const key = normalizePropertyKeyInput(row.key).trim();
    const value = normalizePropertyValueInput(row.value).trim();
    if (!key && !value) return;
    if (Object.keys(props).length >= MAX_PROPERTIES) {
      issues.push({ index, message: "CauseWise keeps up to 8 properties." });
      return;
    }
    if (!PROPERTY_KEY.test(key) || isDeniedPropertyKey(key)) {
      issues.push({
        index,
        message: isDeniedPropertyKey(key)
          ? "That property name can carry personal or secret data, so it is not sent."
          : "Property names start with a letter and use letters, numbers, and underscores.",
      });
      return;
    }
    if (seen.has(key)) {
      issues.push({ index, message: "Use each property name once." });
      return;
    }
    if (!value || isSensitivePropertyValue(value)) {
      issues.push({
        index,
        message: isSensitivePropertyValue(row.value)
          ? "That value looks like personal or secret data, so it is not sent."
          : "Enter a value of 1–200 characters.",
      });
      return;
    }
    seen.add(key);
    props[key] = value;
  });

  return { props: Object.keys(props).length > 0 ? props : null, issues };
}

export function rowsForAttribute(rows: PropertyRow[]): { rows: PropertyRow[]; message: string } {
  const saved: PropertyRow[] = [];
  const seen = new Set<string>();
  let message = "";

  for (const row of rows) {
    const key = normalizePropertyKeyInput(row.key);
    const value = normalizePropertyValueInput(row.value).trim();
    if (!key && !value) continue;
    if (saved.length >= MAX_PROPERTIES) {
      message = message || "CauseWise keeps up to 8 properties.";
      continue;
    }
    if (key && (!PROPERTY_KEY.test(key) || isDeniedPropertyKey(key))) {
      message = isDeniedPropertyKey(key)
        ? "That property name can carry personal or secret data, so it is not saved."
        : "Property names start with a letter and use letters, numbers, and underscores.";
      continue;
    }
    if (key && seen.has(key)) {
      message = message || "Use each property name once.";
      continue;
    }
    if (value && isSensitivePropertyValue(value)) {
      message = "That value looks like personal or secret data, so it is not saved.";
      if (key) {
        seen.add(key);
        saved.push({ key, value: "" });
      }
      continue;
    }
    if (key) seen.add(key);
    saved.push({ key, value });
  }

  return { rows: saved, message };
}

export function attributesAfterToggle(
  attributes: BlockTrackingAttributes,
  enabled: boolean,
): Partial<BlockTrackingAttributes> {
  if (!enabled) return { causewiseTrack: false };
  const normalized = normalizeEventNameInput(attributes.causewiseEvent ?? "");
  return {
    causewiseTrack: true,
    causewiseEvent: normalized || DEFAULT_EVENT_NAME,
    causewiseProps: rowsForAttribute(normalizeStoredRows(attributes.causewiseProps)).rows,
  };
}

export function trackingPayload(
  attributes: BlockTrackingAttributes,
): { event: string; props: Record<string, string> | null } | null {
  if (!attributes.causewiseTrack) return null;
  const name = sanitizeEventName(attributes.causewiseEvent ?? "");
  if (!name.ok) return null;
  return { event: name.value, props: sanitizeProperties(attributes.causewiseProps).props };
}

const NOT_COLLECTED =
  "CauseWise does not read the button text, the link address, or anything typed into a form.";

export function trackingExplanation(
  blockName: string,
  attributes: BlockTrackingAttributes,
  siteTrackingEnabled: boolean,
): string {
  const site = siteTrackingEnabled
    ? ""
    : "CauseWise tracking is turned off for this site. Turn it on under Settings → CauseWise Analytics. Until then, this block keeps working and no click is recorded. ";
  if (!attributes.causewiseTrack) {
    const which =
      blockName === "core/buttons"
        ? "Turn this on to record a click in this button group. The buttons keep their links and styles. "
        : "Turn this on to record a click on this button. The button keeps its link and style. ";
    return site + which + NOT_COLLECTED;
  }
  const name = sanitizeEventName(attributes.causewiseEvent ?? "");
  const nested =
    blockName === "core/buttons"
      ? "A button inside this group that has its own CauseWise event is counted on that button only, so the same click is not sent twice. "
      : "If this button is inside a Buttons block that also tracks clicks, only this button’s event is sent. ";
  const ready = name.ok
    ? `A click sends one ${name.value} event through the CauseWise tracker already on this site. `
    : "Choose a valid event name before clicks are recorded. ";
  return (
    site +
    ready +
    nested +
    NOT_COLLECTED +
    " If that tracker is not available, the click still works and nothing is sent. The page address is included automatically. Properties below are labels you type here."
  );
}

export function trackingSummary(attributes: BlockTrackingAttributes): string {
  const payload = trackingPayload(attributes);
  if (!payload) return "Nothing will be sent until tracking is on and the event name is valid.";
  if (!payload.props) return `Each click records one ${payload.event} event.`;
  const labels = Object.entries(payload.props)
    .map(([key, value]) => `${key} = ${value}`)
    .join(", ");
  return `Each click records one ${payload.event} event with ${labels}.`;
}

export function safeSettingsUrl(input: unknown): string {
  if (typeof input !== "string" || !input.trim()) return "";
  try {
    const url = new URL(input);
    if (url.protocol !== "https:" && url.protocol !== "http:") return "";
    return url.toString();
  } catch {
    return "";
  }
}

export type ElementLike = {
  nodeType?: number;
  tagName?: string;
  className?: string;
  parentElement?: ElementLike | null;
  getAttribute?: (name: string) => string | null;
  hasAttribute?: (name: string) => boolean;
};

export type ClickLike = {
  target?: ElementLike | null;
  button?: number;
  causewiseBlockTracked?: boolean;
  preventDefault?: () => void;
};

export type Tracker = {
  track: (eventName: string, properties?: Record<string, string>) => void;
};

export type TrackResult = { sent: boolean; reason: string };

const seenEvents = new WeakSet<object>();

function readAttr(el: ElementLike, name: string): string | null {
  if (typeof el.getAttribute !== "function") return null;
  if (typeof el.hasAttribute === "function" && !el.hasAttribute(name)) return null;
  const value = el.getAttribute(name);
  if (value === null || value === undefined) return null;
  return String(value);
}

function elementChain(target: ElementLike | null): ElementLike[] {
  const nodes: ElementLike[] = [];
  let el = target?.nodeType === 3 ? (target.parentElement ?? null) : target;
  while (el) {
    nodes.push(el);
    el = el.parentElement ?? null;
  }
  return nodes;
}

function isEditorSurface(el: ElementLike): boolean {
  return typeof el.className === "string" && el.className.split(/\s+/).includes("block-editor-writing-flow");
}

function isFormControl(el: ElementLike): boolean {
  const tag = (el.tagName || "").toUpperCase();
  return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || tag === "OPTION";
}

function parsePropsAttribute(raw: string | null): unknown {
  if (!raw) return undefined;
  try {
    return JSON.parse(raw) as unknown;
  } catch {
    return undefined;
  }
}

export type FindResult =
  | { kind: "none" }
  | { kind: "blocked" }
  | { kind: "match"; eventName: string; props: Record<string, string> | null };

export function findTrackingTarget(target: ElementLike | null): FindResult {
  const nodes = elementChain(target);
  if (nodes.length === 0) return { kind: "none" };
  if (nodes.some(isEditorSurface)) return { kind: "blocked" };

  for (const el of nodes) {
    if (isFormControl(el)) {
      const wrapped = nodes.some((node) => readAttr(node, "data-causewise-event") !== null);
      return wrapped ? { kind: "blocked" } : { kind: "none" };
    }
    const raw = readAttr(el, "data-causewise-event");
    if (raw === null) continue;
    const name = sanitizeEventName(raw);
    if (!name.ok) return { kind: "blocked" };
    return {
      kind: "match",
      eventName: name.value,
      props: sanitizeProperties(parsePropsAttribute(readAttr(el, "data-causewise-props"))).props,
    };
  }
  return { kind: "none" };
}

function markEvent(event: ClickLike) {
  try {
    event.causewiseBlockTracked = true;
  } catch {
    // Some event objects reject new properties. The WeakSet still covers this copy.
  }
  seenEvents.add(event);
}

function eventAlreadyUsed(event: ClickLike): boolean {
  return !!event.causewiseBlockTracked || seenEvents.has(event);
}

export function trackClick(event: ClickLike | null | undefined, getTracker: () => Tracker | null): TrackResult {
  try {
    if (!event) return { sent: false, reason: "ignored" };
    if (eventAlreadyUsed(event)) return { sent: false, reason: "duplicate" };
    if (typeof event.button === "number" && event.button !== 0) {
      return { sent: false, reason: "not-primary" };
    }

    const found = findTrackingTarget(event.target ?? null);
    if (found.kind === "none") return { sent: false, reason: "none" };
    markEvent(event);
    if (found.kind === "blocked") return { sent: false, reason: "blocked" };

    let tracker: Tracker | null = null;
    try {
      tracker = getTracker();
    } catch {
      return { sent: false, reason: "missing-tracker" };
    }
    if (!tracker || typeof tracker.track !== "function") {
      return { sent: false, reason: "missing-tracker" };
    }

    if (found.props) tracker.track(found.eventName, found.props);
    else tracker.track(found.eventName);
    return { sent: true, reason: "sent" };
  } catch {
    return { sent: false, reason: "error" };
  }
}

type ListenerHost = {
  addEventListener: (type: string, listener: (event: ClickLike) => void, capture?: boolean) => void;
};

export function installBlockTracking(getTracker: () => Tracker | null, target?: ListenerHost) {
  const host = (target ?? (typeof document === "undefined" ? null : document)) as
    | (ListenerHost & { [INSTALL_FLAG]?: boolean })
    | null;
  if (!host || typeof host.addEventListener !== "function") return;
  if (host[INSTALL_FLAG]) return;
  try {
    host[INSTALL_FLAG] = true;
    host.addEventListener(
      "click",
      (event) => {
        trackClick(event, getTracker);
      },
      false,
    );
  } catch {
    try {
      delete host[INSTALL_FLAG];
    } catch {
      // The host rejected the flag. A later copy may install one listener.
    }
  }
}
