import { createHash } from 'crypto';
import { escXml, buildAtomSummary, markdownToText } from './atom-utils';
import { verifySignature } from '@atproto/crypto';
import { IdResolver } from '@atproto/identity';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, BatchGetCommand, DeleteCommand, GetCommand, PutCommand, QueryCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';

const API_DID = process.env.API_DID!;
const SITE_DOMAIN = process.env.SITE_DOMAIN ?? '';
const TABLE = process.env.DYNAMODB_TABLE ?? 'blogs';
const CELEBRITY_THRESHOLD = parseInt(process.env.CELEBRITY_THRESHOLD ?? '1000', 10);
const CELEBRITY_POST_LIMIT = 10;

const idResolver = new IdResolver({});
const ddb = DynamoDBDocumentClient.from(
  new DynamoDBClient(
    process.env.AWS_ENDPOINT_URL
      ? { endpoint: process.env.AWS_ENDPOINT_URL, region: process.env.AWS_REGION }
      : {},
  ),
);

const secretsClient = new SecretsManagerClient({});
let cachedIframelyKey: string | null = null;

async function getIframelyKey(): Promise<string> {
  if (cachedIframelyKey) return cachedIframelyKey;
  const secretArn = process.env.IFRAMELY_SECRET_ARN;
  if (!secretArn) throw new Error('IFRAMELY_SECRET_ARN is not configured');
  const { SecretString } = await secretsClient.send(new GetSecretValueCommand({ SecretId: secretArn }));
  if (!SecretString) throw new Error('Iframely secret is empty');
  cachedIframelyKey = SecretString;
  return cachedIframelyKey;
}

// Verifies an ATProto service JWT (iss = user DID, signed with user's atproto key).
// Returns the user's DID on success.
async function verifyServiceToken(jwtStr: string): Promise<string> {
  const parts = jwtStr.split('.');
  if (parts.length !== 3) throw new Error('Malformed JWT');
  const [headerB64, payloadB64, sigB64] = parts;

  let payload: { iss?: string; aud?: string | string[]; exp?: number };
  try {
    payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
  } catch {
    throw new Error('Malformed JWT payload');
  }

  const { iss, aud, exp } = payload;
  if (!iss || !iss.startsWith('did:')) throw new Error('Token iss must be a DID');

  const audList = Array.isArray(aud) ? aud : [aud];
  if (!audList.includes(API_DID)) throw new Error(`Token aud mismatch: expected ${API_DID}, got ${String(aud)}`);

  if (!exp || exp < Math.floor(Date.now() / 1000)) throw new Error('Token is expired');

  const signingKey = await idResolver.did.resolveAtprotoKey(iss);
  const msgBytes = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
  const sigBytes = Buffer.from(sigB64, 'base64url');
  const valid = await verifySignature(signingKey, msgBytes, sigBytes);
  if (!valid) throw new Error('Invalid signature');

  return iss;
}

function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 {
  return {
    statusCode,
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
      'Access-Control-Allow-Headers': 'Authorization, Content-Type',
    },
    body: JSON.stringify(body),
  };
}

async function listPublications(did: string): Promise<APIGatewayProxyResultV2> {
  const result = await ddb.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: 'PK = :pk',
      ExpressionAttributeValues: { ':pk': `u#${did}#pub` },
      ScanIndexForward: false,
    }),
  );
  const publications = (result.Items ?? []).map((item) => ({
    sk: item.SK as string,
    atUri: item.atUri as string,
    url: item.url as string,
    name: item.name as string,
    description: item.description as string | undefined,
    authorHandle: item.resolvedHandle as string | undefined,
    preferences: { showInDiscover: (item.preferences as { showInDiscover?: boolean } | undefined)?.showInDiscover ?? false },
  }));
  return json(200, publications);
}

async function createPublication(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  let atUri: string, url: string, name: string, rkey: string;
  let description: string | undefined;
  let showInDiscover: boolean;
  try {
    const body = JSON.parse(event.body ?? '{}') as {
      atUri?: string; url?: string; name?: string; rkey?: string;
      description?: string; preferences?: { showInDiscover?: boolean };
    };
    if (!body.atUri?.trim()) return json(400, { error: 'atUri is required' });
    if (!body.url?.trim()) return json(400, { error: 'url is required' });
    if (!body.name?.trim()) return json(400, { error: 'name is required' });
    if (!body.rkey?.trim()) return json(400, { error: 'rkey is required' });
    atUri = body.atUri.trim();
    url = body.url.trim();
    name = body.name.trim();
    rkey = body.rkey.trim();
    description = body.description?.trim() || undefined;
    showInDiscover = body.preferences?.showInDiscover ?? false;
  } catch {
    return json(400, { error: 'Invalid JSON body' });
  }

  const sk = `pub#${rkey}`;
  const resolvedHandle = await resolveAuthorHandle(did);
  const item: Record<string, unknown> = {
    PK: `u#${did}#pub`, SK: sk,
    entityType: 'publication',
    atUri, url, name, resolvedHandle,
    preferences: { showInDiscover },
  };
  if (description !== undefined) item.description = description;

  await ddb.send(new PutCommand({ TableName: TABLE, Item: item }));

  const normalizedUrl = decodeURIComponent(url.replace(/\/$/, ''));
  await ddb.send(new PutCommand({
    TableName: TABLE,
    Item: { PK: `url#${normalizedUrl}`, SK: 'info', entityType: 'url-lookup', pubAtUri: atUri },
  }));

  return json(201, { sk, atUri, url, name, description, preferences: { showInDiscover } });
}

async function updatePublication(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  const rkey = event.queryStringParameters?.rkey;
  if (!rkey) return json(400, { error: 'rkey query parameter is required' });

  let name: string, description: string | undefined, showInDiscover: boolean;
  try {
    const body = JSON.parse(event.body ?? '{}') as {
      name?: string; description?: string; preferences?: { showInDiscover?: boolean };
    };
    if (!body.name?.trim()) return json(400, { error: 'name is required' });
    name = body.name.trim();
    description = body.description?.trim() || undefined;
    showInDiscover = body.preferences?.showInDiscover ?? false;
  } catch {
    return json(400, { error: 'Invalid JSON body' });
  }

  const setExpr = description !== undefined
    ? 'SET #name = :name, preferences = :preferences, description = :description'
    : 'SET #name = :name, preferences = :preferences REMOVE description';
  const exprValues: Record<string, unknown> = {
    ':name': name,
    ':preferences': { showInDiscover },
  };
  if (description !== undefined) exprValues[':description'] = description;

  await ddb.send(new UpdateCommand({
    TableName: TABLE,
    Key: { PK: `u#${did}#pub`, SK: `pub#${rkey}` },
    UpdateExpression: setExpr,
    ExpressionAttributeNames: { '#name': 'name' },
    ExpressionAttributeValues: exprValues,
  }));
  return json(200, { name, description, preferences: { showInDiscover } });
}

async function deletePublication(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  const rkey = event.queryStringParameters?.rkey;
  if (!rkey) return json(400, { error: 'rkey query parameter is required' });
  const key = { PK: `u#${did}#pub`, SK: `pub#${rkey}` };
  const existing = await ddb.send(new GetCommand({ TableName: TABLE, Key: key }));
  const pubUrl = existing.Item?.url as string | undefined;
  await ddb.send(new DeleteCommand({ TableName: TABLE, Key: key }));
  if (pubUrl) {
    const normalizedUrl = decodeURIComponent(pubUrl.replace(/\/$/, ''));
    await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `url#${normalizedUrl}`, SK: 'info' } }));
  }
  return json(200, {});
}

async function verifyPublication(rawPath: string): Promise<APIGatewayProxyResultV2> {
  if (!SITE_DOMAIN) {
    return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' };
  }
  const wellKnownSuffix = '/.well-known/site.standard.publication';
  // rawPath is e.g. '/.well-known/site.standard.publication' (root pub)
  // or '/did%3Aplc%3Axxx/pub/rkey/.well-known/site.standard.publication' (non-root pub)
  const rawPubPath = rawPath.slice(0, rawPath.length - wellKnownSuffix.length);
  // Decode percent-encoding so the lookup URL matches what was stored (e.g. 'did:plc:xxx' not 'did%3Aplc%3Axxx')
  const pubPath = rawPubPath ? decodeURIComponent(rawPubPath) : '';
  const url = `https://${SITE_DOMAIN}${pubPath}`;
  const result = await ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `url#${url}`, SK: 'info' } }));
  if (!result.Item?.pubAtUri) {
    return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' };
  }
  return { statusCode: 200, headers: { 'Content-Type': 'text/plain' }, body: result.Item.pubAtUri as string };
}


async function getPublicationFeed(rawPath: string): Promise<APIGatewayProxyResultV2> {
  if (!SITE_DOMAIN) {
    return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' };
  }

  const feedSuffix = '/feed.xml';
  const rawPubPath = rawPath.slice(0, rawPath.length - feedSuffix.length);
  const pubPath = decodeURIComponent(rawPubPath);
  const pubUrl = `https://${SITE_DOMAIN}${pubPath}`;

  const urlLookup = await ddb.send(new GetCommand({
    TableName: TABLE,
    Key: { PK: `url#${pubUrl}`, SK: 'info' },
  }));
  if (!urlLookup.Item?.pubAtUri) {
    return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' };
  }

  const pubAtUri = urlLookup.Item.pubAtUri as string;
  const atParts = pubAtUri.slice('at://'.length).split('/');
  const did = atParts[0];
  const pubRkey = atParts[2];

  const [pubResult, postsResult, authorHandle] = await Promise.all([
    ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${did}#pub`, SK: `pub#${pubRkey}` } })),
    ddb.send(new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: 'PK = :pk',
      FilterExpression: 'visibility = :vis AND site = :site',
      ExpressionAttributeValues: { ':pk': `u#${did}#post`, ':vis': 'published', ':site': pubAtUri },
      ScanIndexForward: false,
      Limit: 100,
    })),
    resolveAuthorHandle(did),
  ]);

  if (!pubResult.Item) {
    return { statusCode: 404, headers: { 'Content-Type': 'text/plain' }, body: 'Not found' };
  }

  const pub = pubResult.Item;
  const pubName = pub.name as string;
  const pubSiteUrl = (pub.url as string).replace(/\/$/, '');
  const pubDescription = pub.description as string | undefined;
  const posts = (postsResult.Items ?? []).slice(0, 20);
  const selfUrl = `https://${SITE_DOMAIN}${rawPath}`;
  const updatedAt = posts.length > 0
    ? ((posts[0].updatedAt ?? posts[0].publishedAt) as string)
    : new Date().toISOString();

  const entryXml = posts.map((post) => {
    const base = (post.resolvedUrl as string | undefined ?? pubSiteUrl).replace(/\/$/, '');
    const postUrl = `${base}${post.path as string | undefined ?? ''}`;
    const rawContent = post.content as string | undefined;
    const summary = rawContent ? markdownToText(rawContent) : (post.description ?? post.textContent) as string | undefined;
    const lines = [
      '  <entry>',
      `    <id>${escXml(post.atUri as string)}</id>`,
      `    <title>${escXml(post.title as string)}</title>`,
      `    <link href="${escXml(postUrl)}" rel="alternate" type="text/html"/>`,
      `    <published>${post.publishedAt as string}</published>`,
      `    <updated>${(post.updatedAt ?? post.publishedAt) as string}</updated>`,
    ];
    if (summary) lines.push(`    ${buildAtomSummary(summary, postUrl, post.title as string)}`);
    lines.push('  </entry>');
    return lines.join('\n');
  });

  const feedLines = [
    '<?xml version="1.0" encoding="UTF-8"?>',
    '<feed xmlns="http://www.w3.org/2005/Atom">',
    `  <id>${escXml(pubAtUri)}</id>`,
    `  <title>${escXml(pubName)}</title>`,
  ];
  if (pubDescription) feedLines.push(`  <subtitle>${escXml(pubDescription)}</subtitle>`);
  feedLines.push(
    `  <link href="${escXml(pubSiteUrl)}" rel="alternate" type="text/html"/>`,
    `  <link href="${escXml(selfUrl)}" rel="self" type="application/atom+xml"/>`,
    `  <updated>${updatedAt}</updated>`,
    '  <author>',
    `    <name>${escXml(authorHandle)}</name>`,
    '  </author>',
    ...entryXml,
    '</feed>',
  );

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/atom+xml; charset=utf-8', 'Cache-Control': 'public, max-age=300' },
    body: feedLines.join('\n'),
  };
}

async function resolvePublicationMeta(atUri: string): Promise<{ url: string; name: string }> {
  const parts = atUri.slice('at://'.length).split('/')
  const pubDid = parts[0]
  const pubRkey = parts[2]

  const cached = await ddb.send(new GetCommand({
    TableName: TABLE,
    Key: { PK: `u#${pubDid}#pub`, SK: `pub#${pubRkey}` },
  }))
  if (cached.Item?.url) {
    return {
      url: (cached.Item.url as string).replace(/\/$/, ''),
      name: (cached.Item.name as string | undefined) ?? '',
    }
  }

  const doc = await idResolver.did.resolve(pubDid)
  const pds = (doc?.service as Array<{ type: string; serviceEndpoint: string }> | undefined)
    ?.find((s) => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
  if (!pds) throw new Error(`No PDS for ${pubDid}`)

  // Validate PDS URL to prevent SSRF via a malicious DID document.
  const pdsUrl = new URL(pds)
  if (pdsUrl.protocol !== 'https:') throw new Error(`PDS must use HTTPS: ${pds}`)
  if (/^(localhost|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.|::1|0\.0\.0\.0)/.test(pdsUrl.hostname)) {
    throw new Error(`PDS hostname is a private/reserved address: ${pdsUrl.hostname}`)
  }

  const res = await fetch(
    `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(pubDid)}&collection=site.standard.publication&rkey=${encodeURIComponent(pubRkey)}`,
  )
  if (!res.ok) throw new Error(`Publication not found: ${atUri}`)
  const record = (await res.json()) as { value: { url: string; name: string } }
  return { url: record.value.url.replace(/\/$/, ''), name: record.value.name ?? '' }
}

async function resolveAuthorHandle(did: string): Promise<string> {
  try {
    const doc = await idResolver.did.resolve(did)
    const handle = (doc?.alsoKnownAs as string[] | undefined)?.[0]?.replace('at://', '')
    return handle ?? did
  } catch {
    return did
  }
}


const FEED_PAGE_SIZE = 20

async function getFeed(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const cursorParam = event.queryStringParameters?.cursor
  let exclusiveStartKey: Record<string, unknown> | undefined
  if (cursorParam) {
    try {
      exclusiveStartKey = JSON.parse(Buffer.from(cursorParam, 'base64url').toString())
    } catch {
      return json(400, { error: 'Invalid cursor' })
    }
  }

  const result = await ddb.send(new QueryCommand({
    TableName: TABLE,
    IndexName: 'FeedIndex',
    KeyConditionExpression: 'GSI1PK = :pk',
    ExpressionAttributeValues: { ':pk': 'blog-post#published' },
    ScanIndexForward: false,
    Limit: FEED_PAGE_SIZE,
    ExclusiveStartKey: exclusiveStartKey,
  }))

  const items = (result.Items ?? []).map((item) => ({
    sk: item.SK as string,
    atUri: item.atUri as string,
    site: item.site as string,
    resolvedUrl: item.resolvedUrl as string | undefined,
    resolvedPubName: item.resolvedPubName as string | undefined,
    resolvedHandle: item.resolvedHandle as string | undefined,
    path: item.path as string | undefined,
    title: item.title as string,
    publishedAt: item.publishedAt as string,
    coverImageRef: item.coverImageRef as string | undefined,
    textContent: item.textContent ? (item.textContent as string).slice(0, 300) : undefined,
    description: item.description as string | undefined,
  }))

  const nextCursor = result.LastEvaluatedKey
    ? Buffer.from(JSON.stringify(result.LastEvaluatedKey)).toString('base64url')
    : null

  return json(200, { items, cursor: nextCursor })
}

async function getUserPublicPosts(did: string): Promise<APIGatewayProxyResultV2> {
  const result = await ddb.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: 'PK = :pk',
      FilterExpression: 'visibility = :vis',
      ExpressionAttributeValues: { ':pk': `u#${did}#post`, ':vis': 'published' },
      ScanIndexForward: false,
    }),
  );
  const posts = (result.Items ?? []).map((item) => ({
    sk: item.SK as string,
    atUri: item.atUri as string,
    site: item.site as string,
    resolvedUrl: item.resolvedUrl as string | undefined,
    resolvedPubName: item.resolvedPubName as string | undefined,
    resolvedHandle: item.resolvedHandle as string | undefined,
    path: item.path as string | undefined,
    title: item.title as string,
    publishedAt: item.publishedAt as string,
    visibility: 'published' as const,
    coverImageRef: item.coverImageRef as string | undefined,
    textContent: item.textContent ? (item.textContent as string).slice(0, 300) : undefined,
    description: item.description as string | undefined,
  }));
  return json(200, posts);
}

async function getUserBlogPosts(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  const rkey = event.queryStringParameters?.rkey;
  if (rkey) return getPostDetail(did, rkey);
  const result = await ddb.send(
    new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: 'PK = :pk',
      ExpressionAttributeValues: { ':pk': `u#${did}#post` },
      // Sort by SK in descending order to get newest posts first
      ScanIndexForward: false,
    }),
  );
  const posts = (result.Items ?? []).map((item) => ({
    sk: item.SK as string,
    atUri: item.atUri as string,
    site: item.site as string,
    resolvedUrl: item.resolvedUrl as string | undefined,
    resolvedPubName: item.resolvedPubName as string | undefined,
    resolvedHandle: item.resolvedHandle as string | undefined,
    path: item.path as string | undefined,
    title: item.title as string,
    publishedAt: item.publishedAt as string,
    visibility: (item.visibility as string) === 'draft' ? 'draft' : 'published',
    coverImageRef: item.coverImageRef as string | undefined,
    textContent: item.textContent ? (item.textContent as string).slice(0, 300) : undefined,
    description: item.description as string | undefined,
  }));
  return json(200, posts);
}

async function createPost(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  const VALID_LICENSES = ['CC BY', 'CC BY-SA', 'CC BY-ND', 'CC BY-NC', 'CC BY-NC-SA', 'CC BY-NC-ND', 'CC0'];
  let atUri: string, site: string, resolvedUrl: string, resolvedPubName: string,
    resolvedHandle: string, path: string, rkey: string,
    title: string, publishedAt: string, updatedAt: string | undefined, content: string | undefined,
    visibility: 'published' | 'draft', coverImageRef: string | undefined,
    bskyPostRef: { uri: string; cid: string } | undefined,
    draftRkey: string | undefined, license: string | undefined;
  try {
    const body = JSON.parse(event.body ?? '{}') as {
      atUri?: string; site?: string; path?: string; rkey?: string; title?: string;
      publishedAt?: string; updatedAt?: string; content?: string; visibility?: string; coverImageRef?: string;
      bskyPostRef?: { uri: string; cid: string }; draftRkey?: string; license?: string;
    };
    if (!body.atUri?.trim()) return json(400, { error: 'atUri is required' });
    if (!body.site?.trim()) return json(400, { error: 'site is required' });
    if (!body.rkey?.trim()) return json(400, { error: 'rkey is required' });
    if (!body.title?.trim()) return json(400, { error: 'title is required' });
    atUri = body.atUri.trim();
    rkey = body.rkey.trim();
    path = (body.path ?? `/${rkey}`).replace(/\/$/, '') || `/${rkey}`;
    title = body.title.trim();
    publishedAt = body.publishedAt ?? new Date().toISOString();
    updatedAt = body.updatedAt;
    content = body.content;
    visibility = body.visibility === 'draft' ? 'draft' : 'published';
    coverImageRef = body.coverImageRef?.trim() || undefined;
    if (body.bskyPostRef !== undefined) {
      if (
        typeof body.bskyPostRef !== 'object' ||
        body.bskyPostRef === null ||
        typeof body.bskyPostRef.uri !== 'string' ||
        typeof body.bskyPostRef.cid !== 'string' ||
        !body.bskyPostRef.uri.startsWith('at://') ||
        body.bskyPostRef.uri.length > 512 ||
        body.bskyPostRef.cid.length > 256 ||
        body.bskyPostRef.uri.split('/')[2] !== did
      ) {
        return json(400, { error: 'Invalid bskyPostRef' });
      }
      bskyPostRef = body.bskyPostRef;
    }

    draftRkey = body.draftRkey?.trim() || undefined;
    if (body.license !== undefined) {
      if (typeof body.license !== 'string' || !VALID_LICENSES.includes(body.license)) {
        return json(400, { error: 'Invalid license' });
      }
      license = body.license;
    }
    site = body.site.trim();
    const siteIsAtUri = site.startsWith('at://')
    const [pubMeta, handle] = await Promise.all([
      siteIsAtUri
        ? resolvePublicationMeta(site)
        : Promise.resolve({ url: site.replace(/\/$/, ''), name: '' }),
      resolveAuthorHandle(did),
    ])
    resolvedUrl = pubMeta.url
    resolvedPubName = pubMeta.name
    resolvedHandle = handle
  } catch (err) {
    if (err instanceof SyntaxError) return json(400, { error: 'Invalid JSON body' });
    throw err;
  }

  const sk = visibility === 'draft' ? `draft#${rkey}` : `p#${rkey}`;
  const item: Record<string, unknown> = {
    PK: `u#${did}#post`, SK: sk, GSI1PK: `blog-post#${visibility}`, GSI1SK: `${new Date(Math.min(new Date(publishedAt).getTime(), Date.now())).toISOString()}#${rkey}`,
    entityType: 'blog-post', visibility,
    atUri, site, resolvedUrl, resolvedPubName, resolvedHandle, path, title, publishedAt,
  };
  if (updatedAt !== undefined) item.updatedAt = updatedAt;
  if (content !== undefined) { item.content = content; item.textContent = markdownToText(content); }
  if (coverImageRef !== undefined) item.coverImageRef = coverImageRef;
  if (bskyPostRef !== undefined) item.bskyPostRef = bskyPostRef;
  if (license !== undefined) item.license = license;

  await ddb.send(new PutCommand({ TableName: TABLE, Item: item }));

  // When publishing, clean up the draft record (draft → published promotion).
  // draftRkey is the old draft rkey (may differ from the new published rkey).
  if (visibility === 'published') {
    const rkeyToDelete = draftRkey ?? rkey;
    await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `u#${did}#post`, SK: `draft#${rkeyToDelete}` } }));
  }

  return json(201, { sk, atUri, site, resolvedUrl, path, title, publishedAt });
}

async function deletePost(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  const rkey = event.queryStringParameters?.rkey;
  if (!rkey) return json(400, { error: 'rkey query parameter is required' });
  await Promise.all([
    ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `u#${did}#post`, SK: `p#${rkey}` } })),
    ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `u#${did}#post`, SK: `draft#${rkey}` } })),
  ]);
  return json(200, {});
}

async function getPostDetail(did: string, rkey: string): Promise<APIGatewayProxyResultV2> {
  const [published, draft] = await Promise.all([
    ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${did}#post`, SK: `p#${rkey}` } })),
    ddb.send(new GetCommand({ TableName: TABLE, Key: { PK: `u#${did}#post`, SK: `draft#${rkey}` } })),
  ]);
  const result = { Item: published.Item ?? draft.Item };
  if (!result.Item) return json(404, { error: 'Post not found' });
  const item = result.Item;
  return json(200, {
    sk: item.SK as string,
    atUri: item.atUri as string,
    site: item.site as string,
    resolvedUrl: item.resolvedUrl as string | undefined,
    path: item.path as string | undefined,
    title: item.title as string,
    publishedAt: item.publishedAt as string,
    updatedAt: item.updatedAt as string | undefined,
    visibility: (item.visibility as string) === 'draft' ? 'draft' : 'published',
    content: item.content as string | undefined,
    coverImageRef: item.coverImageRef as string | undefined,
    bskyPostRef: item.bskyPostRef as { uri: string; cid: string } | undefined,
    license: item.license as string | undefined,
  });
}


const IFRAMELY_CACHE_TTL_SECONDS = 86400; // 24 hours

async function getIframelyEmbed(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const url = event.queryStringParameters?.url;
  if (!url) return json(400, { error: 'url query parameter is required' });

  const urlHash = createHash('sha256').update(url).digest('hex');
  const cacheKey = { PK: 'iframely#cache', SK: urlHash };

  // Return cached response if still within TTL.
  try {
    const cached = await ddb.send(new GetCommand({ TableName: TABLE, Key: cacheKey }));
    if (cached.Item && (cached.Item.ttl as number) > Math.floor(Date.now() / 1000)) {
      return {
        statusCode: cached.Item.statusCode as number,
        headers: {
          'Content-Type': cached.Item.contentType as string,
          'Access-Control-Allow-Origin': '*',
          'Access-Control-Allow-Methods': 'GET, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type',
        },
        body: cached.Item.body as string,
      };
    }
  } catch (err) {
    console.warn('iframely cache read failed:', err);
  }

  const apiKey = await getIframelyKey();
  const iframelyUrl = `https://iframe.ly/api/iframely?url=${encodeURIComponent(url)}&key=${encodeURIComponent(apiKey)}`;

  const res = await fetch(iframelyUrl);
  const body = await res.text();
  const contentType = res.headers.get('content-type') ?? 'application/json';

  // Cache successful responses only.
  if (res.ok) {
    try {
      await ddb.send(new PutCommand({
        TableName: TABLE,
        Item: {
          ...cacheKey,
          statusCode: res.status,
          contentType,
          body,
          ttl: Math.floor(Date.now() / 1000) + IFRAMELY_CACHE_TTL_SECONDS,
        },
      }));
    } catch (err) {
      console.warn('iframely cache write failed:', err);
    }
  }

  return {
    statusCode: res.status,
    headers: {
      'Content-Type': contentType,
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
    body,
  };
}

const TIMELINE_TTL_SECONDS = 30 * 24 * 60 * 60;

async function fanInExistingPosts(readerDid: string, authorDid: string, publicationAtUri: string): Promise<void> {
  const posts: Record<string, unknown>[] = [];
  let lastKey: Record<string, unknown> | undefined;

  do {
    const result = await ddb.send(new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: 'PK = :pk',
      FilterExpression: 'visibility = :vis AND site = :site',
      ExpressionAttributeValues: { ':pk': `u#${authorDid}#post`, ':vis': 'published', ':site': publicationAtUri },
      ScanIndexForward: false,
      Limit: 100,
      ExclusiveStartKey: lastKey,
    }));
    posts.push(...(result.Items ?? []));
    lastKey = result.LastEvaluatedKey as Record<string, unknown> | undefined;
  } while (lastKey && posts.length < 20);

  const recentPosts = posts.slice(0, 20);
  if (recentPosts.length === 0) return;

  const ttl = Math.floor(Date.now() / 1000) + TIMELINE_TTL_SECONDS;
  await Promise.all(recentPosts.map((post) => {
    const postRkey = (post.SK as string).slice('p#'.length);
    const item: Record<string, unknown> = {
      PK: `u#${readerDid}#timeline`,
      SK: `${(post.GSI1SK as string | undefined)?.split('#')[0] ?? new Date(Math.min(new Date(post.publishedAt as string).getTime(), Date.now())).toISOString()}#${postRkey}`,
      entityType: 'timeline-item',
      authorDid,
      rkey: postRkey,
      title: post.title as string,
      publishedAt: post.publishedAt as string,
      publicationName: post.resolvedPubName as string | undefined,
      resolvedUrl: post.resolvedUrl as string | undefined,
      path: post.path as string | undefined,
      resolvedHandle: post.resolvedHandle as string | undefined,
      ttl,
    };
    if (post.coverImageRef) item.coverImageCid = post.coverImageRef as string;
    if (post.description) item.description = post.description as string;
    if (post.textContent) item.textContent = (post.textContent as string).slice(0, 300);
    return ddb.send(new PutCommand({ TableName: TABLE, Item: item }));
  }));
}

async function backfillSubscription(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  let rkey: string, publicationAtUri: string, subscribedAt: string;
  try {
    const body = JSON.parse(event.body ?? '{}') as {
      rkey?: string; publicationAtUri?: string; subscribedAt?: string;
    };
    if (!body.rkey?.trim()) return json(400, { error: 'rkey is required' });
    if (!body.publicationAtUri?.trim()) return json(400, { error: 'publicationAtUri is required' });
    rkey = body.rkey.trim();
    publicationAtUri = body.publicationAtUri.trim();
    subscribedAt = body.subscribedAt ?? new Date().toISOString();
  } catch {
    return json(400, { error: 'Invalid JSON body' });
  }

  if (!publicationAtUri.startsWith('at://')) return json(400, { error: 'publicationAtUri must be an at:// URI' });
  const authorDid = publicationAtUri.slice('at://'.length).split('/')[0];

  // Write subscription item (always idempotent)
  await ddb.send(new PutCommand({
    TableName: TABLE,
    Item: {
      PK: `u#${did}#subscriptions`,
      SK: `sub#${rkey}`,
      entityType: 'subscription',
      publicationAtUri,
      authorDid,
      subscribedAt,
    },
  }));

  // Conditionally write subscriber item; only increment count if the item is new.
  // This makes the endpoint safe to call multiple times without double-counting.
  try {
    await ddb.send(new PutCommand({
      TableName: TABLE,
      Item: {
        PK: `u#${authorDid}#subscribers`,
        SK: `u#${did}`,
        entityType: 'subscriber',
        subscribedAt,
      },
      ConditionExpression: 'attribute_not_exists(PK)',
    }));
    await ddb.send(new UpdateCommand({
      TableName: TABLE,
      Key: { PK: `u#${authorDid}`, SK: 'count' },
      UpdateExpression: 'ADD subscribers :inc',
      ExpressionAttributeValues: { ':inc': 1 },
    }));
  } catch (err) {
    if ((err as { name?: string }).name !== 'ConditionalCheckFailedException') throw err;
    // Item already existed — skip count increment
  }

  await fanInExistingPosts(did, authorDid, publicationAtUri);
  return json(200, {});
}

async function listSubscriptions(did: string): Promise<APIGatewayProxyResultV2> {
  const result = await ddb.send(new QueryCommand({
    TableName: TABLE,
    KeyConditionExpression: 'PK = :pk',
    ExpressionAttributeValues: { ':pk': `u#${did}#subscriptions` },
  }));
  const items = (result.Items ?? []).map((item) => ({
    rkey: (item.SK as string).slice('sub#'.length),
    publicationAtUri: item.publicationAtUri as string,
    authorDid: item.authorDid as string,
    subscribedAt: item.subscribedAt as string,
  }));
  return json(200, items);
}

async function deleteSubscription(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  const rkey = event.queryStringParameters?.rkey?.trim()
  if (!rkey) return json(400, { error: 'rkey is required' })

  const existing = await ddb.send(new GetCommand({
    TableName: TABLE,
    Key: { PK: `u#${did}#subscriptions`, SK: `sub#${rkey}` },
  }))
  if (!existing.Item) return json(404, { error: 'Subscription not found' })

  const authorDid = existing.Item.authorDid as string

  // Remove all timeline items for this author from the reader's timeline
  async function removeAuthorTimeline(): Promise<void> {
    let lastKey: Record<string, unknown> | undefined
    do {
      const result = await ddb.send(new QueryCommand({
        TableName: TABLE,
        KeyConditionExpression: 'PK = :pk',
        FilterExpression: 'authorDid = :authorDid',
        ExpressionAttributeValues: { ':pk': `u#${did}#timeline`, ':authorDid': authorDid },
        ProjectionExpression: 'PK, SK',
        ExclusiveStartKey: lastKey,
      }))
      await Promise.all((result.Items ?? []).map((item) =>
        ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: item.PK, SK: item.SK } }))
      ))
      lastKey = result.LastEvaluatedKey as Record<string, unknown> | undefined
    } while (lastKey)
  }

  await Promise.all([
    ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `u#${did}#subscriptions`, SK: `sub#${rkey}` } })),
    ddb.send(new DeleteCommand({ TableName: TABLE, Key: { PK: `u#${authorDid}#subscribers`, SK: `u#${did}` } })),
    ddb.send(new UpdateCommand({
      TableName: TABLE,
      Key: { PK: `u#${authorDid}`, SK: 'count' },
      UpdateExpression: 'ADD subscribers :dec',
      ExpressionAttributeValues: { ':dec': -1 },
    })),
    removeAuthorTimeline(),
  ])

  return json(200, {})
}

async function getTimeline(did: string, event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const cursorParam = event.queryStringParameters?.cursor;
  let exclusiveStartKey: Record<string, unknown> | undefined;
  if (cursorParam) {
    try {
      exclusiveStartKey = JSON.parse(Buffer.from(cursorParam, 'base64url').toString());
    } catch {
      return json(400, { error: 'Invalid cursor' });
    }
  }

  // Fetch subscriptions to identify celebrity authors
  const subsResult = await ddb.send(new QueryCommand({
    TableName: TABLE,
    KeyConditionExpression: 'PK = :pk',
    ExpressionAttributeValues: { ':pk': `u#${did}#subscriptions` },
  }));
  const subscriptions = subsResult.Items ?? [];
  const authorDids = [...new Set(subscriptions.map((s) => s.authorDid as string))];

  // BatchGet subscriber counts to identify celebrities
  const celebrityDids = new Set<string>();
  for (let i = 0; i < authorDids.length; i += 100) {
    const chunk = authorDids.slice(i, i + 100);
    const batchResult = await ddb.send(new BatchGetCommand({
      RequestItems: {
        [TABLE]: {
          Keys: chunk.map((authorDid) => ({ PK: `u#${authorDid}`, SK: 'count' })),
        },
      },
    }));
    for (const item of batchResult.Responses?.[TABLE] ?? []) {
      const count = (item.subscribers as number | undefined) ?? 0;
      if (count > CELEBRITY_THRESHOLD) {
        celebrityDids.add((item.PK as string).slice('u#'.length));
      }
    }
  }

  // Query personal timeline (fan-out items for non-celebrity authors)
  const timelineResult = await ddb.send(new QueryCommand({
    TableName: TABLE,
    KeyConditionExpression: 'PK = :pk',
    ExpressionAttributeValues: { ':pk': `u#${did}#timeline` },
    ScanIndexForward: false,
    Limit: FEED_PAGE_SIZE,
    ExclusiveStartKey: exclusiveStartKey,
  }));

  const timelineItems = (timelineResult.Items ?? []).map((item) => ({
    authorDid: item.authorDid as string,
    rkey: item.rkey as string,
    title: item.title as string,
    publishedAt: item.publishedAt as string,
    publicationName: item.publicationName as string | undefined,
    pubAtUri: item.pubAtUri as string | undefined,
    resolvedUrl: item.resolvedUrl as string | undefined,
    path: item.path as string | undefined,
    resolvedHandle: item.resolvedHandle as string | undefined,
    coverImageCid: item.coverImageCid as string | undefined,
    description: item.description as string | undefined,
    textContent: item.textContent as string | undefined,
  }));

  // Query celebrity posts (latest N per celebrity)
  const celebrityPostResults = await Promise.all(
    [...celebrityDids].map((celDid) =>
      ddb.send(new QueryCommand({
        TableName: TABLE,
        KeyConditionExpression: 'PK = :pk',
        FilterExpression: 'visibility = :vis',
        ExpressionAttributeValues: { ':pk': `u#${celDid}#post`, ':vis': 'published' },
        ScanIndexForward: false,
        Limit: CELEBRITY_POST_LIMIT,
      })).then((r) => (r.Items ?? []).map((item) => ({
        authorDid: celDid,
        rkey: (item.SK as string).slice('p#'.length),
        title: item.title as string,
        publishedAt: item.publishedAt as string,
        publicationName: item.resolvedPubName as string | undefined,
        pubAtUri: typeof item.site === 'string' && item.site.startsWith('at://') ? item.site : undefined,
        resolvedUrl: item.resolvedUrl as string | undefined,
        path: item.path as string | undefined,
        resolvedHandle: item.resolvedHandle as string | undefined,
        coverImageCid: item.coverImageRef as string | undefined,
        description: item.description as string | undefined,
        textContent: item.textContent ? (item.textContent as string).slice(0, 300) : undefined,
      })))
    )
  );
  const celebrityItems = celebrityPostResults.flat();

  // Merge and sort by publishedAt descending
  const allItems = [...timelineItems, ...celebrityItems]
    .sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));

  const nextCursor = timelineResult.LastEvaluatedKey
    ? Buffer.from(JSON.stringify(timelineResult.LastEvaluatedKey)).toString('base64url')
    : null;

  return json(200, { items: allItems, cursor: nextCursor });
}

async function getPublicPostContent(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  const did = event.queryStringParameters?.did;
  const rkey = event.queryStringParameters?.rkey;
  if (!did || !rkey) return json(400, { error: 'did and rkey are required' });
  if (!did.startsWith('did:')) return json(400, { error: 'did must be a valid DID' });
  const result = await ddb.send(new GetCommand({
    TableName: TABLE,
    Key: { PK: `u#${did}#post`, SK: `p#${rkey}` },
  }));
  if (!result.Item || result.Item.visibility !== 'published') return json(404, { error: 'Post not found' });
  const item = result.Item;
  return json(200, {
    title: item.title as string,
    publishedAt: item.publishedAt as string,
    updatedAt: item.updatedAt as string | undefined,
    content: item.content as string | undefined,
    coverImageRef: item.coverImageRef as string | undefined,
    bskyPostRef: item.bskyPostRef as { uri: string; cid: string } | undefined,
    site: item.site as string | undefined,
    license: item.license as string | undefined,
  });
}

async function recordAcceptedTerms(did: string): Promise<APIGatewayProxyResultV2> {
  const key = { PK: `u#${did}`, SK: 'info' };
  const existing = await ddb.send(new GetCommand({ TableName: TABLE, Key: key }));
  if (existing.Item?.acceptedTermsAt) return json(200, {});

  const now = new Date().toISOString();
  await ddb.send(new PutCommand({
    TableName: TABLE,
    Item: { ...key, entityType: 'user-info', GSI1PK: 'user-info', GSI1SK: did, acceptedTermsAt: now, updatedAt: now },
  }));
  return json(200, {});
}

async function listSuggestions(did: string): Promise<APIGatewayProxyResultV2> {
  const items: Record<string, unknown>[] = []
  let lastKey: Record<string, unknown> | undefined
  do {
    const result = await ddb.send(new QueryCommand({
      TableName: TABLE,
      KeyConditionExpression: 'PK = :pk',
      FilterExpression: '#show = :show',
      ExpressionAttributeValues: { ':pk': `u#${did}#sugg`, ':show': true },
      ExpressionAttributeNames: { '#show': 'show' },
      ExclusiveStartKey: lastKey,
    }))
    items.push(...(result.Items ?? []))
    lastKey = result.LastEvaluatedKey as Record<string, unknown> | undefined
  } while (lastKey)

  return json(200, items.map((item) => ({
    rkey: (item.SK as string).slice('pub#'.length),
    pubAtUri: item.pubAtUri as string,
    authorDid: item.authorDid as string,
    pubName: item.pubName as string,
    pubUrl: item.pubUrl as string,
    pubDescription: item.pubDescription as string | undefined,
    authorHandle: item.authorHandle as string | undefined,
  })))
}

async function addSuggestionsBulk(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  let pubs: Array<{ pubAtUri?: string; rkey?: string; authorDid?: string; pubName?: string; pubUrl?: string; pubDescription?: string; authorHandle?: string }>
  try {
    const body = JSON.parse(event.body ?? '{}') as { pubs?: unknown }
    if (!Array.isArray(body.pubs)) return json(400, { error: 'pubs array is required' })
    pubs = body.pubs as typeof pubs
  } catch {
    return json(400, { error: 'Invalid JSON body' })
  }

  if (pubs.length === 0) return json(200, { added: 0 })
  if (pubs.length > 200) return json(400, { error: 'Too many pubs (max 200)' })

  let added = 0
  await Promise.all(pubs.map(async (pub) => {
    if (
      typeof pub.pubAtUri !== 'string' || !pub.pubAtUri.startsWith('at://') ||
      typeof pub.rkey !== 'string' || !pub.rkey.trim() ||
      typeof pub.authorDid !== 'string' || !pub.authorDid.startsWith('did:') ||
      typeof pub.pubName !== 'string' || !pub.pubName.trim() ||
      typeof pub.pubUrl !== 'string' || !pub.pubUrl.trim()
    ) return
    try {
      new URL(pub.pubUrl)
    } catch {
      return
    }
    const item: Record<string, unknown> = {
      PK: `u#${did}#sugg`,
      SK: `pub#${pub.rkey.trim()}`,
      entityType: 'suggestion',
      pubAtUri: pub.pubAtUri,
      authorDid: pub.authorDid,
      pubName: pub.pubName.trim(),
      pubUrl: pub.pubUrl.trim(),
      show: true,
    }
    if (pub.pubDescription?.trim()) item.pubDescription = pub.pubDescription.trim()
    if (pub.authorHandle?.trim()) item.authorHandle = pub.authorHandle.trim()
    try {
      await ddb.send(new PutCommand({
        TableName: TABLE,
        Item: item,
        ConditionExpression: 'attribute_not_exists(PK)',
      }))
      added++
    } catch (err: unknown) {
      if ((err as { name?: string })?.name !== 'ConditionalCheckFailedException') throw err
    }
  }))

  return json(200, { added })
}

async function dismissSuggestion(event: APIGatewayProxyEventV2, did: string): Promise<APIGatewayProxyResultV2> {
  const rkey = event.queryStringParameters?.rkey?.trim()
  if (!rkey) return json(400, { error: 'rkey is required' })

  await ddb.send(new UpdateCommand({
    TableName: TABLE,
    Key: { PK: `u#${did}#sugg`, SK: `pub#${rkey}` },
    UpdateExpression: 'SET #show = :show',
    ExpressionAttributeNames: { '#show': 'show' },
    ExpressionAttributeValues: { ':show': false },
  }))

  return json(200, {})
}

export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> {
  try {
    // Supports both V1 (httpMethod at top level) and V2 (requestContext.http.method).
    const ctx = event.requestContext as any;
    const method = (ctx?.http?.method ?? ctx?.httpMethod ?? (event as any).httpMethod ?? '').toUpperCase();
    // V1: event.path; V2: event.rawPath
    const path: string = event.rawPath ?? (event as any).path ?? '/';

    // Respond to CORS preflight before any auth checks.
    if (method === 'OPTIONS') return json(204, {});

    // Public endpoints — no auth required.
    if (method === 'GET' && path.endsWith('/.well-known/site.standard.publication')) return verifyPublication(path);
    if (method === 'GET' && path.endsWith('/feed.xml')) return getPublicationFeed(path);
    if (method === 'GET' && path === '/feed') return getFeed(event);
    if (method === 'GET' && path === '/iframely') return getIframelyEmbed(event);
    if (method === 'GET' && path === '/posts/content') return getPublicPostContent(event);
    if (method === 'GET' && path === '/publications' && event.queryStringParameters?.did) {
      const publicDid = event.queryStringParameters.did;
      if (!publicDid.startsWith('did:')) return json(400, { error: 'did must be a valid DID' });
      return listPublications(publicDid);
    }
    if (method === 'GET' && path === '/profile') {
      const did = event.queryStringParameters?.did;
      if (!did) return json(400, { error: 'did query parameter is required' });
      if (!did.startsWith('did:')) return json(400, { error: 'did must be a valid DID' });
      return getUserPublicPosts(did);
    }

    // All other endpoints require a valid ATProto service JWT.
    // Normalize to lowercase: V1 preserves original casing, V2 lowercases.
    const headers = Object.fromEntries(
      Object.entries(event.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
    );
    const authHeader = headers['authorization'] ?? '';
    if (!authHeader.toLowerCase().startsWith('bearer ')) {
      return json(401, { error: 'Missing or invalid Authorization header' });
    }
    const token = authHeader.slice(7).trim();

    let did: string;
    try {
      did = await verifyServiceToken(token);
    } catch (err) {
      console.warn('Token verification failed:', (err as Error).message);
      return json(401, { error: 'Token verification failed' });
    }

    if (method === 'POST' && path === '/users') return recordAcceptedTerms(did);
    if (method === 'GET' && path === '/publications') return listPublications(did);
    if (method === 'POST' && path === '/publications') return createPublication(event, did);
    if (method === 'PUT' && path === '/publications') return updatePublication(event, did);
    if (method === 'DELETE' && path === '/publications') return deletePublication(event, did);
    if (method === 'GET' && path === '/posts') return getUserBlogPosts(event, did);
    if (method === 'POST' && path === '/posts') return createPost(event, did);
    if (method === 'DELETE' && path === '/posts') return deletePost(event, did);
    if (method === 'GET' && path === '/subscriptions') return listSubscriptions(did);
    if (method === 'POST' && path === '/subscriptions') return backfillSubscription(event, did);
    if (method === 'DELETE' && path === '/subscriptions') return deleteSubscription(event, did);
    if (method === 'GET' && path === '/timeline') return getTimeline(did, event);
    if (method === 'GET' && path === '/suggestions') return listSuggestions(did);
    if (method === 'POST' && path === '/suggestions') return addSuggestionsBulk(event, did);
    if (method === 'PATCH' && path === '/suggestions') return dismissSuggestion(event, did);
    return json(405, { error: 'Method not allowed' });
  } catch (err) {
    console.error('Unhandled error:', err);
    return json(500, { error: 'Internal server error' });
  }
}
