import { IncomingMessage } from 'http';
import crypto from 'crypto';
import type WebSocket from 'ws';
import jwt from 'jsonwebtoken';
import { UserRole } from '@prisma/client';
import * as Y from 'yjs';
import { Hocuspocus } from '@hocuspocus/server';
import type { Extension } from '@hocuspocus/server';
import { env } from '../../config/env';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { CONTENT_ROLES, getUserRoles } from '../../utils/roles';
import { docsFilesService } from './docs-files.service';
import { docsAccessService } from './docs-access.service';

// --- Metrics ---
import { Gauge } from 'prom-client';

const collabConnections = new Gauge({
  name: 'cm_docs_collab_connections',
  help: 'Number of active docs collaboration WebSocket connections',
});
const collabDocuments = new Gauge({
  name: 'cm_docs_collab_documents',
  help: 'Number of active collaboratively-edited documents',
});

// --- Connection tracking ---
const connectionsPerUser = new Map<string, number>();
const MAX_CONNECTIONS_PER_USER = 5;
const MAX_CONCURRENT_DOCUMENTS = 50;
const MAX_DOC_SIZE_BYTES = 5 * 1024 * 1024; // 5MB

// --- JWT token payload ---
interface TokenPayload {
  id: string;
  email: string;
  role: UserRole;
  roles?: UserRole[];
}

// Share-link collab JWT payload (signed with JWT_INVITE_SECRET)
interface ShareTokenPayload {
  type: 'doc_share';
  shareToken: string;
  documentPath: string;
  canEdit: boolean;
  guestName: string;
}

// --- Deterministic color from user ID ---
const COLLAB_COLORS = [
  '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
  '#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
  '#F0B27A', '#82E0AA', '#F1948A', '#AED6F1', '#D7BDE2',
];

function getUserColor(userId: string): string {
  let hash = 0;
  for (let i = 0; i < userId.length; i++) {
    hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
  }
  return COLLAB_COLORS[Math.abs(hash) % COLLAB_COLORS.length];
}

// --- Redis cache key helper ---
function fileCacheKey(relativePath: string): string {
  const hash = crypto.createHash('sha256').update(relativePath).digest('hex').substring(0, 16);
  return `DOCS_CACHE:file:${hash}`;
}

// --- Hocuspocus extension with hooks ---
const docsExtension: Extension = {
  priority: 1,

  async onAuthenticate(data) {
    const { token, documentName } = data;

    if (!token) {
      throw new Error('Authentication required');
    }

    // Validate document path (prevent path traversal)
    try {
      docsFilesService.safeResolve(documentName);
    } catch {
      throw new Error('Invalid document path');
    }

    // Try standard JWT (access token) first
    let payload: TokenPayload | null = null;
    try {
      payload = jwt.verify(token, env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as TokenPayload;
    } catch {
      // Standard JWT failed — try share-link JWT below
    }

    if (payload) {
      // --- Standard authenticated user flow ---
      const roles = payload.roles || [payload.role];

      // Check CONTENT_ROLES for write access
      const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
      if (!hasWriteAccess) {
        if (roles.includes(UserRole.TEMP)) {
          throw new Error('TEMP users cannot access collaboration');
        }
        data.connectionConfig.readOnly = true;
      }

      // Per-file access policy check
      if (hasWriteAccess) {
        try {
          const canEdit = await docsAccessService.canUserEdit(payload.id, roles as UserRole[], documentName);
          if (!canEdit) {
            data.connectionConfig.readOnly = true;
          }
        } catch {
          // If policy check fails, default to read-only
          data.connectionConfig.readOnly = true;
        }
      }

      // Rate limit: max connections per user
      const currentCount = connectionsPerUser.get(payload.id) || 0;
      if (currentCount >= MAX_CONNECTIONS_PER_USER) {
        throw new Error('Too many concurrent connections');
      }

      // Rate limit: max concurrent documents
      if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
        if (!hocuspocus.documents.has(documentName)) {
          throw new Error('Too many concurrent documents');
        }
      }

      // Track connection
      connectionsPerUser.set(payload.id, currentCount + 1);

      // Look up user name from DB
      let userName = payload.email.split('@')[0];
      try {
        const user = await prisma.user.findUnique({
          where: { id: payload.id },
          select: { name: true },
        });
        if (user?.name) userName = user.name;
      } catch {
        // Fall back to email prefix
      }

      data.context.user = {
        id: payload.id,
        email: payload.email,
        name: userName,
        color: getUserColor(payload.id),
        roles,
      };

      logger.info(`Docs collab: ${userName} connected to ${documentName}`);
      return;
    }

    // --- Share-link guest flow ---
    let sharePayload: ShareTokenPayload;
    try {
      sharePayload = jwt.verify(token, env.JWT_INVITE_SECRET, { algorithms: ['HS256'] }) as ShareTokenPayload;
    } catch {
      throw new Error('Invalid or expired token');
    }

    // Verify it's a doc_share token
    if (sharePayload.type !== 'doc_share') {
      throw new Error('Invalid token type');
    }

    // Verify the document matches
    if (sharePayload.documentPath !== documentName) {
      throw new Error('Token does not match this document');
    }

    // Re-validate share token against DB (could have been revoked since page load)
    try {
      await docsAccessService.validateShareToken(sharePayload.shareToken);
    } catch {
      throw new Error('Share link has been revoked or expired');
    }

    // Set read-only if share link doesn't grant edit
    if (!sharePayload.canEdit) {
      data.connectionConfig.readOnly = true;
    }

    // Rate limit for share guests (keyed on share token prefix)
    const guestKey = `share:${sharePayload.shareToken.substring(0, 8)}`;
    const guestCount = connectionsPerUser.get(guestKey) || 0;
    if (guestCount >= MAX_CONNECTIONS_PER_USER) {
      throw new Error('Too many concurrent connections');
    }
    connectionsPerUser.set(guestKey, guestCount + 1);

    // Set guest context
    data.context.user = {
      id: guestKey,
      email: '',
      name: sharePayload.guestName || 'Guest',
      color: getUserColor(sharePayload.shareToken),
      roles: [],
      isShareGuest: true,
    };

    logger.info(`Docs collab: guest "${sharePayload.guestName || 'Guest'}" connected to ${documentName} via share link`);
  },

  async onLoadDocument(data) {
    const { document, documentName } = data;
    // Try loading persisted Y.Doc state from DB
    try {
      const stored = await prisma.docCollabState.findUnique({
        where: { documentId: documentName },
      });

      if (stored) {
        const stateVector = new Uint8Array(stored.state);
        Y.applyUpdate(document, stateVector);
        logger.debug(`Docs collab: loaded persisted state for ${documentName} (${stateVector.length} bytes)`);
        return;
      }
    } catch (err) {
      logger.warn(`Docs collab: failed to load persisted state for ${documentName}`, err);
    }

    // No persisted state — seed from disk
    try {
      const content = await docsFilesService.readFileContent(documentName);
      const yText = document.getText('content');
      // Only seed if the Y.Text is empty (first load)
      if (yText.length === 0) {
        yText.insert(0, content);
      }
      logger.debug(`Docs collab: seeded from disk for ${documentName} (${content.length} chars)`);
    } catch (err) {
      logger.warn(`Docs collab: failed to read file from disk for ${documentName}`, err);
      // File might not exist yet — that's OK, start with empty doc
    }
  },

  async onChange(data) {
    const { document, documentName } = data;

    // Size guard
    const yText = document.getText('content');
    const textLength = yText.length;
    if (textLength > MAX_DOC_SIZE_BYTES) {
      logger.warn(`Docs collab: document ${documentName} exceeds size limit (${textLength} chars)`);
      return;
    }

    // Duplication guard: detect and fix content that appears to be doubled
    const content = yText.toString();
    if (content.length > 20) {
      const half = Math.floor(content.length / 2);
      const firstHalf = content.substring(0, half);
      const secondHalf = content.substring(half);
      if (firstHalf === secondHalf) {
        logger.warn(`Docs collab: detected duplicated content in ${documentName} (${content.length} chars), fixing`);
        // Replace the Y.Text with just the first half
        document.transact(() => {
          yText.delete(half, content.length - half);
        });
        return; // The deletion triggers another onChange with the fixed content
      }
    }

    // Write plaintext to disk (debounced by Hocuspocus's built-in debounce)
    try {
      await docsFilesService.writeFileContent(documentName, content);
      // Invalidate Redis file cache
      try {
        await redis.del(fileCacheKey(documentName));
      } catch { /* ignore */ }
      logger.debug(`Docs collab: wrote ${documentName} to disk (${content.length} chars)`);
    } catch (err) {
      logger.error(`Docs collab: failed to write ${documentName} to disk`, err);
    }
  },

  async onStoreDocument(data) {
    const { document, documentName } = data;

    // Persist Y.Doc binary state to PostgreSQL
    try {
      const state = Y.encodeStateAsUpdate(document);

      await prisma.docCollabState.upsert({
        where: { documentId: documentName },
        update: {
          state: Buffer.from(state),
        },
        create: {
          documentId: documentName,
          state: Buffer.from(state),
        },
      });
      logger.debug(`Docs collab: persisted state for ${documentName}`);
    } catch (err) {
      logger.error(`Docs collab: failed to persist state for ${documentName}`, err);
    }
  },

  async onDisconnect(data) {
    const { documentName, context } = data;
    const userId = context?.user?.id;
    if (userId) {
      const count = connectionsPerUser.get(userId) || 0;
      if (count <= 1) {
        connectionsPerUser.delete(userId);
      } else {
        connectionsPerUser.set(userId, count - 1);
      }
    }
    logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} disconnected from ${documentName}`);

    // Update metrics
    collabConnections.set(hocuspocus.getConnectionsCount());
    collabDocuments.set(hocuspocus.getDocumentsCount());
  },

  async connected(data) {
    const { documentName, context } = data;

    // Awareness is set client-side; we just log here
    logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} fully synced on ${documentName}`);

    // Update metrics
    collabConnections.set(hocuspocus.getConnectionsCount());
    collabDocuments.set(hocuspocus.getDocumentsCount());
  },
};

// --- Create Hocuspocus instance ---
const hocuspocus = new Hocuspocus({
  name: 'changemaker-docs',
  quiet: true,
  debounce: 1000,    // Debounce disk writes by 1s
  maxDebounce: 5000,  // Force write every 5s at most
  timeout: 30000,     // 30s ping timeout
  extensions: [docsExtension],
});

/**
 * Handle an incoming WebSocket connection for docs collaboration.
 * Called from server.ts after the HTTP → WS upgrade.
 */
function handleConnection(
  ws: WebSocket,
  request: IncomingMessage,
  context: { documentName: string; token: string },
): void {
  // Hocuspocus expects the token in the URL search params for onAuthenticate
  // We manually set it via the context's requestParameters
  const url = new URL(request.url || '', `http://${request.headers.host}`);
  url.searchParams.set('token', context.token);
  request.url = url.pathname + url.search;

  hocuspocus.handleConnection(ws, request, {
    // Pass initial context
    documentName: context.documentName,
  });
}

/**
 * Invalidate a document's collaboration state.
 * Called when files are modified externally (rename, delete, PUT endpoint).
 */
async function invalidateDocument(documentName: string): Promise<void> {
  try {
    // Delete persisted state
    await prisma.docCollabState.deleteMany({
      where: { documentId: documentName },
    });
    // Close active connections for this document so clients reload
    hocuspocus.closeConnections(documentName);
    logger.debug(`Docs collab: invalidated document ${documentName}`);
  } catch (err) {
    logger.warn(`Docs collab: failed to invalidate document ${documentName}`, err);
  }
}

/**
 * Clean up stale DocCollabState records (older than 7 days with no corresponding file).
 */
async function cleanupStaleStates(): Promise<void> {
  const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);

  try {
    const staleRecords = await prisma.docCollabState.findMany({
      where: { updatedAt: { lt: sevenDaysAgo } },
      select: { id: true, documentId: true },
    });

    let cleaned = 0;
    for (const record of staleRecords) {
      try {
        // Check if file still exists on disk
        docsFilesService.safeResolve(record.documentId);
        await docsFilesService.readFileContent(record.documentId);
      } catch {
        // File doesn't exist — delete the state
        await prisma.docCollabState.delete({ where: { id: record.id } });
        cleaned++;
      }
    }

    if (cleaned > 0) {
      logger.info(`Docs collab: cleaned up ${cleaned} stale collaboration states`);
    }
  } catch (err) {
    logger.warn('Docs collab: cleanup failed', err);
  }
}

/**
 * Gracefully close all connections and stop Hocuspocus.
 */
async function shutdown(): Promise<void> {
  hocuspocus.closeConnections();
  collabConnections.set(0);
  collabDocuments.set(0);
}

export const docsCollabService = {
  handleConnection,
  invalidateDocument,
  cleanupStaleStates,
  shutdown,
  getConnectionsCount: () => hocuspocus.getConnectionsCount(),
  getDocumentsCount: () => hocuspocus.getDocumentsCount(),
};
