import crypto from 'crypto';
import dns from 'dns/promises';
import { URL } from 'url';
import {
  CalendarLayerType,
  CalendarVisibility,
  CalendarItemType,
  CalendarItemSource,
  CalendarFeedStatus,
  CalendarFeedInterval,
  Prisma,
} from '@prisma/client';
import ical, { ICalCalendarMethod } from 'ical-generator';
import nodeIcal from 'node-ical';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import type { CreateFeedInput, UpdateFeedInput, CreateExportTokenInput } from './feed.schemas';

const MAX_EVENTS_PER_FEED = 1000;
const FETCH_TIMEOUT_MS = 30_000;
const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB
const MATERIALIZE_MONTHS = 3;

/** Extract hostname from a URL string, returning empty string on failure. */
function extractHost(url: string): string {
  try { return new URL(url).hostname; } catch { return ''; }
}

/**
 * SSRF protection: build blocklist dynamically from env-configured service URLs.
 * This adapts to any container naming convention used by the deployment.
 */
function buildBlockedHostnames(): Set<string> {
  const hosts = new Set([
    'localhost', '0.0.0.0', '[::]', '[::1]',
  ]);
  // Extract hostnames from all internal service URLs
  const serviceUrls = [
    env.REDIS_URL, env.NOCODB_URL, env.N8N_URL, env.GITEA_URL,
    env.MAILHOG_URL, env.MINI_QR_URL, env.EXCALIDRAW_URL, env.HOMEPAGE_URL,
    env.VAULTWARDEN_URL, env.ROCKETCHAT_URL, env.GANCIO_URL, env.JITSI_URL,
    env.CODE_SERVER_URL, env.MKDOCS_PREVIEW_URL, env.MKDOCS_SITE_SERVER_URL,
    env.PROMETHEUS_URL, env.GRAFANA_URL, env.ALERTMANAGER_URL, env.GOTIFY_URL,
    env.CADVISOR_URL, env.NODE_EXPORTER_URL, env.REDIS_EXPORTER_URL,
    env.DOCKER_PROXY_URL, env.API_URL,
  ];
  for (const url of serviceUrls) {
    const h = extractHost(url);
    if (h) hosts.add(h);
  }
  return hosts;
}

const BLOCKED_HOSTNAMES = buildBlockedHostnames();

function isPrivateIP(ip: string): boolean {
  // IPv4 private/reserved ranges
  if (ip.startsWith('10.')) return true;
  if (ip.startsWith('127.')) return true;
  if (ip.startsWith('169.254.')) return true; // Link-local / cloud metadata
  if (ip.startsWith('172.')) {
    const second = parseInt(ip.split('.')[1], 10);
    if (second >= 16 && second <= 31) return true;
  }
  if (ip.startsWith('192.168.')) return true;
  if (ip === '0.0.0.0') return true;
  // IPv6 private/reserved
  if (ip === '::1' || ip === '::') return true;
  if (ip.startsWith('fc') || ip.startsWith('fd')) return true; // ULA
  if (ip.startsWith('fe80')) return true; // Link-local
  return false;
}

async function validateFeedUrl(rawUrl: string): Promise<void> {
  let parsed: URL;
  try {
    parsed = new URL(rawUrl);
  } catch {
    throw new AppError(400, 'Invalid URL format', 'INVALID_FEED_URL');
  }

  if (!['http:', 'https:'].includes(parsed.protocol)) {
    throw new AppError(400, 'Only http and https URLs are allowed', 'INVALID_FEED_URL');
  }

  const hostname = parsed.hostname.toLowerCase();
  if (BLOCKED_HOSTNAMES.has(hostname)) {
    throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
  }

  // Resolve DNS and check all resolved IPs
  try {
    const addrs4 = await dns.resolve4(hostname).catch(() => [] as string[]);
    const addrs6 = await dns.resolve6(hostname).catch(() => [] as string[]);
    const allAddrs = [...addrs4, ...addrs6];

    if (allAddrs.length === 0) {
      throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
    }

    for (const addr of allAddrs) {
      if (isPrivateIP(addr)) {
        throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
      }
    }
  } catch (err) {
    if (err instanceof AppError) throw err;
    throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
  }
}

// Map CalendarFeedInterval to milliseconds
const INTERVAL_MS: Record<CalendarFeedInterval, number> = {
  FIFTEEN_MIN: 15 * 60 * 1000,
  HOURLY: 60 * 60 * 1000,
  SIX_HOUR: 6 * 60 * 60 * 1000,
  DAILY: 24 * 60 * 60 * 1000,
};

export const feedService = {
  // =========================================================================
  // Feed Management
  // =========================================================================

  async listFeeds(userId: string) {
    return prisma.calendarFeed.findMany({
      where: { userId },
      include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
      orderBy: { createdAt: 'asc' },
    });
  },

  async createFeed(userId: string, data: CreateFeedInput) {
    // SSRF protection: validate URL before making any request
    await validateFeedUrl(data.url);

    // Validate URL is reachable
    try {
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), 10_000);
      const res = await fetch(data.url, {
        method: 'HEAD',
        signal: controller.signal,
        redirect: 'manual', // Don't follow redirects (prevent SSRF via open redirects)
      });
      clearTimeout(timeout);
      if (!res.ok && res.status !== 405 && !(res.status >= 300 && res.status < 400)) {
        throw new AppError(400, `Feed URL returned status ${res.status}`, 'FEED_URL_UNREACHABLE');
      }
    } catch (err) {
      if (err instanceof AppError) throw err;
      throw new AppError(400, 'Feed URL is not reachable', 'FEED_URL_UNREACHABLE');
    }

    // Create EXTERNAL layer + feed in a transaction
    const result = await prisma.$transaction(async (tx) => {
      const layer = await tx.calendarLayer.create({
        data: {
          userId,
          name: data.name,
          color: '#1890ff',
          layerType: CalendarLayerType.EXTERNAL,
          visibility: CalendarVisibility.PRIVATE,
        },
      });

      const feed = await tx.calendarFeed.create({
        data: {
          userId,
          name: data.name,
          url: data.url,
          layerId: layer.id,
          refreshInterval: data.refreshInterval as CalendarFeedInterval,
        },
      });

      return feed;
    });

    // Trigger initial fetch (fire-and-forget)
    this.refreshFeed(result.id).catch((err) => {
      logger.warn(`Initial feed refresh failed for ${result.id}:`, err);
    });

    return prisma.calendarFeed.findUnique({
      where: { id: result.id },
      include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
    });
  },

  async updateFeed(userId: string, feedId: string, data: UpdateFeedInput) {
    const feed = await prisma.calendarFeed.findFirst({
      where: { id: feedId, userId },
    });

    if (!feed) {
      throw new AppError(404, 'Feed not found', 'NOT_FOUND');
    }

    const updateData: Prisma.CalendarFeedUncheckedUpdateInput = {};
    if (data.name !== undefined) {
      updateData.name = data.name;
      // Also update layer name
      await prisma.calendarLayer.update({
        where: { id: feed.layerId },
        data: { name: data.name },
      });
    }
    if (data.url !== undefined) {
      // SSRF protection: validate new URL before saving
      await validateFeedUrl(data.url);
      updateData.url = data.url;
    }
    if (data.refreshInterval !== undefined) {
      updateData.refreshInterval = data.refreshInterval as CalendarFeedInterval;
    }

    return prisma.calendarFeed.update({
      where: { id: feedId },
      data: updateData,
      include: { layer: { select: { id: true, name: true, color: true, isEnabled: true } } },
    });
  },

  async deleteFeed(userId: string, feedId: string) {
    const feed = await prisma.calendarFeed.findFirst({
      where: { id: feedId, userId },
    });

    if (!feed) {
      throw new AppError(404, 'Feed not found', 'NOT_FOUND');
    }

    // Deleting the layer cascades to items and the feed record
    await prisma.calendarLayer.delete({ where: { id: feed.layerId } });
  },

  async refreshFeed(feedId: string) {
    const feed = await prisma.calendarFeed.findUnique({ where: { id: feedId } });
    if (!feed) return;

    try {
      // Fetch the ICS data
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);

      // Re-validate the stored URL in case it was changed outside the update flow
      await validateFeedUrl(feed.url);

      const response = await fetch(feed.url, {
        signal: controller.signal,
        redirect: 'manual', // Don't follow redirects (SSRF protection)
        headers: { 'User-Agent': 'Changemaker-Calendar/1.0' },
      });
      clearTimeout(timeout);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      // Read body with size limit
      const reader = response.body?.getReader();
      if (!reader) throw new Error('No response body');

      const chunks: Uint8Array[] = [];
      let totalSize = 0;

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        totalSize += value.byteLength;
        if (totalSize > FETCH_MAX_BYTES) {
          reader.cancel();
          throw new Error('Feed exceeds 5MB size limit');
        }
        chunks.push(value);
      }

      const icsText = Buffer.concat(chunks).toString('utf-8');

      // Parse ICS
      const parsed = nodeIcal.sync.parseICS(icsText);

      // Extract VEVENT entries
      const events: Array<{
        uid: string;
        title: string;
        description: string | null;
        date: Date;
        startTime: string;
        endTime: string;
        isAllDay: boolean;
        location: string | null;
      }> = [];

      const now = new Date();
      const futureLimit = new Date(now);
      futureLimit.setMonth(futureLimit.getMonth() + MATERIALIZE_MONTHS);

      for (const key of Object.keys(parsed)) {
        const component = parsed[key];
        if (!component || component.type !== 'VEVENT') continue;
        if (events.length >= MAX_EVENTS_PER_FEED) break;

        const vevent = component as nodeIcal.VEvent;
        if (!vevent.uid || !vevent.start) continue;

        const start = vevent.start instanceof Date ? vevent.start : new Date(vevent.start as unknown as string);
        if (isNaN(start.getTime())) continue;

        // Check if this is an all-day event (dateOnly property from node-ical)
        const isAllDay = !!(vevent.start as { dateOnly?: boolean }).dateOnly;

        let startTime = '00:00';
        let endTime = '23:59';

        if (!isAllDay) {
          startTime = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`;

          if (vevent.end) {
            const end = vevent.end instanceof Date ? vevent.end : new Date(vevent.end as unknown as string);
            if (!isNaN(end.getTime())) {
              endTime = `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`;
            }
          } else {
            // Default 1 hour
            const endDate = new Date(start);
            endDate.setHours(endDate.getHours() + 1);
            endTime = `${String(endDate.getHours()).padStart(2, '0')}:${String(endDate.getMinutes()).padStart(2, '0')}`;
          }
        }

        // Handle RRULE: materialize recurring instances
        if (vevent.rrule) {
          try {
            const occurrences = vevent.rrule.between(now, futureLimit, true);
            for (const occ of occurrences) {
              if (events.length >= MAX_EVENTS_PER_FEED) break;
              const occDate = new Date(occ);
              events.push({
                uid: `${vevent.uid}_${occDate.toISOString().split('T')[0]}`,
                title: String(vevent.summary || 'Untitled'),
                description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
                date: occDate,
                startTime,
                endTime,
                isAllDay,
                location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
              });
            }
          } catch {
            // If rrule parsing fails, just add the single instance
            events.push({
              uid: vevent.uid,
              title: String(vevent.summary || 'Untitled'),
              description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
              date: start,
              startTime,
              endTime,
              isAllDay,
              location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
            });
          }
        } else {
          events.push({
            uid: vevent.uid,
            title: String(vevent.summary || 'Untitled'),
            description: typeof vevent.description === 'string' ? vevent.description.slice(0, 2000) : null,
            date: start,
            startTime,
            endTime,
            isAllDay,
            location: typeof vevent.location === 'string' ? vevent.location.slice(0, 500) : null,
          });
        }
      }

      // Upsert events and remove stale ones in a transaction
      await prisma.$transaction(async (tx) => {
        const sourceIds = events.map((e) => e.uid);

        // Delete stale items (no longer in feed)
        await tx.calendarItem.deleteMany({
          where: {
            layerId: feed.layerId,
            userId: feed.userId,
            sourceType: CalendarItemSource.ICS_FEED,
            sourceId: { notIn: sourceIds.length > 0 ? sourceIds : ['__none__'] },
          },
        });

        // Upsert each event (no unique constraint on sourceId, so find+update/create)
        for (const event of events) {
          const existing = await tx.calendarItem.findFirst({
            where: {
              userId: feed.userId,
              layerId: feed.layerId,
              sourceType: CalendarItemSource.ICS_FEED,
              sourceId: event.uid,
            },
          });

          if (existing) {
            await tx.calendarItem.update({
              where: { id: existing.id },
              data: {
                title: event.title,
                description: event.description,
                date: event.date,
                startTime: event.startTime,
                endTime: event.endTime,
                isAllDay: event.isAllDay,
                location: event.location,
              },
            });
          } else {
            await tx.calendarItem.create({
              data: {
                userId: feed.userId,
                layerId: feed.layerId,
                title: event.title,
                description: event.description,
                date: event.date,
                startTime: event.startTime,
                endTime: event.endTime,
                isAllDay: event.isAllDay,
                itemType: CalendarItemType.EVENT,
                sourceType: CalendarItemSource.ICS_FEED,
                sourceId: event.uid,
                location: event.location,
              },
            });
          }
        }
      });

      // Update feed status
      await prisma.calendarFeed.update({
        where: { id: feedId },
        data: {
          lastFetchedAt: new Date(),
          lastStatus: CalendarFeedStatus.OK,
          lastError: null,
          itemCount: events.length,
        },
      });

      logger.debug(`Feed ${feed.name} refreshed: ${events.length} events`);
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Unknown error';
      await prisma.calendarFeed.update({
        where: { id: feedId },
        data: {
          lastFetchedAt: new Date(),
          lastStatus: CalendarFeedStatus.ERROR,
          lastError: message.slice(0, 500),
        },
      });
      logger.warn(`Feed ${feed.name} refresh failed: ${message}`);
    }
  },

  // =========================================================================
  // Export
  // =========================================================================

  async createExportToken(userId: string, data: CreateExportTokenInput) {
    const token = crypto.randomBytes(32).toString('hex');

    return prisma.calendarExportToken.create({
      data: {
        userId,
        token,
        includePersonal: data.includePersonal,
        includeLayers: data.includeLayers
          ? (data.includeLayers as unknown as Prisma.InputJsonValue)
          : undefined,
      },
    });
  },

  async revokeExportToken(userId: string, tokenId: string) {
    const exportToken = await prisma.calendarExportToken.findFirst({
      where: { id: tokenId, userId },
    });

    if (!exportToken) {
      throw new AppError(404, 'Export token not found', 'NOT_FOUND');
    }

    await prisma.calendarExportToken.delete({ where: { id: tokenId } });
  },

  async listExportTokens(userId: string) {
    return prisma.calendarExportToken.findMany({
      where: { userId },
      orderBy: { createdAt: 'desc' },
    });
  },

  async getExportFeed(token: string): Promise<string | null> {
    const exportToken = await prisma.calendarExportToken.findUnique({
      where: { token },
      include: { user: { select: { id: true, name: true } } },
    });

    if (!exportToken) return null;

    // Enforce 1-year expiry on export tokens (soft check — no DB column needed)
    const TOKEN_MAX_AGE_MS = 365 * 24 * 60 * 60 * 1000; // 1 year
    if (Date.now() - exportToken.createdAt.getTime() > TOKEN_MAX_AGE_MS) {
      // Token expired — clean it up and return null
      await prisma.calendarExportToken.delete({ where: { id: exportToken.id } }).catch(() => {});
      return null;
    }

    const now = new Date();
    const pastLimit = new Date(now);
    pastLimit.setMonth(pastLimit.getMonth() - 1);
    const futureLimit = new Date(now);
    futureLimit.setMonth(futureLimit.getMonth() + MATERIALIZE_MONTHS);

    // Build layer filter
    const layerWhere: Prisma.CalendarLayerWhereInput = {
      userId: exportToken.userId,
      isEnabled: true,
    };

    // If includeLayers is specified, filter to those layer IDs
    const includeLayerIds = exportToken.includeLayers as string[] | null;
    if (includeLayerIds && includeLayerIds.length > 0) {
      layerWhere.id = { in: includeLayerIds };
    }

    // If not includePersonal, exclude USER layers
    if (!exportToken.includePersonal) {
      layerWhere.layerType = { not: CalendarLayerType.USER };
    }

    const layers = await prisma.calendarLayer.findMany({ where: layerWhere });
    const layerIds = layers.map((l) => l.id);

    if (layerIds.length === 0) {
      // Return empty calendar
      const cal = ical({ name: 'Changemaker Calendar' });
      cal.method(ICalCalendarMethod.PUBLISH);
      return cal.toString();
    }

    const items = await prisma.calendarItem.findMany({
      where: {
        userId: exportToken.userId,
        layerId: { in: layerIds },
        date: { gte: pastLimit, lte: futureLimit },
      },
      orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
    });

    const cal = ical({
      name: `${exportToken.user.name || 'Changemaker'} Calendar`,
      prodId: { company: 'Changemaker', product: 'Calendar', language: 'EN' },
    });
    cal.method(ICalCalendarMethod.PUBLISH);

    for (const item of items) {
      const dateStr = item.date.toISOString().split('T')[0];
      const [startH, startM] = item.startTime.split(':').map(Number);
      const [endH, endM] = item.endTime.split(':').map(Number);

      const startDt = new Date(item.date);
      startDt.setHours(startH, startM, 0, 0);

      const endDt = new Date(item.date);
      endDt.setHours(endH, endM, 0, 0);

      // If end is before start (shouldn't happen), add 1 hour
      if (endDt <= startDt) {
        endDt.setHours(startDt.getHours() + 1);
      }

      const event = cal.createEvent({
        id: item.sourceId || item.id,
        start: startDt,
        end: endDt,
        summary: item.title,
        description: item.description || undefined,
        location: item.location || undefined,
        allDay: item.isAllDay,
      });

      if (item.isAllDay) {
        event.allDay(true);
      }
    }

    return cal.toString();
  },

  // =========================================================================
  // Queue helpers
  // =========================================================================

  /**
   * Get feeds that are due for refresh based on their interval and lastFetchedAt.
   */
  async getFeedsDueForRefresh(limit: number = 10) {
    const now = new Date();

    const feeds = await prisma.calendarFeed.findMany({
      orderBy: { lastFetchedAt: 'asc' },
      take: limit * 2, // fetch extra to filter
    });

    return feeds.filter((feed) => {
      if (!feed.lastFetchedAt) return true; // never fetched
      const intervalMs = INTERVAL_MS[feed.refreshInterval];
      const nextDue = new Date(feed.lastFetchedAt.getTime() + intervalMs);
      return now >= nextDue;
    }).slice(0, limit);
  },
};
