import Fastify from "fastify";
import fastifyStatic from "@fastify/static";
import { config } from "./core/config.js";
import { dropLegacyMeasurementTables, openDatabase } from "./core/db.js";
import { PoliteHttp } from "./core/politeHttp.js";
import { BrowserHub } from "./core/browserHub.js";
import { ResponseCache } from "./core/cache.js";
import { validateOutUrl } from "./core/redirect.js";
import { PurchasePlanRepository, PurchasePlanSchema } from "./core/purchasePlan.js";
import { SearchHistoryRepository, SearchHistorySchema } from "./core/searchHistory.js";
import { getAdapter } from "./core/registry.js";
import { createEngine, EngineRequestError } from "./engine.js";
import type {
  FetchContext,
  HostCapabilities,
  OfferRefreshRequest,
} from "./core/types.js";
import type {
  PurchasePlanResponse,
  SavePurchasePlanRequest,
  SaveSearchHistoryRequest,
  SearchHistoryResponse,
} from "../shared/contract.js";

// Composition root: everything with state or configuration is built here, once,
// and handed down — nothing below this file reads env or holds a singleton.
const app = Fastify({ logger: { level: "info" } });
const db = openDatabase(config.dbPath);
dropLegacyMeasurementTables(db);
const cache = new ResponseCache(db);
const purchasePlan = new PurchasePlanRepository(db);
const searchHistory = new SearchHistoryRepository(db);
const browser = new BrowserHub(config.browser);
const host: HostCapabilities = {
  browserFetch: browser,
  // BrowserHub already keeps the challenged page in front for the noVNC UI.
  // Phase C will make the engine call this seam; the POC's retry flow stays unchanged now.
  async presentInteractiveSession(_storeId, { until }) {
    while (!(await until())) await new Promise((resolve) => setTimeout(resolve, 250));
  },
  openExternal(url) {
    return url;
  },
  storage: purchasePlan,
};
const ctx: FetchContext = { http: new PoliteHttp(), browser: host.browserFetch };
const engine = createEngine({ ...ctx, cache });

app.setErrorHandler((error, _request, reply) => {
  if (error instanceof EngineRequestError) {
    return reply.code(error.statusCode).send({ error: error.message });
  }
  return reply.send(error);
});

app.register(fastifyStatic, { root: config.publicDir });

app.get("/api/stores", async () => engine.stores());

app.get("/api/purchase-plan", async (_req, reply): Promise<PurchasePlanResponse> => {
  reply.header("Cache-Control", "no-store");
  const saved = await host.storage.get();
  return saved ? { plan: saved.plan, updatedAt: saved.updatedAt } : { plan: null, updatedAt: null };
});

app.put<{ Body: SavePurchasePlanRequest }>(
  "/api/purchase-plan",
  {
    schema: {
      body: {
        type: "object",
        additionalProperties: false,
        required: ["plan"],
        properties: { plan: { type: "object" } },
      },
    },
  },
  async (req, reply) => {
    const parsed = PurchasePlanSchema.safeParse(req.body.plan);
    if (!parsed.success) return reply.code(400).send({ error: "invalid purchase plan" });
    return { updatedAt: await host.storage.set(parsed.data) };
  },
);

app.get("/api/search-history", async (_req, reply): Promise<SearchHistoryResponse> => {
  reply.header("Cache-Control", "no-store");
  const saved = searchHistory.get();
  return saved ?? { history: null, updatedAt: null };
});

app.put<{ Body: SaveSearchHistoryRequest }>(
  "/api/search-history",
  {
    schema: {
      body: {
        type: "object",
        additionalProperties: false,
        required: ["history"],
        properties: { history: { type: "object" } },
      },
    },
  },
  async (req, reply) => {
    const parsed = SearchHistorySchema.safeParse(req.body.history);
    if (!parsed.success) return reply.code(400).send({ error: "invalid search history" });
    return { updatedAt: searchHistory.set(parsed.data) };
  },
);

// Route input schemas: Fastify validates at the edge. The part-number length
// rule itself stays in the portable engine and works on the canonical form.
const rawQuerySchema = { type: "string", minLength: 1, maxLength: 200 } as const;

app.get<{ Params: { store: string }; Querystring: { q: string } }>(
  "/api/search/:store",
  {
    schema: {
      params: {
        type: "object",
        required: ["store"],
        properties: { store: { type: "string", minLength: 1, maxLength: 40 } },
      },
      querystring: {
        type: "object",
        required: ["q"],
        properties: { q: rawQuerySchema },
      },
    },
  },
  async (req) => engine.search(req.params.store, req.query.q),
);

app.post<{ Body: OfferRefreshRequest }>(
  "/api/offers/refresh",
  {
    schema: {
      body: {
        type: "object",
        additionalProperties: false,
        required: ["store", "url", "sourceRef", "expectedBrand", "expectedPartNumber"],
        properties: {
          store: { type: "string", minLength: 1, maxLength: 40 },
          url: { type: "string", minLength: 1, maxLength: 2048 },
          sourceRef: { anyOf: [{ type: "string", minLength: 1, maxLength: 200 }, { type: "null" }] },
          expectedBrand: { anyOf: [{ type: "string", minLength: 1, maxLength: 120 }, { type: "null" }] },
          expectedPartNumber: { type: "string", minLength: 1, maxLength: 120 },
        },
      },
    },
  },
  async (req) => engine.refreshOffer(req.body),
);

/** Validated outbound redirect; no click or search data is retained. */
app.get<{ Querystring: { store: string; url: string } }>(
  "/out",
  {
    schema: {
      querystring: {
        type: "object",
        required: ["store", "url"],
        properties: {
          store: { type: "string", minLength: 1, maxLength: 40 },
          url: { type: "string", minLength: 1, maxLength: 2048 },
        },
      },
    },
  },
  async (req, reply) => {
    const adapter = getAdapter(req.query.store);
    const target = adapter ? validateOutUrl(adapter, req.query.url) : null;
    if (!adapter || !target) {
      return reply.code(400).send({ error: "invalid outbound link" });
    }
    const decoratedTarget = await host.openExternal(target);
    return reply.redirect(decoratedTarget, 302);
  },
);

app.get("/health", async () => ({
  stores: Object.fromEntries(engine.counters),
}));

app.listen({ port: config.port, host: config.host }).then(() => {
  app.log.info(`→ http://${config.host}:${config.port}`);
});

let shuttingDown = false;
async function shutdown(signal: string): Promise<void> {
  if (shuttingDown) return;
  shuttingDown = true;
  app.log.info(`${signal} received, shutting down`);
  await app.close().catch(() => {});
  await browser.close().catch(() => {}); // detaches CDP; the resident Chrome keeps running
  db.close();
  process.exit(0);
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
