import { defineConfig } from "cypress";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Client } from "pg";

/**
 * Freight e2e suite. Three origins:
 *   backoffice  http://localhost:5383  (baseUrl — most specs live here)
 *   portal      http://localhost:5373  (env.portalUrl; portal specs cy.visit it,
 *                                       cross-app flows reach it via cy.origin)
 *   api         http://localhost:3101  (env.apiUrl; cy.request only)
 *
 * All URLs are host-published ports from docker-compose.e2e.yaml. The cypress
 * container in that compose file runs with network_mode: host, so the same
 * localhost URLs work identically for `cypress open` on the host and for the
 * containerized headless run.
 */
export default defineConfig({
  e2e: {
    baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383",
    specPattern: "cypress/e2e/**/*.cy.ts",
    supportFile: "cypress/support/e2e.ts",
    video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true",
    screenshotOnRunFailure: true,
    viewportWidth: 1440,
    viewportHeight: 900,
    defaultCommandTimeout: 10000,
    requestTimeout: 15000,
    retries: { runMode: 1, openMode: 0 },
    env: {
      apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101",
      portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
      backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383",
      // Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml.
      defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria",
      // Demo portal users: hardcoded in DemoUsersSeeder.
      demoPassword: "12345678",
    },
    setupNodeEvents(on) {
      const dbUrl =
        process.env.E2E_DB_URL ??
        "postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e";

      // Shapes already retired in THIS cypress run. The Node plugin process
      // outlives spec-bundle re-evaluation (Cypress re-runs the bundle — and
      // so `before()` — on every cross-origin visit), so a run-scoped set here
      // is what keeps the cleanup from firing a second time and cancelling the
      // contract the spec had just created. Browser-side state cannot do this:
      // Cypress.env() is reset by the reload.
      const retiredContractShapes = new Set<string>();
      /** Keys already executed by `db:queryOnce` in this cypress run. */
      const onceRunKeys = new Set<string>();
      // Stamped when the plugin loads, i.e. once per cypress run. Cleanup only
      // ever touches rows older than this, so nothing the current run creates
      // can be cancelled out from under it.
      const runStartedAt = new Date().toISOString();

      on("task", {
        /**
         * Cancel a previous run's contracts of one shape so a spec can run
         * again against a warm DB (the API allows one active contract per
         * customer + service type + route). Runs at most once per shape per
         * cypress run — see `retiredContractShapes`.
         */
        async "db:retireStaleContracts"({
          tin,
          kind,
          direction,
        }: {
          tin: string;
          kind: string;
          direction: string;
        }) {
          const key = `${tin}:${kind}:${direction}`;
          if (retiredContractShapes.has(key)) return { skipped: true };
          retiredContractShapes.add(key);

          const client = new Client({ connectionString: dbUrl });
          await client.connect();
          try {
            const result = await client.query(
              `UPDATE freight.contracts ct
                  SET status = 'CANCELLED'
                 FROM freight.companies c
                WHERE c.id = ct.company_id
                  AND c.tin = $1
                  AND ct.deleted_at IS NULL
                  AND ct.contract_kind = $2
                  AND ct.trade_direction = $3
                  -- Only ever previous runs' rows.
                  AND ct.created_at < $4::timestamptz
                  -- Corridor fixtures are re-seeded by reference, so a
                  -- cancelled one would never come back — leave them alone.
                  AND ct.reference NOT LIKE 'CTR-IMP-%'
                  -- Segment fixtures are stamped per run and always booked by
                  -- their own spec. An UNBOOKED leftover is debris that still
                  -- holds the lane (one active contract per service + route),
                  -- which blocked the intercity spec from filing its own.
                  AND (
                    ct.reference NOT LIKE 'CTR-SEG-%'
                    OR NOT EXISTS (
                      SELECT 1 FROM freight.bookings b
                       WHERE b.contract_id = ct.id AND b.deleted_at IS NULL
                    )
                  )
                  AND ct.status NOT IN
                      ('REJECTED','CANCELLED','CONTRACT_CLOSED','ARCHIVED','EXPIRED')`,
              [tin, kind, direction, runStartedAt],
            );
            return { skipped: false, cancelled: result.rowCount };
          } finally {
            await client.end();
          }
        },

        /**
         * Run a statement at most ONCE per cypress run, keyed by `key`.
         *
         * For arrange-data that must not repeat: Cypress re-evaluates the spec
         * bundle on every cross-origin visit, so a `before()` hook fires again
         * mid-spec and a plain cleanup would then wipe what the run had just
         * created. The Node plugin process outlives those reloads, so the guard
         * lives here rather than in the browser.
         */
        async "db:queryOnce"({
          key,
          sql,
          params = [],
        }: {
          key: string;
          sql: string;
          params?: unknown[];
        }) {
          if (onceRunKeys.has(key)) return { skipped: true };
          onceRunKeys.add(key);
          const client = new Client({ connectionString: dbUrl });
          await client.connect();
          try {
            const result = await client.query(sql, params as never[]);
            return { skipped: false, rowCount: result.rowCount };
          } finally {
            await client.end();
          }
        },

        /** Run an arbitrary SQL statement against the ephemeral e2e database. */
        async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) {
          const client = new Client({ connectionString: dbUrl });
          await client.connect();
          try {
            const result = await client.query(sql, params as never[]);
            return { rowCount: result.rowCount, rows: result.rows };
          } finally {
            await client.end();
          }
        },

        /**
         * Seed the test users (staff + demo) directly in SQL. The API's
         * user seeders are disabled in app code, so the fixture replicates
         * their output. Idempotent — safe to run before every spec file.
         */
        /** Apply one idempotent SQL fixture from cypress/fixtures (arrange-data). */
        async "db:seedFile"(file: string) {
          const client = new Client({ connectionString: dbUrl });
          await client.connect();
          try {
            const sql = readFileSync(
              join(process.cwd(), "cypress", "fixtures", file),
              "utf8",
            );
            await client.query(sql);
            return true;
          } finally {
            await client.end();
          }
        },

        /**
         * Node-side multipart POST — cy.request cannot stream FormData files,
         * and driving every GL upload modal through the UI is out of scope for
         * the scheduling-engine specs. Uses Node 18+ global fetch/FormData.
         */
        async "api:upload"({
          url,
          token,
          fields = {},
          files = [],
        }: {
          url: string;
          token: string;
          fields?: Record<string, string>;
          files?: Array<{
            field: string;
            fixture: string;
            filename?: string;
            contentType?: string;
          }>;
        }) {
          const form = new FormData();
          for (const [key, value] of Object.entries(fields)) form.append(key, value);
          for (const f of files) {
            const buf = readFileSync(join(process.cwd(), "cypress", "fixtures", f.fixture));
            form.append(
              f.field,
              new Blob([buf], { type: f.contentType ?? "application/pdf" }),
              f.filename ?? "document.pdf",
            );
          }
          const res = await fetch(url, {
            method: "POST",
            headers: { Authorization: `Bearer ${token}` },
            body: form,
          });
          const text = await res.text();
          let body: unknown = text;
          try {
            body = JSON.parse(text);
          } catch {
            // non-JSON body (rare) — return as text
          }
          return { status: res.status, body };
        },

        async "db:seedUsers"() {
          // cwd = the e2e/freight project root when Cypress runs.
          // seed-company.sql depends on rows from seed-users.sql — keep order.
          const client = new Client({ connectionString: dbUrl });
          await client.connect();
          try {
            for (const file of ["seed-users.sql", "seed-company.sql"]) {
              const sql = readFileSync(
                join(process.cwd(), "cypress", "fixtures", file),
                "utf8",
              );
              await client.query(sql);
            }
            return true;
          } finally {
            await client.end();
          }
        },
      });
    },
  },
});
