import express, { type Request, type Response } from "express";
import { and, eq, type SQL } from "drizzle-orm";
import {
  hosts,
  sshCredentials,
  sshFolders,
  snippets,
  snippetFolders,
  vaultProfiles,
  dashboardServiceLinks,
  homepageItems,
  userPreferences,
} from "../db/schema.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import {
  createCurrentRepositoryContext,
  createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
import {
  deserializeSyncReferences,
  serializeSyncReferences,
  type SyncReferenceEntity,
} from "./sync-references.js";
import { timestampAtOrAfter } from "../sync-timestamp.js";

const router = express.Router();
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();

// Encrypted tables need DataCrypto to translate between the wire payload
// (plaintext) and the stored row (encrypted). Everything else is stored
// and synced as-is.
const ENCRYPTED_ENTITY_TABLES: Partial<Record<SyncEntityType, string>> = {
  hosts: "ssh_data",
  sshCredentials: "ssh_credentials",
};

interface EntityConfig {
  table:
    | typeof hosts
    | typeof sshCredentials
    | typeof sshFolders
    | typeof snippets
    | typeof snippetFolders
    | typeof vaultProfiles
    | typeof dashboardServiceLinks
    | typeof homepageItems
    | typeof userPreferences;
  // Fields that only make sense on the device that created the row, or
  // that are managed elsewhere and must never be overwritten by a sync
  // payload from the other side.
  readOnlyFields: string[];
  singleton?: boolean;
}

const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
  hosts: {
    table: hosts,
    readOnlyFields: ["connectionOrigin"],
  },
  sshCredentials: { table: sshCredentials, readOnlyFields: [] },
  sshFolders: { table: sshFolders, readOnlyFields: [] },
  snippets: { table: snippets, readOnlyFields: [] },
  snippetFolders: { table: snippetFolders, readOnlyFields: [] },
  vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
  dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
  homepageItems: { table: homepageItems, readOnlyFields: [] },
  userPreferences: {
    table: userPreferences,
    readOnlyFields: ["storageMode"],
    singleton: true,
  },
};

const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;

export function isValidEntityType(value: unknown): value is SyncEntityType {
  return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
}

/**
 * Locates the stored row a sync payload corresponds to.
 *
 * Read and write have to agree on this. A singleton entity is keyed on its
 * owner rather than a sync id, and `user_preferences` — the only singleton —
 * has no `id` column at all, so an update cannot fall back to one: `table.id`
 * is undefined there and drizzle emits `WHERE  = ?`.
 */
export function locateSyncRow(
  entityType: SyncEntityType,
  userId: string,
  syncId: string,
): SQL {
  const { table, singleton } = ENTITY_CONFIG[entityType];

  if (singleton) {
    return eq(table.userId, userId);
  }

  return and(
    eq((table as typeof hosts).syncId, syncId),
    eq(table.userId, userId),
  )!;
}

async function findReferenceSyncId(
  context: RepositoryContext,
  entityType: SyncReferenceEntity,
  id: number,
  userId: string,
): Promise<string | null> {
  if (entityType === "sshCredentials") {
    const [row] = await context.drizzle
      .select({ syncId: sshCredentials.syncId })
      .from(sshCredentials)
      .where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)))
      .limit(1);
    return row?.syncId ?? null;
  }

  const [row] = await context.drizzle
    .select({ syncId: vaultProfiles.syncId })
    .from(vaultProfiles)
    .where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId)))
    .limit(1);
  return row?.syncId ?? null;
}

async function findReferenceId(
  context: RepositoryContext,
  entityType: SyncReferenceEntity,
  syncId: string,
  userId: string,
): Promise<number | null> {
  if (entityType === "sshCredentials") {
    const [row] = await context.drizzle
      .select({ id: sshCredentials.id })
      .from(sshCredentials)
      .where(
        and(
          eq(sshCredentials.syncId, syncId),
          eq(sshCredentials.userId, userId),
        ),
      )
      .limit(1);
    return row?.id ?? null;
  }

  const [row] = await context.drizzle
    .select({ id: vaultProfiles.id })
    .from(vaultProfiles)
    .where(
      and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)),
    )
    .limit(1);
  return row?.id ?? null;
}

function requireUserDataKey(userId: string): Buffer {
  return DataCrypto.validateUserAccess(userId);
}

function decryptIfNeeded(
  entityType: SyncEntityType,
  row: Record<string, unknown>,
  userId: string,
): Record<string, unknown> {
  const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
  if (!tableName) return row;
  const userDataKey = DataCrypto.getUserDataKey(userId);
  if (!userDataKey) return row;
  return DataCrypto.decryptRecord(
    tableName,
    row,
    userId,
    userDataKey,
  ) as Record<string, unknown>;
}

function encryptIfNeeded(
  entityType: SyncEntityType,
  row: Record<string, unknown>,
  userId: string,
): Record<string, unknown> {
  const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
  if (!tableName) return row;
  const userDataKey = requireUserDataKey(userId);
  return DataCrypto.encryptRecord(
    tableName,
    row,
    userId,
    userDataKey,
  ) as Record<string, unknown>;
}

export function stripWritePayload(
  entityType: SyncEntityType,
  payload: Record<string, unknown>,
): Record<string, unknown> {
  const { readOnlyFields } = ENTITY_CONFIG[entityType];
  const clean = { ...payload };
  delete clean.id;
  delete clean.userId;
  delete clean.syncId;
  for (const field of readOnlyFields) delete clean[field];
  return clean;
}

/**
 * @openapi
 * /sync/{entityType}:
 *   get:
 *     summary: Pull synced rows for an entity type
 *     description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
 *     tags:
 *       - Sync
 *     parameters:
 *       - in: path
 *         name: entityType
 *         required: true
 *         schema:
 *           type: string
 *       - in: query
 *         name: since
 *         schema:
 *           type: string
 *     responses:
 *       200:
 *         description: Rows updated since the given timestamp.
 *       400:
 *         description: Unknown entity type.
 *       500:
 *         description: Failed to fetch rows.
 */
router.get(
  "/:entityType",
  authenticateJWT,
  async (req: Request, res: Response) => {
    const userId = (req as AuthenticatedRequest).userId;
    const entityType = req.params.entityType;
    if (!isValidEntityType(entityType)) {
      return res.status(400).json({ error: "Unknown entity type" });
    }
    const since =
      typeof req.query.since === "string" && req.query.since
        ? req.query.since
        : null;

    try {
      const { table, singleton } = ENTITY_CONFIG[entityType];
      const context = createCurrentRepositoryContext();
      const conditions = [eq(table.userId, userId)];
      if (since && "updatedAt" in table) {
        conditions.push(
          timestampAtOrAfter((table as typeof hosts).updatedAt, since),
        );
      }

      const rows = await context.drizzle
        .select()
        .from(table as typeof hosts)
        .where(and(...conditions));

      const decrypted = await Promise.all(
        rows.map(async (row) => {
          const result = await serializeSyncReferences(
            entityType,
            decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
            (referenceType, id) =>
              findReferenceSyncId(context, referenceType, id, userId),
          );
          return singleton
            ? { ...result, syncId: `${entityType}:singleton` }
            : result;
        }),
      );

      res.json({ rows: decrypted });
    } catch (err) {
      databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
        operation: "sync_pull",
        entityType,
        userId,
      });
      res.status(500).json({ error: "Failed to fetch rows" });
    }
  },
);

/**
 * @openapi
 * /sync/tombstones:
 *   post:
 *     summary: Report a deletion from the other side of a sync pair
 *     description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
 *     tags:
 *       - Sync
 *     responses:
 *       200:
 *         description: Deletion applied (or row already absent).
 *       400:
 *         description: Unknown entity type or missing syncId.
 *       500:
 *         description: Failed to apply deletion.
 */
router.post(
  "/tombstones",
  authenticateJWT,
  async (req: Request, res: Response) => {
    const userId = (req as AuthenticatedRequest).userId;
    const entityType = req.body?.entityType;
    const syncId = req.body?.syncId;
    if (
      !isValidEntityType(entityType) ||
      typeof syncId !== "string" ||
      !syncId
    ) {
      return res.status(400).json({ error: "Missing entityType or syncId" });
    }

    try {
      const { table } = ENTITY_CONFIG[entityType];
      const context = createCurrentRepositoryContext();

      await context.drizzle
        .delete(table as typeof hosts)
        .where(locateSyncRow(entityType, userId, syncId));

      await createCurrentSyncTombstoneRepository().record(
        userId,
        entityType,
        syncId,
      );
      await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");

      res.json({ success: true });
    } catch (err) {
      databaseLogger.error("Failed to apply sync tombstone", err, {
        operation: "sync_tombstone_apply",
        entityType,
        userId,
      });
      res.status(500).json({ error: "Failed to apply deletion" });
    }
  },
);

/**
 * @openapi
 * /sync/{entityType}:
 *   post:
 *     summary: Upsert a synced row by syncId
 *     description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
 *     tags:
 *       - Sync
 *     parameters:
 *       - in: path
 *         name: entityType
 *         required: true
 *         schema:
 *           type: string
 *     responses:
 *       200:
 *         description: Row upserted.
 *       400:
 *         description: Unknown entity type or missing syncId.
 *       500:
 *         description: Failed to upsert row.
 */
router.post(
  "/:entityType",
  authenticateJWT,
  async (req: Request, res: Response) => {
    const userId = (req as AuthenticatedRequest).userId;
    const entityType = req.params.entityType;
    if (!isValidEntityType(entityType)) {
      return res.status(400).json({ error: "Unknown entity type" });
    }
    const payload = req.body?.row;
    const syncId = payload?.syncId;
    if (!payload || typeof syncId !== "string" || !syncId) {
      return res.status(400).json({ error: "Missing row.syncId" });
    }

    try {
      // singleton is still needed below: those tables have no sync_id column
      // for the insert to populate.
      const { table, singleton } = ENTITY_CONFIG[entityType];
      const context = createCurrentRepositoryContext();

      const locateRow = locateSyncRow(entityType, userId, syncId);

      const existingRows = await context.drizzle
        .select()
        .from(table as typeof hosts)
        .where(locateRow)
        .limit(1);
      const existing = existingRows[0] as Record<string, unknown> | undefined;

      const resolvedPayload = await deserializeSyncReferences(
        entityType,
        payload,
        (referenceType, referenceSyncId) =>
          findReferenceId(context, referenceType, referenceSyncId, userId),
      );
      const writePayload = stripWritePayload(entityType, resolvedPayload);
      const encryptedPayload = encryptIfNeeded(
        entityType,
        writePayload,
        userId,
      );

      let resultRow: Record<string, unknown>;
      if (existing) {
        const updatedRows = await context.drizzle
          .update(table as typeof hosts)
          .set(encryptedPayload)
          .where(locateRow)
          .returning();
        resultRow = updatedRows[0] as Record<string, unknown>;
      } else {
        const insertedRows = await context.drizzle
          .insert(table as typeof hosts)
          .values(
            (singleton
              ? { ...encryptedPayload, userId }
              : {
                  ...encryptedPayload,
                  userId,
                  syncId,
                }) as typeof hosts.$inferInsert,
          )
          .returning();
        resultRow = insertedRows[0] as Record<string, unknown>;
      }

      await DatabaseSaveTrigger.forceSave("sync_upsert");

      res.json({
        row: decryptIfNeeded(entityType, resultRow, userId),
        created: !existing,
      });
    } catch (err) {
      databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
        operation: "sync_upsert",
        entityType,
        userId,
      });
      res.status(500).json({ error: "Failed to upsert row" });
    }
  },
);

/**
 * @openapi
 * /sync/{entityType}/tombstones:
 *   get:
 *     summary: Pull deletion tombstones for an entity type
 *     description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
 *     tags:
 *       - Sync
 *     parameters:
 *       - in: path
 *         name: entityType
 *         required: true
 *         schema:
 *           type: string
 *       - in: query
 *         name: since
 *         schema:
 *           type: string
 *     responses:
 *       200:
 *         description: Tombstones recorded since the given timestamp.
 *       400:
 *         description: Unknown entity type.
 *       500:
 *         description: Failed to fetch tombstones.
 */
router.get(
  "/:entityType/tombstones",
  authenticateJWT,
  async (req: Request, res: Response) => {
    const userId = (req as AuthenticatedRequest).userId;
    const entityType = req.params.entityType;
    if (!isValidEntityType(entityType)) {
      return res.status(400).json({ error: "Unknown entity type" });
    }
    const since =
      typeof req.query.since === "string" && req.query.since
        ? req.query.since
        : null;

    try {
      const tombstones = await createCurrentSyncTombstoneRepository().listSince(
        userId,
        entityType,
        since,
      );
      res.json({ tombstones });
    } catch (err) {
      databaseLogger.error(
        `Failed to fetch sync tombstones for ${entityType}`,
        err,
        { operation: "sync_tombstones_pull", entityType, userId },
      );
      res.status(500).json({ error: "Failed to fetch tombstones" });
    }
  },
);

export default router;
