import crypto from 'crypto';
import path from 'path';
import fs from 'fs/promises';
import { createReadStream, existsSync } from 'fs';
import type { ReadStream } from 'fs';
import bcrypt from 'bcryptjs';
import sharp from 'sharp';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { emailService } from '../../services/email.service';
import { siteSettingsService } from '../settings/settings.service';
import { env } from '../../config/env';
import type { ProfileSelfUpdateInput, ProfileActivityInput } from './profile-public.schemas';
import type { GenerateProfileLinkInput, UpdateProfileLinkInput } from './people.schemas';

const UPLOAD_DIR = '/app/uploads/profile-photos';
const COVER_WIDTH = 800;
const COVER_HEIGHT = 400;
const THUMB_WIDTH = 200;
const THUMB_HEIGHT = 100;
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

// Activity types that are safe to show to the contact themselves
const PUBLIC_ACTIVITY_TYPES = [
  'EMAIL_SENT',
  'RESPONSE_SUBMITTED',
  'SHIFT_SIGNUP',
  'CANVASS_VISIT',
  'DONATION',
  'PURCHASE',
  'VIDEO_VIEW',
  'PROFILE_SELF_EDIT',
  'PROFILE_PHOTO_UPDATED',
];

// Fields that should NOT be returned to the public profile
const HIDDEN_FIELDS = [
  'notes',
  'doNotContact',
  'signRequested',
  'mergedIntoId',
  'createdByUserId',
] as const;

const BCRYPT_SALT_ROUNDS = 12;

/** Map duration enum → milliseconds */
const DURATION_MS: Record<string, number> = {
  '24h': 24 * 60 * 60 * 1000,
  '7d': 7 * 24 * 60 * 60 * 1000,
  '30d': 30 * 24 * 60 * 60 * 1000,
  '90d': 90 * 24 * 60 * 60 * 1000,
  '1y': 365 * 24 * 60 * 60 * 1000,
};

type ValidContactResult =
  | { status: 'not_found' }
  | { status: 'expired'; contact: { id: string; profileTokenExpiresAt: Date } }
  | { status: 'ok'; contact: NonNullable<Awaited<ReturnType<typeof prisma.contact.findUnique>>> };

type Branding = {
  organizationName: string;
  organizationShortName: string;
  organizationLogoUrl: string | null;
  publicColorPrimary: string;
  publicColorBgBase: string;
  publicColorBgContainer: string;
  publicHeaderGradient: string;
};

class ProfileService {
  /**
   * Shared helper: look up contact by token + check merged + check expiration
   */
  private async getValidContact(token: string): Promise<ValidContactResult> {
    const contact = await prisma.contact.findUnique({
      where: { profileToken: token },
    });

    if (!contact || contact.mergedIntoId) {
      return { status: 'not_found' };
    }

    if (contact.profileTokenExpiresAt && contact.profileTokenExpiresAt < new Date()) {
      return { status: 'expired', contact: { id: contact.id, profileTokenExpiresAt: contact.profileTokenExpiresAt } };
    }

    return { status: 'ok', contact };
  }

  /**
   * Public pre-check for access control: returns discriminated union for route handler
   */
  async validateProfileAccess(token: string): Promise<
    | { status: 'ok' }
    | { status: 'not_found' }
    | { status: 'expired'; expiresAt: Date }
    | { status: 'password_required'; branding: Branding }
  > {
    const result = await this.getValidContact(token);

    if (result.status === 'not_found') return { status: 'not_found' };
    if (result.status === 'expired') {
      return { status: 'expired', expiresAt: result.contact.profileTokenExpiresAt };
    }

    // Check password protection
    if (result.contact.profilePasswordHash) {
      const settings = await siteSettingsService.get();
      return {
        status: 'password_required',
        branding: {
          organizationName: settings.organizationName,
          organizationShortName: settings.organizationShortName,
          organizationLogoUrl: settings.organizationLogoUrl,
          publicColorPrimary: settings.publicColorPrimary,
          publicColorBgBase: settings.publicColorBgBase,
          publicColorBgContainer: settings.publicColorBgContainer,
          publicHeaderGradient: settings.publicHeaderGradient,
        },
      };
    }

    return { status: 'ok' };
  }

  /**
   * Verify password for a protected profile link
   * Returns the full profile on success, null on wrong password, or status string for errors
   */
  async verifyProfilePassword(token: string, password: string): Promise<
    | { status: 'ok'; profile: NonNullable<Awaited<ReturnType<ProfileService['getProfileByToken']>>> }
    | { status: 'invalid_password' }
    | { status: 'not_found' }
    | { status: 'expired'; expiresAt: Date }
  > {
    const result = await this.getValidContact(token);

    if (result.status === 'not_found') return { status: 'not_found' };
    if (result.status === 'expired') {
      return { status: 'expired', expiresAt: result.contact.profileTokenExpiresAt };
    }

    if (!result.contact.profilePasswordHash) {
      // No password required — just return the profile
      const profile = await this.getProfileByToken(token);
      if (!profile) return { status: 'not_found' };
      return { status: 'ok', profile };
    }

    const valid = await bcrypt.compare(password, result.contact.profilePasswordHash);
    if (!valid) return { status: 'invalid_password' };

    const profile = await this.getProfileByToken(token);
    if (!profile) return { status: 'not_found' };
    return { status: 'ok', profile };
  }

  /**
   * Look up a contact by their profile token, returning safe public data.
   * If viewerUserId is provided and matches contact.userId, adds isOwnProfile + enableSocial flags.
   */
  async getProfileByToken(token: string, viewerUserId?: string) {
    const result = await this.getValidContact(token);
    if (result.status !== 'ok') return null;
    const contact = result.contact;

    // Get org branding for the profile page header
    const settings = await siteSettingsService.get();
    const branding = {
      organizationName: settings.organizationName,
      organizationShortName: settings.organizationShortName,
      organizationLogoUrl: settings.organizationLogoUrl,
      publicColorPrimary: settings.publicColorPrimary,
      publicColorBgBase: settings.publicColorBgBase,
      publicColorBgContainer: settings.publicColorBgContainer,
      publicHeaderGradient: settings.publicHeaderGradient,
    };

    // Build engagement summary (reuse people service pattern)
    const engagement = await this.getPublicEngagementSummary(contact.id, contact.email, contact.phone);

    // Look up primary address
    const primaryLink = await prisma.contactAddress.findFirst({
      where: { contactId: contact.id, isPrimary: true },
      include: { address: { include: { location: { select: { address: true } } } } },
    });
    const primaryAddress = primaryLink?.address?.location?.address || null;

    // Check if the authenticated viewer owns this contact profile
    const isOwnProfile = viewerUserId ? contact.userId === viewerUserId : false;

    // Return sanitized profile — strip hidden fields
    return {
      id: contact.id,
      displayName: contact.displayName,
      firstName: contact.firstName,
      lastName: contact.lastName,
      email: contact.email,
      phone: contact.phone,
      primaryAddress,
      tags: contact.tags as string[],
      supportLevel: contact.supportLevel,
      primarySource: contact.primarySource,
      emailOptOut: contact.emailOptOut,
      smsOptOut: contact.smsOptOut,
      coverPhotoPath: contact.coverPhotoPath ? true : false, // Boolean only — don't expose path
      engagementScore: engagement.score,
      engagement,
      branding,
      isOwnProfile,
      enableSocial: settings.enableSocial ?? false,
    };
  }

  /**
   * Look up a contact by ID (admin preview) — same shape as public profile + profileToken
   */
  async getProfileByContactId(contactId: string) {
    const contact = await prisma.contact.findUnique({
      where: { id: contactId },
    });

    if (!contact || contact.mergedIntoId) {
      return null;
    }

    const settings = await siteSettingsService.get();
    const branding = {
      organizationName: settings.organizationName,
      organizationShortName: settings.organizationShortName,
      organizationLogoUrl: settings.organizationLogoUrl,
      publicColorPrimary: settings.publicColorPrimary,
      publicColorBgBase: settings.publicColorBgBase,
      publicColorBgContainer: settings.publicColorBgContainer,
      publicHeaderGradient: settings.publicHeaderGradient,
    };

    const engagement = await this.getPublicEngagementSummary(contact.id, contact.email, contact.phone);

    // Look up primary address
    const primaryLink = await prisma.contactAddress.findFirst({
      where: { contactId: contact.id, isPrimary: true },
      include: { address: { include: { location: { select: { address: true } } } } },
    });
    const primaryAddress = primaryLink?.address?.location?.address || null;

    return {
      id: contact.id,
      displayName: contact.displayName,
      firstName: contact.firstName,
      lastName: contact.lastName,
      email: contact.email,
      phone: contact.phone,
      primaryAddress,
      tags: contact.tags as string[],
      supportLevel: contact.supportLevel,
      primarySource: contact.primarySource,
      emailOptOut: contact.emailOptOut,
      smsOptOut: contact.smsOptOut,
      coverPhotoPath: contact.coverPhotoPath ? true : false,
      engagementScore: engagement.score,
      engagement,
      branding,
      profileToken: contact.profileToken,
    };
  }

  /**
   * Serve cover photo by contact ID (admin preview)
   */
  serveCoverPhotoByContactId(contactId: string, size: 'cover' | 'thumb' = 'cover'): { stream: ReadStream; contentType: string } | null {
    const suffix = size === 'thumb' ? '-thumb.jpg' : '-cover.jpg';
    const filePath = path.join(UPLOAD_DIR, `${contactId}${suffix}`);

    if (!existsSync(filePath)) return null;

    return {
      stream: createReadStream(filePath),
      contentType: 'image/jpeg',
    };
  }

  /**
   * Update only the self-editable fields
   */
  async updateProfileSelfService(token: string, data: ProfileSelfUpdateInput) {
    const result = await this.getValidContact(token);
    if (result.status !== 'ok') return null;
    const contact = result.contact;

    // Build update payload — only self-editable fields
    const updateData: Record<string, unknown> = {};
    if (data.displayName !== undefined) updateData.displayName = data.displayName;
    if (data.firstName !== undefined) updateData.firstName = data.firstName || null;
    if (data.lastName !== undefined) updateData.lastName = data.lastName || null;
    if (data.email !== undefined) updateData.email = data.email || null;
    if (data.phone !== undefined) updateData.phone = data.phone || null;
    if (data.emailOptOut !== undefined) updateData.emailOptOut = data.emailOptOut;
    if (data.smsOptOut !== undefined) updateData.smsOptOut = data.smsOptOut;

    const hasAddressUpdate = !!(data.address && data.address.trim());

    if (Object.keys(updateData).length === 0 && !hasAddressUpdate) {
      return contact;
    }

    let updated = contact;
    if (Object.keys(updateData).length > 0) {
      updateData.lastSelfEditAt = new Date();
      updated = await prisma.contact.update({
        where: { id: contact.id },
        data: updateData,
      });
    }

    // Log activity
    await prisma.contactActivity.create({
      data: {
        contactId: contact.id,
        type: 'PROFILE_SELF_EDIT',
        title: 'Profile updated via self-service',
        description: `Fields updated: ${Object.keys(data).filter(k => data[k as keyof ProfileSelfUpdateInput] !== undefined).join(', ')}`,
        occurredAt: new Date(),
      },
    });

    // Handle address update via peopleService (geocodes + creates Location/Address/ContactAddress)
    if (data.address && data.address.trim()) {
      try {
        const { peopleService } = await import('./people.service');
        await peopleService.addAddress(
          contact.id,
          { address: data.address.trim(), isPrimary: true, addToMap: true },
          contact.userId,
        );
      } catch (err) {
        logger.warn(`Self-service address update failed for contact ${contact.id}`, err);
      }
    }

    return updated;
  }

  /**
   * Upload + process a cover photo: resize to 800x400 cover + 200x100 thumb
   */
  async uploadCoverPhoto(
    token: string,
    fileBuffer: Buffer,
    mimeType: string,
    originalName: string,
  ) {
    const result = await this.getValidContact(token);
    if (result.status !== 'ok') return null;
    const contact = result.contact;

    // Validate MIME type
    if (!ALLOWED_MIME_TYPES.includes(mimeType)) {
      throw new Error('Invalid file type. Allowed: JPEG, PNG, WebP');
    }

    // Validate file size
    if (fileBuffer.length > MAX_FILE_SIZE) {
      throw new Error('File too large. Maximum size: 5MB');
    }

    // Validate with sharp (also strips EXIF)
    try {
      await sharp(fileBuffer).metadata();
    } catch {
      throw new Error('Invalid image file');
    }

    // Ensure upload directory exists
    await fs.mkdir(UPLOAD_DIR, { recursive: true });

    const coverPath = path.join(UPLOAD_DIR, `${contact.id}-cover.jpg`);
    const thumbPath = path.join(UPLOAD_DIR, `${contact.id}-thumb.jpg`);

    // Process: resize + auto-orient + strip all EXIF (sharp strips metadata by default)
    await sharp(fileBuffer)
      .resize(COVER_WIDTH, COVER_HEIGHT, { fit: 'cover', position: 'centre' })
      .rotate() // Auto-rotate based on EXIF before strip
      .jpeg({ quality: 85 })
      .toFile(coverPath);

    await sharp(fileBuffer)
      .resize(THUMB_WIDTH, THUMB_HEIGHT, { fit: 'cover', position: 'centre' })
      .rotate()
      .jpeg({ quality: 75 })
      .toFile(thumbPath);

    // Update contact record
    await prisma.contact.update({
      where: { id: contact.id },
      data: {
        coverPhotoPath: coverPath,
        lastSelfEditAt: new Date(),
      },
    });

    // Log activity — sanitize user-supplied filename before storage
    const sanitizedName = originalName.replace(/[<>"'&]/g, '').slice(0, 200);
    await prisma.contactActivity.create({
      data: {
        contactId: contact.id,
        type: 'PROFILE_PHOTO_UPDATED',
        title: 'Cover photo updated via self-service',
        description: `Uploaded: ${sanitizedName}`,
        occurredAt: new Date(),
      },
    });

    return { success: true };
  }

  /**
   * Serve cover photo file as a read stream
   */
  async serveCoverPhoto(token: string, size: 'cover' | 'thumb' = 'cover'): Promise<{ stream: ReadStream; contentType: string } | null> {
    const result = await this.getValidContact(token);
    if (result.status !== 'ok') return null;
    const contact = result.contact;

    if (!contact.coverPhotoPath) return null;

    const suffix = size === 'thumb' ? '-thumb.jpg' : '-cover.jpg';
    const filePath = path.join(UPLOAD_DIR, `${contact.id}${suffix}`);

    if (!existsSync(filePath)) return null;

    return {
      stream: createReadStream(filePath),
      contentType: 'image/jpeg',
    };
  }

  /**
   * Get paginated activity timeline (filtered for public-safe types only)
   */
  async getProfileActivity(token: string, params: ProfileActivityInput) {
    const result = await this.getValidContact(token);
    if (result.status !== 'ok') return null;
    const contact = result.contact;

    const where = {
      contactId: contact.id,
      type: { in: PUBLIC_ACTIVITY_TYPES as any },
    };

    const [activities, total] = await Promise.all([
      prisma.contactActivity.findMany({
        where,
        orderBy: { occurredAt: 'desc' },
        skip: (params.page - 1) * params.limit,
        take: params.limit,
      }),
      prisma.contactActivity.count({ where }),
    ]);

    return {
      activities,
      pagination: {
        page: params.page,
        limit: params.limit,
        total,
        totalPages: Math.ceil(total / params.limit),
      },
    };
  }

  /**
   * Generate a new profile token for a contact, with optional expiration + password
   */
  async generateProfileToken(contactId: string, options?: GenerateProfileLinkInput) {
    const token = crypto.randomBytes(32).toString('hex');

    // Compute expiration date
    let expiresAt: Date | null = null;
    const duration = options?.expiresIn || 'never';
    if (duration !== 'never' && DURATION_MS[duration]) {
      expiresAt = new Date(Date.now() + DURATION_MS[duration]);
    }

    // Hash password if provided
    let passwordHash: string | null = null;
    if (options?.password) {
      passwordHash = await bcrypt.hash(options.password, BCRYPT_SALT_ROUNDS);
    }

    const contact = await prisma.contact.update({
      where: { id: contactId },
      data: {
        profileToken: token,
        profileTokenExpiresAt: expiresAt,
        profilePasswordHash: passwordHash,
      },
    });

    return {
      token: contact.profileToken!,
      url: this.buildProfileUrl(contact.profileToken!),
      expiresAt: contact.profileTokenExpiresAt?.toISOString() || null,
      hasPassword: !!contact.profilePasswordHash,
    };
  }

  /**
   * Regenerate profile token (invalidates old one)
   */
  async regenerateProfileToken(contactId: string, options?: GenerateProfileLinkInput) {
    return this.generateProfileToken(contactId, options);
  }

  /**
   * Revoke profile token — clears token, expiration, and password hash
   */
  async revokeProfileToken(contactId: string) {
    await prisma.contact.update({
      where: { id: contactId },
      data: {
        profileToken: null,
        profileTokenExpiresAt: null,
        profilePasswordHash: null,
      },
    });
  }

  /**
   * Update profile link settings (expiration/password) without regenerating the token
   */
  async updateProfileLinkSettings(contactId: string, options: UpdateProfileLinkInput) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact || !contact.profileToken) {
      throw new Error('Contact not found or has no profile link');
    }

    const data: Record<string, unknown> = {};

    // Update expiration
    if (options.expiresIn !== undefined) {
      if (options.expiresIn === 'never') {
        data.profileTokenExpiresAt = null;
      } else if (DURATION_MS[options.expiresIn]) {
        data.profileTokenExpiresAt = new Date(Date.now() + DURATION_MS[options.expiresIn]);
      }
    }

    // Update password
    if (options.removePassword) {
      data.profilePasswordHash = null;
    } else if (options.password) {
      data.profilePasswordHash = await bcrypt.hash(options.password, BCRYPT_SALT_ROUNDS);
    }

    if (Object.keys(data).length === 0) {
      return {
        token: contact.profileToken,
        url: this.buildProfileUrl(contact.profileToken),
        expiresAt: contact.profileTokenExpiresAt?.toISOString() || null,
        hasPassword: !!contact.profilePasswordHash,
      };
    }

    const updated = await prisma.contact.update({
      where: { id: contactId },
      data,
    });

    return {
      token: updated.profileToken!,
      url: this.buildProfileUrl(updated.profileToken!),
      expiresAt: updated.profileTokenExpiresAt?.toISOString() || null,
      hasPassword: !!updated.profilePasswordHash,
    };
  }

  /**
   * Send profile link via email to the contact
   */
  async sendProfileLink(contactId: string, adminUserId: string) {
    const contact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!contact) throw new Error('Contact not found');
    if (!contact.email) throw new Error('Contact has no email address');

    // Generate token if not already present
    if (!contact.profileToken) {
      await this.generateProfileToken(contactId);
    }

    const freshContact = await prisma.contact.findUnique({ where: { id: contactId } });
    if (!freshContact?.profileToken) throw new Error('Failed to generate profile token');

    const profileUrl = this.buildProfileUrl(freshContact.profileToken);
    const settings = await siteSettingsService.get();

    // Build extra notes for email
    const extraNotes: string[] = [];
    if (freshContact.profileTokenExpiresAt) {
      const expiryDate = freshContact.profileTokenExpiresAt.toLocaleDateString('en-CA', {
        year: 'numeric', month: 'long', day: 'numeric',
      });
      extraNotes.push(`This link will expire on ${expiryDate}.`);
    }
    if (freshContact.profilePasswordHash) {
      extraNotes.push('This link is password-protected. You will need the password provided separately by the organization.');
    }

    await emailService.sendProfileLinkEmail({
      recipientEmail: freshContact.email!,
      recipientName: freshContact.displayName,
      profileUrl,
      organizationName: settings.organizationName,
      extraNotes: extraNotes.length > 0 ? extraNotes.join(' ') : undefined,
    });

    // Log activity
    await prisma.contactActivity.create({
      data: {
        contactId,
        type: 'NOTE_ADDED',
        title: 'Profile link sent via email',
        description: `Sent by admin to ${freshContact.email}`,
        occurredAt: new Date(),
      },
    });

    return { success: true, url: profileUrl };
  }

  /**
   * Build the full profile URL
   */
  private buildProfileUrl(token: string): string {
    const domain = env.DOMAIN || 'localhost:3000';
    const protocol = env.NODE_ENV === 'production' ? 'https' : 'http';
    const appSubdomain = env.NODE_ENV === 'production' ? `app.${domain}` : domain;
    return `${protocol}://${appSubdomain}/profile/${token}`;
  }

  /**
   * Simplified engagement summary for public profile
   */
  private async getPublicEngagementSummary(contactId: string, email: string | null, phone: string | null) {
    // Count activities by type for this contact
    const activityCounts = await prisma.contactActivity.groupBy({
      by: ['type'],
      where: {
        contactId,
        type: { in: PUBLIC_ACTIVITY_TYPES as any },
      },
      _count: { type: true },
    });

    const countMap = Object.fromEntries(
      activityCounts.map((a) => [a.type, a._count.type]),
    );

    // Simple engagement score calculation
    const emailsSent = countMap.EMAIL_SENT || 0;
    const responses = countMap.RESPONSE_SUBMITTED || 0;
    const shiftSignups = countMap.SHIFT_SIGNUP || 0;
    const visits = countMap.CANVASS_VISIT || 0;
    const donations = countMap.DONATION || 0;
    const videoViews = countMap.VIDEO_VIEW || 0;

    const score = Math.min(100,
      emailsSent * 5 +
      responses * 15 +
      shiftSignups * 10 +
      visits * 8 +
      donations * 20 +
      videoViews * 2,
    );

    return {
      score,
      emailsSent,
      responsesSubmitted: responses,
      shiftsSignedUp: shiftSignups,
      canvassVisits: visits,
      donationCount: donations,
      videoViews,
    };
  }
}

export const profileService = new ProfileService();
