/**
 * Facebook Publisher Service
 *
 * Publishes content to a Facebook Page via the Graph API (raw fetch, consistent
 * with the rest of the codebase). Supports:
 *  - text / link posts        → POST /{page-id}/feed
 *  - single image             → POST /{page-id}/photos
 *  - carousel (multi-image)   → unpublished /photos + /feed attached_media
 *  - video                    → POST /{page-id}/videos (+ processing poll)
 *  - native scheduling        → published=false + scheduled_publish_time
 *  - draft                    → published=false (no schedule)
 *
 * Publishing isolation: the Page token is always resolved via
 * getFreshPageToken(companyId, createdBy, accountRef) — the owning admin's Page.
 * A job never falls back to another connection.
 */

import fs from 'fs';
import path from 'path';
import { getFreshPageToken, getFacebookCredentials } from './facebookAuth';
import type { ISocialMediaPublication } from '../../models/SocialMediaPublication';

const GRAPH_BASE = 'https://graph.facebook.com';
const MAX_ATTEMPTS = 3;

// Graph error codes that mean "reconnect", "rate limited", or "permission"
const TOKEN_ERROR_CODES = new Set([190]);
const RATE_LIMIT_CODES = new Set([4, 17, 32, 613, 80001]);
const PERMISSION_ERROR_CODES = new Set([10, 200, 299, 3]);

// ============================================
// HELPERS
// ============================================

async function graphVersion(): Promise<string> {
  const creds = await getFacebookCredentials();
  return creds?.graphVersion || process.env.FACEBOOK_GRAPH_API_VERSION || 'v21.0';
}

function recordError(publication: ISocialMediaPublication, code: string, message: string): void {
  const error = { code, message: message.slice(0, 500), at: new Date() };
  publication.lastError = error as any;
  publication.errorHistory.push(error as any);
}

async function failAttempt(publication: ISocialMediaPublication, code: string, message: string, retryable: boolean): Promise<void> {
  recordError(publication, code, message);
  publication.attemptCount += 1;
  publication.workerLockedAt = null as any;

  if (retryable && publication.attemptCount < MAX_ATTEMPTS) {
    publication.status = 'queued';
    publication.nextAttemptAt = new Date(Date.now() + Math.pow(4, publication.attemptCount) * 30 * 1000);
  } else {
    publication.status = 'failed';
    publication.nextAttemptAt = null as any;
  }
  await publication.save();
}

function resolveFilePath(relative: string): string {
  return path.resolve(process.cwd(), relative.replace(/^\//, ''));
}

function mimeForFile(filePath: string): string {
  const ext = path.extname(filePath).toLowerCase();
  switch (ext) {
    case '.png': return 'image/png';
    case '.webp': return 'image/webp';
    case '.gif': return 'image/gif';
    case '.jpg':
    case '.jpeg': return 'image/jpeg';
    case '.mov': return 'video/quicktime';
    case '.webm': return 'video/webm';
    case '.avi': return 'video/x-msvideo';
    case '.mp4':
    default: return 'video/mp4';
  }
}

/** Turn a Graph error body into a normalised { code, message, retryable, fatal }. */
function classifyGraphError(status: number, body: string): { code: string; message: string; retryable: boolean; fatal: boolean } {
  let fbCode: number | undefined;
  let message = `Facebook API error (HTTP ${status})`;
  try {
    const parsed = JSON.parse(body);
    if (parsed?.error) {
      fbCode = parsed.error.code;
      message = parsed.error.message || message;
    }
  } catch {
    // keep default
  }

  if (fbCode !== undefined && TOKEN_ERROR_CODES.has(fbCode)) {
    return { code: 'invalid_token', message, retryable: false, fatal: true };
  }
  if (fbCode !== undefined && PERMISSION_ERROR_CODES.has(fbCode)) {
    return { code: 'permission_missing', message, retryable: false, fatal: true };
  }
  if (fbCode !== undefined && RATE_LIMIT_CODES.has(fbCode)) {
    return { code: 'rate_limited', message, retryable: true, fatal: false };
  }
  if (status >= 500) {
    return { code: 'facebook_server_error', message, retryable: true, fatal: false };
  }
  return { code: 'facebook_api_error', message, retryable: status === 429, fatal: false };
}

async function readFileBlob(relative: string): Promise<{ blob: Blob; filename: string }> {
  const absolute = resolveFilePath(relative);
  const buffer = await fs.promises.readFile(absolute);
  const blob = new Blob([new Uint8Array(buffer)], { type: mimeForFile(absolute) });
  return { blob, filename: path.basename(absolute) };
}

function scheduledSeconds(publication: ISocialMediaPublication): number | null {
  if (!publication.publishAt) return null;
  const when = new Date(publication.publishAt).getTime();
  if (when <= Date.now()) return null; // past → publish now
  return Math.floor(when / 1000);
}

/**
 * Whether this publication should be created unpublished on Facebook.
 * Only native scheduling makes a post unpublished — a "draft" in this system is
 * a LOCAL draft (status 'draft', never sent to Facebook until the owner
 * publishes it), so once a job actually reaches the worker it always goes live
 * (now, or at the scheduled time).
 */
function isUnpublished(publication: ISocialMediaPublication): { unpublished: boolean; scheduleAt: number | null } {
  const scheduleAt = scheduledSeconds(publication);
  if (scheduleAt) return { unpublished: true, scheduleAt };
  return { unpublished: false, scheduleAt: null };
}

function permalinkFor(publication: ISocialMediaPublication, postOrVideoId: string, isVideo: boolean): string {
  if (isVideo) {
    return `https://www.facebook.com/${publication.pageId}/videos/${postOrVideoId}`;
  }
  // Feed/photo post ids are "{pageId}_{postId}" and resolve directly
  return `https://www.facebook.com/${postOrVideoId}`;
}

// ============================================
// POST-TYPE PUBLISHERS
// ============================================

async function publishTextOrLink(version: string, pageId: string, pageToken: string, publication: ISocialMediaPublication): Promise<string> {
  const { unpublished, scheduleAt } = isUnpublished(publication);
  const form = new URLSearchParams();
  if (publication.message) form.set('message', publication.message);
  if (publication.link) form.set('link', publication.link);
  form.set('published', unpublished ? 'false' : 'true');
  if (scheduleAt) form.set('scheduled_publish_time', String(scheduleAt));
  form.set('access_token', pageToken);

  const response = await fetch(`${GRAPH_BASE}/${version}/${pageId}/feed`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: form.toString(),
  });
  if (!response.ok) {
    const info = classifyGraphError(response.status, await response.text());
    throw Object.assign(new Error(info.message), info);
  }
  const data: any = await response.json();
  return data.id;
}

async function uploadPhoto(version: string, pageId: string, pageToken: string, mediaPath: string, opts: { published: boolean; caption?: string; scheduleAt?: number | null }): Promise<{ id: string; post_id?: string }> {
  const { blob, filename } = await readFileBlob(mediaPath);
  const form = new FormData();
  form.append('source', blob, filename);
  form.append('published', opts.published ? 'true' : 'false');
  if (opts.caption) form.append('caption', opts.caption);
  if (opts.scheduleAt) form.append('scheduled_publish_time', String(opts.scheduleAt));
  form.append('access_token', pageToken);

  const response = await fetch(`${GRAPH_BASE}/${version}/${pageId}/photos`, { method: 'POST', body: form });
  if (!response.ok) {
    const info = classifyGraphError(response.status, await response.text());
    throw Object.assign(new Error(info.message), info);
  }
  return response.json() as Promise<{ id: string; post_id?: string }>;
}

async function publishSinglePhoto(version: string, pageId: string, pageToken: string, publication: ISocialMediaPublication): Promise<string> {
  const { unpublished, scheduleAt } = isUnpublished(publication);
  const mediaPath = publication.mediaFilePaths?.[0];
  if (!mediaPath) throw Object.assign(new Error('No image attached'), { code: 'source_file_missing', retryable: false, fatal: true });

  const result = await uploadPhoto(version, pageId, pageToken, mediaPath, {
    published: !unpublished,
    caption: publication.message,
    scheduleAt,
  });
  // For a published photo, post_id is the feed story; fall back to the photo id.
  return result.post_id || result.id;
}

async function publishCarousel(version: string, pageId: string, pageToken: string, publication: ISocialMediaPublication): Promise<string> {
  const paths = publication.mediaFilePaths || [];
  if (paths.length < 2) throw Object.assign(new Error('A carousel needs at least 2 images'), { code: 'validation', retryable: false, fatal: true });

  // 1. Upload every image unpublished to get media_fbid values
  const mediaIds: string[] = [];
  for (const mediaPath of paths) {
    const uploaded = await uploadPhoto(version, pageId, pageToken, mediaPath, { published: false });
    mediaIds.push(uploaded.id);
  }
  publication.mediaFbIds = mediaIds;

  // 2. Create the feed post referencing the uploaded media
  const { unpublished, scheduleAt } = isUnpublished(publication);
  const form = new URLSearchParams();
  if (publication.message) form.set('message', publication.message);
  mediaIds.forEach((id, i) => form.set(`attached_media[${i}]`, JSON.stringify({ media_fbid: id })));
  form.set('published', unpublished ? 'false' : 'true');
  if (scheduleAt) form.set('scheduled_publish_time', String(scheduleAt));
  form.set('access_token', pageToken);

  const response = await fetch(`${GRAPH_BASE}/${version}/${pageId}/feed`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: form.toString(),
  });
  if (!response.ok) {
    const info = classifyGraphError(response.status, await response.text());
    throw Object.assign(new Error(info.message), info);
  }
  const data: any = await response.json();
  return data.id;
}

async function publishVideo(version: string, pageId: string, pageToken: string, publication: ISocialMediaPublication): Promise<string> {
  const mediaPath = publication.mediaFilePaths?.[0] || publication.videoFilePath;
  if (!mediaPath) throw Object.assign(new Error('No video attached'), { code: 'source_file_missing', retryable: false, fatal: true });

  const { unpublished, scheduleAt } = isUnpublished(publication);
  const { blob, filename } = await readFileBlob(mediaPath);
  const form = new FormData();
  form.append('source', blob, filename);
  if (publication.message) form.append('description', publication.message);
  if (publication.title) form.append('title', publication.title);
  form.append('published', unpublished ? 'false' : 'true');
  if (scheduleAt) form.append('scheduled_publish_time', String(scheduleAt));
  form.append('access_token', pageToken);

  const response = await fetch(`${GRAPH_BASE}/${version}/${pageId}/videos`, { method: 'POST', body: form });
  if (!response.ok) {
    const info = classifyGraphError(response.status, await response.text());
    throw Object.assign(new Error(info.message), info);
  }
  const data: any = await response.json();
  return data.id; // video id
}

// ============================================
// MAIN: PUBLISH A QUEUED PUBLICATION
// ============================================

/**
 * Execute a queued Facebook publication. Owns the lifecycle:
 * queued → uploading → processing/scheduled/published.
 */
export async function executePublication(publication: ISocialMediaPublication): Promise<void> {
  // Resolve the owning admin's Page token — the isolation checkpoint.
  const tokenResult = await getFreshPageToken(publication.companyId, publication.createdBy, publication.accountRef);
  if (tokenResult.error || !tokenResult.pageToken) {
    await failAttempt(publication, 'account_unavailable', tokenResult.error || 'Connected Page unavailable', false);
    return;
  }

  const pageToken = tokenResult.pageToken;
  const pageId = publication.pageId || tokenResult.account?.pageId;
  if (!pageId) {
    await failAttempt(publication, 'page_missing', 'This publication has no target Page', false);
    return;
  }

  // Validate that required media exists on disk before starting
  const postType = publication.postType || 'text';
  const needsMedia = ['photo', 'carousel', 'video', 'reel'].includes(postType);
  if (needsMedia) {
    const paths = publication.mediaFilePaths?.length ? publication.mediaFilePaths : (publication.videoFilePath ? [publication.videoFilePath] : []);
    if (paths.length === 0) {
      await failAttempt(publication, 'source_file_missing', 'No media file attached to this publication', false);
      return;
    }
    for (const p of paths) {
      if (!fs.existsSync(resolveFilePath(p))) {
        await failAttempt(publication, 'source_file_missing', 'A media file no longer exists on the server', false);
        return;
      }
    }
  }

  try {
    publication.status = 'uploading';
    await publication.save();

    const version = await graphVersion();
    const isVideo = postType === 'video' || postType === 'reel';

    let resultId: string;
    switch (postType) {
      case 'photo':
        resultId = await publishSinglePhoto(version, pageId, pageToken, publication);
        break;
      case 'carousel':
        resultId = await publishCarousel(version, pageId, pageToken, publication);
        break;
      case 'video':
      case 'reel':
        resultId = await publishVideo(version, pageId, pageToken, publication);
        break;
      case 'link':
      case 'text':
      default:
        resultId = await publishTextOrLink(version, pageId, pageToken, publication);
        break;
    }

    publication.platformPostId = resultId;
    publication.platformUrl = permalinkFor(publication, resultId, isVideo);
    publication.uploadProgress = 100;
    publication.workerLockedAt = null as any;

    const { scheduleAt } = isUnpublished(publication);
    if (scheduleAt) {
      publication.status = 'scheduled';
    } else if (isVideo) {
      // Videos process asynchronously on Facebook — poll until ready
      publication.status = 'processing';
    } else {
      publication.status = 'published';
      publication.publishedAt = new Date();
    }
    await publication.save();
  } catch (error: any) {
    const code = error.code || 'facebook_api_error';
    const retryable = error.retryable === true;
    await failAttempt(publication, code, error.message || 'Publish failed', retryable);
  }
}

// ============================================
// STATUS POLLING
// ============================================

/**
 * Poll Facebook for the state of a processing video or a scheduled post and
 * advance the publication lifecycle. Called by the shared publish worker.
 */
export async function syncPublicationStatus(publication: ISocialMediaPublication): Promise<void> {
  if (!publication.platformPostId) return;

  const tokenResult = await getFreshPageToken(publication.companyId, publication.createdBy, publication.accountRef);
  if (tokenResult.error || !tokenResult.pageToken) return;

  const version = await graphVersion();
  const pageToken = tokenResult.pageToken;

  try {
    if (publication.status === 'processing') {
      // Video processing status
      const response = await fetch(
        `${GRAPH_BASE}/${version}/${publication.platformPostId}?fields=status&access_token=${encodeURIComponent(pageToken)}`
      );
      if (!response.ok) return;
      const data: any = await response.json();
      const videoStatus = data?.status?.video_status; // ready | processing | error
      if (videoStatus === 'ready') {
        publication.status = 'published';
        publication.publishedAt = publication.publishedAt || new Date();
        await publication.save();
      } else if (videoStatus === 'error') {
        recordError(publication, 'video_processing_failed', 'Facebook failed to process the video');
        publication.status = 'failed';
        await publication.save();
      }
      return;
    }

    if (publication.status === 'scheduled') {
      // Native scheduling: Facebook publishes at scheduled_publish_time. Check
      // whether the post has gone live yet.
      const response = await fetch(
        `${GRAPH_BASE}/${version}/${publication.platformPostId}?fields=is_published&access_token=${encodeURIComponent(pageToken)}`
      );
      if (response.ok) {
        const data: any = await response.json();
        if (data?.is_published === true) {
          publication.status = 'published';
          publication.publishedAt = publication.publishedAt || new Date();
          await publication.save();
          return;
        }
      }
      // Fallback: if the scheduled time has clearly passed, mark it published.
      if (publication.publishAt && new Date(publication.publishAt).getTime() < Date.now() - 5 * 60 * 1000) {
        publication.status = 'published';
        publication.publishedAt = publication.publishedAt || new Date(publication.publishAt);
        await publication.save();
      }
    }
  } catch (error) {
    console.error('Facebook status sync error:', error);
  }
}

// ============================================
// CANCEL A SCHEDULED / DRAFT POST
// ============================================

/**
 * Cancel a scheduled or draft Facebook post: delete it on the platform so it
 * never goes live. Published posts are also deletable here if the caller allows.
 */
export async function cancelScheduledPost(publication: ISocialMediaPublication): Promise<{ success: boolean; error?: string }> {
  if (!publication.platformPostId) return { success: true };

  const tokenResult = await getFreshPageToken(publication.companyId, publication.createdBy, publication.accountRef);
  if (tokenResult.error || !tokenResult.pageToken) {
    return { success: false, error: tokenResult.error || 'Connected Page unavailable' };
  }

  const version = await graphVersion();
  try {
    const response = await fetch(
      `${GRAPH_BASE}/${version}/${publication.platformPostId}?access_token=${encodeURIComponent(tokenResult.pageToken)}`,
      { method: 'DELETE' }
    );
    if (!response.ok) {
      const info = classifyGraphError(response.status, await response.text());
      // If the post is already gone, treat as success
      if (response.status === 404) return { success: true };
      return { success: false, error: info.message };
    }
    return { success: true };
  } catch (error) {
    console.error('Facebook cancel scheduled post error:', error);
    return { success: false, error: 'Failed to cancel the scheduled post on Facebook' };
  }
}
