import axios from "axios";

type JsonObject = Record<string, unknown>;

type SnapshotItem = {
  key: string;
  serialized: string;
  data: JsonObject;
  trackedRows: TrackedRow[];
  trackedSignature: string;
};

type TrackedRow = {
  key: string;
  product: string;
  onHand: string;
  inventory: string;
};

const POLL_MS = 60_000;
const CW_BASE_URL =
  process.env.CW_BASE_URL ??
  "https://ttscw.totaltech.net/v4_6_release/apis/3.0";
const ENDPOINT = "/procurement/adjustments?pageSize=1000";

const CW_BASIC_TOKEN = process.env.CW_BASIC_TOKEN;
const CW_CLIENT_ID = process.env.CW_CLIENT_ID;

if (!CW_BASIC_TOKEN || !CW_CLIENT_ID) {
  console.error(
    "Missing required env vars: CW_BASIC_TOKEN and/or CW_CLIENT_ID",
  );
  process.exit(1);
}

const cw = axios.create({
  baseURL: CW_BASE_URL,
  headers: {
    Authorization: `Basic ${CW_BASIC_TOKEN}`,
    clientId: CW_CLIENT_ID,
    "Content-Type": "application/json",
  },
  timeout: 30_000,
});

const isObject = (value: unknown): value is JsonObject =>
  typeof value === "object" && value !== null && !Array.isArray(value);

const stableStringify = (value: unknown): string => {
  if (Array.isArray(value)) {
    const entries = value.map((entry) => stableStringify(entry)).sort();

    return `[${entries.join(",")}]`;
  }

  if (isObject(value)) {
    const keys = Object.keys(value).sort();
    const pairs = keys.map(
      (key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`,
    );

    return `{${pairs.join(",")}}`;
  }

  return JSON.stringify(value);
};

const toObject = (value: unknown): JsonObject => {
  if (!isObject(value)) return {};

  return value;
};

const readPathValue = (obj: JsonObject, path: string): unknown => {
  const parts = path.split(".");
  let current: unknown = obj;

  for (const part of parts) {
    if (!isObject(current)) return null;
    current = current[part];
  }

  return current;
};

const asKey = (value: unknown): string | null => {
  if (typeof value === "string" && value.length > 0) return value;
  if (typeof value === "number") return value.toString();

  return null;
};

const firstValue = (obj: JsonObject, paths: string[]): unknown => {
  for (const path of paths) {
    const value = readPathValue(obj, path);
    if (value === null || value === undefined || value === "") continue;

    return value;
  }

  return null;
};

const itemKey = (adjustment: JsonObject): string => {
  const keyPaths = [
    "id",
    "adjustmentId",
    "procurementAdjustmentId",
    "recordId",
    "recId",
    "_info.id",
    "_info.href",
  ];

  for (const keyPath of keyPaths) {
    const keyValue = asKey(readPathValue(adjustment, keyPath));
    if (keyValue) return keyValue;
  }

  return `anon:${stableStringify(adjustment)}`;
};

const summarize = (adjustment: JsonObject) => {
  const pick = (...paths: string[]) => {
    for (const path of paths) {
      const value = readPathValue(adjustment, path);
      if (value !== null && value !== undefined && value !== "") return value;
    }

    return "-";
  };

  return {
    id: pick("id", "adjustmentId", "procurementAdjustmentId", "recordId"),
    type: pick(
      "type.name",
      "type.identifier",
      "type.id",
      "type",
      "adjustmentType.name",
      "adjustmentType",
      "transactionType.name",
      "transactionType",
    ),
    amount: pick("amount", "value", "total", "quantity"),
    status: pick("status.name", "status", "state"),
    description: pick("description", "summary", "notes"),
    updatedBy: pick("_info.updatedBy", "updatedBy", "lastUpdatedBy"),
    lastUpdated: pick("_info.lastUpdated", "lastUpdated", "dateUpdated"),
  };
};

const normalizeValue = (value: unknown): string => {
  if (value === null || value === undefined || value === "") return "-";

  return toDisplayValue(value);
};

const isMeaningfulQuantity = (value: string) => value !== "-";

const toTrackedRow = (detail: JsonObject): TrackedRow | null => {
  const productValue = firstValue(detail, [
    "product.name",
    "product.identifier",
    "product.id",
    "item.name",
    "item.identifier",
    "item.id",
    "catalogItem.name",
    "catalogItem.identifier",
    "catalogItem.id",
    "productIdentifier",
    "productName",
    "sku",
    "identifier",
    "id",
  ]);

  const onHandValue = firstValue(detail, [
    "onHand",
    "onHandQty",
    "onHandQuantity",
    "qtyOnHand",
    "quantityOnHand",
    "quantity.onHand",
  ]);

  const inventoryValue = firstValue(detail, [
    "inventory",
    "inventoryQty",
    "inventoryLevel",
    "quantity",
    "qty",
  ]);

  const onHand = normalizeValue(onHandValue);
  const inventory = normalizeValue(inventoryValue);
  const hasMeaningfulQuantity =
    isMeaningfulQuantity(onHand) || isMeaningfulQuantity(inventory);
  if (!hasMeaningfulQuantity) return null;

  const product = normalizeValue(productValue);
  const rowKey = `${product}|${onHand}|${inventory}`;

  return {
    key: rowKey,
    product,
    onHand,
    inventory,
  };
};

const getTrackedRows = (adjustment: JsonObject): TrackedRow[] => {
  const detailCandidates = [
    readPathValue(adjustment, "adjustmentDetails"),
    readPathValue(adjustment, "details"),
    readPathValue(adjustment, "lineItems"),
  ];

  for (const candidate of detailCandidates) {
    if (!Array.isArray(candidate)) continue;

    const rows = candidate
      .map((entry) => toTrackedRow(toObject(entry)))
      .filter((entry): entry is TrackedRow => entry !== null)
      .sort((a, b) => a.key.localeCompare(b.key));

    if (rows.length > 0) return rows;
  }

  const rootRow = toTrackedRow(adjustment);
  if (!rootRow) return [];

  return [rootRow];
};

const toDisplayValue = (value: unknown): string => {
  if (value === null || value === undefined || value === "") return "-";
  if (
    typeof value === "string" ||
    typeof value === "number" ||
    typeof value === "boolean"
  ) {
    return String(value);
  }

  if (Array.isArray(value)) {
    return `[${value.map((entry) => toDisplayValue(entry)).join(",")}]`;
  }

  if (!isObject(value)) return String(value);

  const commonObjectPaths = ["name", "identifier", "id", "value", "code"];
  for (const path of commonObjectPaths) {
    const objectValue = readPathValue(value, path);
    if (objectValue === null || objectValue === undefined || objectValue === "")
      continue;
    if (typeof objectValue === "object") continue;

    return String(objectValue);
  }

  return stableStringify(value);
};

const clean = (value: unknown): string => {
  if (value === null || value === undefined || value === "") return "-";

  return toDisplayValue(value);
};

const diffPaths = (
  before: unknown,
  after: unknown,
  currentPath = "",
  paths: string[] = [],
  maxPaths = 6,
): string[] => {
  if (paths.length >= maxPaths) return paths;

  const beforeIsObject = isObject(before);
  const afterIsObject = isObject(after);
  if (!beforeIsObject || !afterIsObject) {
    if (stableStringify(before) === stableStringify(after)) return paths;
    const label = currentPath || "(root)";
    paths.push(label);

    return paths;
  }

  const keys = [
    ...new Set([...Object.keys(before), ...Object.keys(after)]),
  ].sort();
  for (const key of keys) {
    if (paths.length >= maxPaths) return paths;

    const nextPath = currentPath ? `${currentPath}.${key}` : key;
    diffPaths(before[key], after[key], nextPath, paths, maxPaths);
  }

  return paths;
};

const toLine = (kind: "+" | "~" | "-", adjustment: JsonObject): string => {
  const s = summarize(adjustment);

  return `${kind} id=${clean(s.id)} type=${clean(s.type)} status=${clean(s.status)} amount=${clean(
    s.amount,
  )} by=${clean(s.updatedBy)} desc=${clean(s.description)}`;
};

const updatedToLine = (before: JsonObject, after: JsonObject): string => {
  const prev = summarize(before);
  const next = summarize(after);

  const changed: string[] = [];

  if (clean(prev.status) !== clean(next.status)) {
    changed.push(`status:${clean(prev.status)}→${clean(next.status)}`);
  }

  if (clean(prev.amount) !== clean(next.amount)) {
    changed.push(`amount:${clean(prev.amount)}→${clean(next.amount)}`);
  }

  if (clean(prev.updatedBy) !== clean(next.updatedBy)) {
    changed.push(`by:${clean(prev.updatedBy)}→${clean(next.updatedBy)}`);
  }

  if (clean(prev.description) !== clean(next.description)) {
    changed.push(`desc:${clean(prev.description)}→${clean(next.description)}`);
  }

  if (clean(prev.lastUpdated) !== clean(next.lastUpdated)) {
    changed.push(
      `updated:${clean(prev.lastUpdated)}→${clean(next.lastUpdated)}`,
    );
  }

  const noisyFields = new Set(["_info.lastUpdated"]);
  const rawDiffs = diffPaths(before, after).filter(
    (path) => !noisyFields.has(path),
  );
  const rawDelta =
    rawDiffs.length > 0 ? `fields:${rawDiffs.join(",")}` : "content changed";
  const delta = changed.length > 0 ? changed.join(" | ") : rawDelta;

  return `~ id=${clean(next.id)} type=${clean(next.type)} ${delta}`;
};

const formatTracked = (row: TrackedRow) =>
  `product=${row.product} onHand=${row.onHand} inventory=${row.inventory}`;

const trackedAddedLine = (item: SnapshotItem) => {
  const base = summarize(item.data);
  const rows = item.trackedRows.slice(0, 3).map(formatTracked).join(" ; ");
  const more =
    item.trackedRows.length > 3
      ? ` ; +${item.trackedRows.length - 3} more`
      : "";

  return `+ id=${clean(base.id)} type=${clean(base.type)} ${rows}${more}`;
};

const trackedRemovedLine = (item: SnapshotItem) => {
  const base = summarize(item.data);
  const rows = item.trackedRows.slice(0, 3).map(formatTracked).join(" ; ");
  const more =
    item.trackedRows.length > 3
      ? ` ; +${item.trackedRows.length - 3} more`
      : "";

  return `- id=${clean(base.id)} type=${clean(base.type)} ${rows}${more}`;
};

const trackedUpdatedLine = (
  beforeItem: SnapshotItem,
  afterItem: SnapshotItem,
) => {
  const base = summarize(afterItem.data);
  const beforeMap = new Map(
    beforeItem.trackedRows.map((row) => [row.product, row]),
  );
  const afterMap = new Map(
    afterItem.trackedRows.map((row) => [row.product, row]),
  );
  const productKeys = [
    ...new Set([...beforeMap.keys(), ...afterMap.keys()]),
  ].sort();

  const deltas: string[] = [];
  for (const product of productKeys) {
    const prev = beforeMap.get(product);
    const next = afterMap.get(product);
    if (!prev && next) {
      deltas.push(
        `${product} added(onHand=${next.onHand},inventory=${next.inventory})`,
      );
      continue;
    }
    if (prev && !next) {
      deltas.push(`${product} removed`);
      continue;
    }
    if (!prev || !next) continue;
    const onHandChanged = prev.onHand !== next.onHand;
    const inventoryChanged = prev.inventory !== next.inventory;
    if (!onHandChanged && !inventoryChanged) continue;

    const parts: string[] = [];
    onHandChanged ? parts.push(`onHand:${prev.onHand}→${next.onHand}`) : null;
    inventoryChanged
      ? parts.push(`inventory:${prev.inventory}→${next.inventory}`)
      : null;
    deltas.push(`${product} ${parts.join(",")}`);
  }

  const shown = deltas.slice(0, 3).join(" ; ");
  const more = deltas.length > 3 ? ` ; +${deltas.length - 3} more` : "";
  const changes = shown || "inventory/on-hand changed";

  return `~ id=${clean(base.id)} type=${clean(base.type)} ${changes}${more}`;
};

const toSnapshot = (rows: unknown[]): SnapshotItem[] =>
  rows.map((row) => {
    const data = toObject(row);
    const trackedRows = getTrackedRows(data);
    const trackedSignature = stableStringify(trackedRows);

    return {
      key: itemKey(data),
      serialized: stableStringify(data),
      data,
      trackedRows,
      trackedSignature,
    };
  });

const fetchAdjustments = async (): Promise<unknown[]> => {
  const response = await cw.get(ENDPOINT);
  const payload = response.data;

  if (Array.isArray(payload)) return payload;
  if (isObject(payload) && Array.isArray(payload.data)) return payload.data;

  return [];
};

const now = () => new Date().toISOString();

let previous = new Map<string, SnapshotItem>();

const run = async () => {
  console.log(
    `[${now()}] Watching ${CW_BASE_URL}${ENDPOINT} every ${POLL_MS / 1000}s`,
  );

  while (true) {
    try {
      const rows = await fetchAdjustments();
      const snapshotItems = toSnapshot(rows);
      const current = new Map(snapshotItems.map((item) => [item.key, item]));

      if (previous.size === 0) {
        previous = current;
        console.log(
          `[${now()}] Baseline captured (${current.size} adjustments)`,
        );
        await Bun.sleep(POLL_MS);
        continue;
      }

      const added: SnapshotItem[] = [];
      const removed: SnapshotItem[] = [];
      const updated: Array<{ before: SnapshotItem; after: SnapshotItem }> = [];

      for (const [key, item] of current) {
        const previousItem = previous.get(key);

        if (!previousItem) {
          if (item.trackedRows.length === 0) continue;
          added.push(item);
          continue;
        }

        const hasTrackedRows =
          item.trackedRows.length > 0 || previousItem.trackedRows.length > 0;
        if (!hasTrackedRows) continue;

        if (previousItem.trackedSignature !== item.trackedSignature) {
          updated.push({ before: previousItem, after: item });
        }
      }

      for (const [key, item] of previous) {
        if (!current.has(key) && item.trackedRows.length > 0)
          removed.push(item);
      }

      if (added.length > 0 || updated.length > 0 || removed.length > 0) {
        console.log(`\n[${now()}] Changes detected:`);
        console.log(`- added: ${added.length}`);
        console.log(`- updated: ${updated.length}`);
        console.log(`- removed: ${removed.length}`);

        if (added.length > 0) {
          console.log("\nAdded:");
          for (const item of added) {
            console.log(trackedAddedLine(item));
          }
        }

        if (updated.length > 0) {
          console.log("\nUpdated:");
          for (const item of updated) {
            console.log(trackedUpdatedLine(item.before, item.after));
          }
        }

        if (removed.length > 0) {
          console.log("\nRemoved:");
          for (const item of removed) {
            console.log(trackedRemovedLine(item));
          }
        }
      }

      if (added.length === 0 && updated.length === 0 && removed.length === 0) {
        console.log(`[${now()}] No changes (${current.size} adjustments)`);
      }

      previous = current;
    } catch (error) {
      if (axios.isAxiosError(error)) {
        console.error(
          `[${now()}] Poll failed: ${error.response?.status ?? "ERR"}`,
          error.message,
        );
      } else {
        console.error(`[${now()}] Poll failed:`, error);
      }
    }

    await Bun.sleep(POLL_MS);
  }
};

run().catch((error) => {
  console.error("Watcher crashed:", error);
  process.exit(1);
});
