import { ipcMain, shell, type WebContents } from "electron";
import type { Engine } from "../src/engine.js";
import { canonicalize, MAX_DATE_TIMESTAMP, type OfferRefreshRequest, type PurchasePlan, type SearchHistory } from "../shared/contract.js";
import type { ElectronBrowserHub } from "./electronBrowserHub.js";
import type { JsonPlanStorage } from "./planStorage.js";
import type { JsonSearchHistoryStorage } from "./historyStorage.js";

interface DesktopIpcDependencies {
  engine: Engine;
  browser: ElectronBrowserHub;
  planStorage: JsonPlanStorage;
  historyStorage: JsonSearchHistoryStorage;
}

export function registerDesktopIpc({ engine, browser, planStorage, historyStorage }: DesktopIpcDependencies): () => void {
  const registrations: string[] = [];
  const handle = (channel: string, listener: (event: Electron.IpcMainInvokeEvent, ...args: never[]) => unknown) => {
    ipcMain.handle(channel, (event, ...args) => {
      assertTrustedRenderer(event.sender);
      return listener(event, ...args as never[]);
    });
    registrations.push(channel);
  };

  handle("engine:stores", () => engine.stores());
  handle("engine:search", (_event, storeId: string, query: string) => {
    assertString(storeId, "store id");
    assertString(query, "query");
    return engine.search(storeId, query);
  });
  handle("engine:refreshOffer", (_event, request: OfferRefreshRequest) => {
    assertRefreshRequest(request);
    return engine.refreshOffer(request);
  });
  handle("plan:load", () => planStorage.loadResponse());
  handle("plan:save", async (_event, plan: PurchasePlan) => {
    assertPlan(plan);
    return { updatedAt: await planStorage.set(plan) };
  });
  handle("history:load", () => historyStorage.loadResponse());
  handle("history:save", async (_event, history: SearchHistory) => {
    assertSearchHistory(history);
    return { updatedAt: await historyStorage.set(history) };
  });
  handle("solve:show", (_event, storeId: string) => {
    assertString(storeId, "store id");
    return browser.showSolve(storeId);
  });
  handle("solve:dismiss", (_event, storeId: string) => {
    assertString(storeId, "store id");
    return browser.dismissSolve(storeId);
  });

  return () => {
    for (const channel of registrations) ipcMain.removeHandler(channel);
  };
}

export async function openExternalUrl(rawUrl: string): Promise<string> {
  const target = assertExternalUrl(rawUrl);
  await shell.openExternal(target);
  return target;
}

export function assertExternalUrl(rawUrl: string): string {
  const url = new URL(rawUrl);
  if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("external URL must use HTTP(S)");
  return url.toString();
}

function assertTrustedRenderer(sender: WebContents): void {
  if (!sender.getURL().startsWith("app://app/")) throw new Error("untrusted IPC sender");
}

function assertString(value: unknown, label: string): asserts value is string {
  if (typeof value !== "string" || value.length === 0) throw new Error(`invalid ${label}`);
}

function assertRefreshRequest(value: unknown): asserts value is OfferRefreshRequest {
  if (!value || typeof value !== "object") throw new Error("invalid refresh request");
  const request = value as Partial<OfferRefreshRequest>;
  assertString(request.store, "refresh store");
  assertExternalUrl(assertStringValue(request.url, "refresh URL"));
  assertString(request.expectedPartNumber, "expected part number");
  if (request.sourceRef !== null && typeof request.sourceRef !== "string") throw new Error("invalid source ref");
  if (request.expectedBrand !== null && typeof request.expectedBrand !== "string") throw new Error("invalid expected brand");
}

function assertPlan(value: unknown): asserts value is PurchasePlan {
  if (!value || typeof value !== "object") throw new Error("invalid purchase plan");
  const plan = value as Partial<PurchasePlan>;
  if (plan.schemaVersion !== 1 || !Array.isArray(plan.requirements)) throw new Error("invalid purchase plan");
  if (plan.storeSort !== "lines" && plan.storeSort !== "subtotal" && plan.storeSort !== "alphabetical") {
    throw new Error("invalid purchase plan sort");
  }
}

function assertSearchHistory(value: unknown): asserts value is SearchHistory {
  if (!value || typeof value !== "object") throw new Error("invalid search history");
  const history = value as Partial<SearchHistory>;
  if (history.schemaVersion !== 1 || !Array.isArray(history.entries) || history.entries.length > 20) {
    throw new Error("invalid search history");
  }
  const seen = new Set<string>();
  let previousTime = Number.POSITIVE_INFINITY;
  for (const entry of history.entries) {
    if (!entry || typeof entry !== "object") throw new Error("invalid search history entry");
    const candidate = entry as { query?: unknown; canonical?: unknown; searchedAt?: unknown };
    if (typeof candidate.query !== "string" || candidate.query.length === 0 || candidate.query.length > 200) {
      throw new Error("invalid search history query");
    }
    if (typeof candidate.canonical !== "string" || canonicalize(candidate.query) !== candidate.canonical || seen.has(candidate.canonical)) {
      throw new Error("invalid search history canonical value");
    }
    if (typeof candidate.searchedAt !== "number" || !Number.isInteger(candidate.searchedAt) || candidate.searchedAt < 0 || candidate.searchedAt > MAX_DATE_TIMESTAMP || candidate.searchedAt > previousTime) {
      throw new Error("invalid search history timestamp");
    }
    seen.add(candidate.canonical);
    previousTime = candidate.searchedAt;
  }
}

function assertStringValue(value: unknown, label: string): string {
  assertString(value, label);
  return value;
}
