/**
 * LinkedIn Publisher Service
 *
 * Publishes content to LinkedIn (member profile or Company Page) via the
 * versioned REST Posts API. Supports:
 *   - text posts            → commentary only
 *   - link/article shares   → content.article
 *   - single image          → Images API (init → PUT bytes) → content.media
 *   - multi-image           → Images API per image → content.multiImage
 *   - video                 → Videos API (init → PUT parts → finalize) → content.media
 *
 * Media is BINARY-uploaded to LinkedIn-provided upload URLs (init → PUT), so no
 * public media hosting is needed (unlike Instagram). Scheduling is worker-driven
 * (LinkedIn has no native scheduled publish).
 *
 * Publishing isolation: the token is always resolved via
 * getFreshToken(companyId, createdBy, accountRef) — the owning admin's account.
 * A job never falls back to another connection.
 */

import fs from 'fs';
import path from 'path';
import { getFreshToken, getLinkedInCredentials } from './linkedinAuth';
import type { ISocialMediaPublication } from '../../models/SocialMediaPublication';

const API_BASE = 'https://api.linkedin.com';
const MAX_ATTEMPTS = 3;

// ============================================
// HELPERS
// ============================================

async function apiVersion(): Promise<string> {
  const creds = await getLinkedInCredentials();
  return creds?.apiVersion || process.env.LINKEDIN_API_VERSION || '202401';
}

function restHeaders(accessToken: string, version: string): Record<string, string> {
  return {
    Authorization: `Bearer ${accessToken}`,
    'LinkedIn-Version': version,
    'X-Restli-Protocol-Version': '2.0.0',
  };
}

function resolveFilePath(relative: string): string {
  return path.resolve(process.cwd(), relative.replace(/^\//, ''));
}

function isVideoPath(filePath: string): boolean {
  const ext = path.extname(filePath).toLowerCase();
  return ['.mp4', '.mov', '.avi', '.webm', '.m4v'].includes(ext);
}

/**
 * Escape the characters LinkedIn reserves in "commentary" text so posts don't
 * 422 on punctuation. The backslashes are stripped when LinkedIn renders the
 * post (so hashtags, parentheses, etc. still display correctly).
 */
function escapeCommentary(text: string): string {
  if (!text) return '';
  return text.replace(/[\\|{}@\[\]()<>#*_~]/g, (ch) => `\\${ch}`);
}

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 classifyError(status: number, body: string): { code: string; message: string; retryable: boolean } {
  let message = `LinkedIn API error (HTTP ${status})`;
  try {
    const parsed = JSON.parse(body);
    message = parsed?.message || parsed?.error_description || parsed?.error || message;
  } catch {
    // keep default
  }
  if (status === 401) return { code: 'invalid_token', message, retryable: false };
  if (status === 403) return { code: 'permission_denied', message, retryable: false };
  if (status === 422) return { code: 'content_rejected', message, retryable: false };
  if (status === 429) return { code: 'rate_limited', message, retryable: true };
  if (status >= 500) return { code: 'linkedin_server_error', message, retryable: true };
  return { code: 'linkedin_api_error', message, retryable: false };
}

// ============================================
// MEDIA UPLOAD
// ============================================

/** Upload a single image; returns the image URN. */
async function uploadImage(version: string, accessToken: string, authorUrn: string, filePath: string): Promise<string> {
  const initResponse = await fetch(`${API_BASE}/rest/images?action=initializeUpload`, {
    method: 'POST',
    headers: { ...restHeaders(accessToken, version), 'Content-Type': 'application/json' },
    body: JSON.stringify({ initializeUploadRequest: { owner: authorUrn } }),
  });
  if (!initResponse.ok) {
    const info = classifyError(initResponse.status, await initResponse.text());
    throw Object.assign(new Error(`Image init failed: ${info.message}`), info);
  }
  const init: any = await initResponse.json();
  const uploadUrl: string = init?.value?.uploadUrl;
  const imageUrn: string = init?.value?.image;
  if (!uploadUrl || !imageUrn) throw Object.assign(new Error('Image init returned no upload URL'), { code: 'media_init_failed', retryable: false });

  const bytes = await fs.promises.readFile(resolveFilePath(filePath));
  const putResponse = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { Authorization: `Bearer ${accessToken}` },
    body: new Uint8Array(bytes),
  });
  if (!putResponse.ok) {
    const info = classifyError(putResponse.status, await putResponse.text());
    throw Object.assign(new Error(`Image upload failed: ${info.message}`), info);
  }
  return imageUrn;
}

/** Upload a video (single- or multi-part); returns the video URN. */
async function uploadVideo(version: string, accessToken: string, authorUrn: string, filePath: string): Promise<string> {
  const absolute = resolveFilePath(filePath);
  const fileSize = (await fs.promises.stat(absolute)).size;

  const initResponse = await fetch(`${API_BASE}/rest/videos?action=initializeUpload`, {
    method: 'POST',
    headers: { ...restHeaders(accessToken, version), 'Content-Type': 'application/json' },
    body: JSON.stringify({
      initializeUploadRequest: { owner: authorUrn, fileSizeBytes: fileSize, uploadCaptions: false, uploadThumbnail: false },
    }),
  });
  if (!initResponse.ok) {
    const info = classifyError(initResponse.status, await initResponse.text());
    throw Object.assign(new Error(`Video init failed: ${info.message}`), info);
  }
  const init: any = await initResponse.json();
  const videoUrn: string = init?.value?.video;
  const uploadToken: string = init?.value?.uploadToken || '';
  const instructions: Array<{ uploadUrl: string; firstByte: number; lastByte: number }> = init?.value?.uploadInstructions || [];
  if (!videoUrn || instructions.length === 0) throw Object.assign(new Error('Video init returned no upload instructions'), { code: 'media_init_failed', retryable: false });

  const buffer = await fs.promises.readFile(absolute);
  const uploadedPartIds: string[] = [];
  for (const part of instructions) {
    const chunk = buffer.subarray(part.firstByte, part.lastByte + 1);
    const putResponse = await fetch(part.uploadUrl, {
      method: 'PUT',
      headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/octet-stream' },
      body: new Uint8Array(chunk),
    });
    if (!putResponse.ok) {
      const info = classifyError(putResponse.status, await putResponse.text());
      throw Object.assign(new Error(`Video part upload failed: ${info.message}`), info);
    }
    const etag = putResponse.headers.get('etag') || putResponse.headers.get('ETag') || '';
    uploadedPartIds.push(etag.replace(/"/g, ''));
  }

  const finalizeResponse = await fetch(`${API_BASE}/rest/videos?action=finalizeUpload`, {
    method: 'POST',
    headers: { ...restHeaders(accessToken, version), 'Content-Type': 'application/json' },
    body: JSON.stringify({ finalizeUploadRequest: { video: videoUrn, uploadToken, uploadedPartIds } }),
  });
  if (!finalizeResponse.ok) {
    const info = classifyError(finalizeResponse.status, await finalizeResponse.text());
    throw Object.assign(new Error(`Video finalize failed: ${info.message}`), info);
  }
  return videoUrn;
}

// ============================================
// POST BUILDER
// ============================================

async function buildPostContent(version: string, accessToken: string, authorUrn: string, publication: ISocialMediaPublication): Promise<{ content?: any; mediaUrns: string[] }> {
  const postType = publication.postType || 'text';
  const media = publication.mediaFilePaths || [];

  if (postType === 'text') {
    return { mediaUrns: [] };
  }

  if (postType === 'link') {
    return {
      content: {
        article: {
          source: publication.articleSource || publication.link,
          title: publication.articleTitle || undefined,
          description: publication.articleDescription || undefined,
        },
      },
      mediaUrns: [],
    };
  }

  if (postType === 'photo') {
    const imageUrn = await uploadImage(version, accessToken, authorUrn, media[0]);
    return { content: { media: { id: imageUrn } }, mediaUrns: [imageUrn] };
  }

  if (postType === 'carousel') {
    const images: Array<{ id: string }> = [];
    for (const filePath of media) {
      const urn = await uploadImage(version, accessToken, authorUrn, filePath);
      images.push({ id: urn });
    }
    return { content: { multiImage: { images } }, mediaUrns: images.map((i) => i.id) };
  }

  if (postType === 'video' || postType === 'reel') {
    const videoUrn = await uploadVideo(version, accessToken, authorUrn, media[0]);
    return { content: { media: { id: videoUrn } }, mediaUrns: [videoUrn] };
  }

  return { mediaUrns: [] };
}

function permalinkFor(postUrn: string): string {
  return `https://www.linkedin.com/feed/update/${postUrn}`;
}

// ============================================
// MAIN: PUBLISH A QUEUED PUBLICATION
// ============================================

export async function executePublication(publication: ISocialMediaPublication): Promise<void> {
  // Resolve the owning admin's token — the isolation checkpoint.
  const tokenResult = await getFreshToken(publication.companyId, publication.createdBy, publication.accountRef);
  if (tokenResult.error || !tokenResult.accessToken) {
    await failAttempt(publication, 'account_unavailable', tokenResult.error || 'Connected LinkedIn account unavailable', false);
    return;
  }

  const accessToken = tokenResult.accessToken;
  const authorUrn = publication.authorUrn || tokenResult.account?.authorUrn;
  if (!authorUrn) {
    await failAttempt(publication, 'author_missing', 'This publication has no target LinkedIn author', false);
    return;
  }

  const postType = publication.postType || 'text';
  const needsMedia = ['photo', 'carousel', 'video', 'reel'].includes(postType);
  if (needsMedia) {
    const media = publication.mediaFilePaths || [];
    if (media.length === 0) {
      await failAttempt(publication, 'source_file_missing', 'No media attached to this publication', false);
      return;
    }
    for (const p of media) {
      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 apiVersion();

    // Upload media (if any) and build the content block
    const { content, mediaUrns } = await buildPostContent(version, accessToken, authorUrn, publication);
    if (mediaUrns.length > 0) {
      publication.linkedinMediaUrns = mediaUrns;
      await publication.save();
    }

    // Organizations can only post PUBLIC; members may choose CONNECTIONS.
    const isOrg = authorUrn.startsWith('urn:li:organization:');
    const visibility = isOrg ? 'PUBLIC' : (publication.linkVisibility === 'CONNECTIONS' ? 'CONNECTIONS' : 'PUBLIC');

    const postBody: any = {
      author: authorUrn,
      commentary: escapeCommentary(publication.message || ''),
      visibility,
      distribution: { feedDistribution: 'MAIN_FEED', targetEntities: [], thirdPartyDistributionChannels: [] },
      lifecycleState: 'PUBLISHED',
      isReshareDisabledByAuthor: false,
    };
    if (content) postBody.content = content;

    const response = await fetch(`${API_BASE}/rest/posts`, {
      method: 'POST',
      headers: { ...restHeaders(accessToken, version), 'Content-Type': 'application/json' },
      body: JSON.stringify(postBody),
    });

    if (!response.ok) {
      const info = classifyError(response.status, await response.text());
      await failAttempt(publication, info.code, info.message, info.retryable);
      return;
    }

    const postUrn = response.headers.get('x-restli-id') || response.headers.get('x-linkedin-id') || '';
    publication.platformPostId = postUrn;
    publication.platformUrl = postUrn ? permalinkFor(postUrn) : 'https://www.linkedin.com/';
    publication.status = 'published';
    publication.publishedAt = new Date();
    publication.workerLockedAt = null as any;
    await publication.save();
  } catch (error: any) {
    const code = error.code || 'linkedin_api_error';
    const retryable = error.retryable === true;
    await failAttempt(publication, code, error.message || 'Publish failed', retryable);
  }
}

// ============================================
// STATUS POLLING (LinkedIn posts publish synchronously — no-op placeholder)
// ============================================

export async function syncPublicationStatus(_publication: ISocialMediaPublication): Promise<void> {
  // LinkedIn posts are published synchronously via /rest/posts, so there is no
  // async "processing" state to poll. Kept for worker interface parity; a future
  // phase can refresh analytics here.
  return;
}
