import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import {
  MAX_IMPORT_CELL_CHARS,
  MAX_IMPORT_COLUMNS,
  MAX_IMPORT_ROWS,
} from './tabular-import';

export const NOTION_API_VERSION = '2025-09-03';
export const NOTION_OAUTH_TTL_MS = 10 * 60 * 1_000;
const NOTION_API_BASE = 'https://api.notion.com/v1';

export interface NotionOAuthAttempt {
  state: string;
  stateHash: string;
  verifier: string;
  verifierHash: string;
  expiresAt: Date;
}

export interface NotionConnectionMetadata {
  id: string;
  workspaceId: string;
  workspaceName: string | null;
  workspaceIcon: string | null;
  connectedAt: string;
}

export interface NotionCredentials {
  accessToken: string;
  refreshToken?: string;
  expiresAt?: string;
}

export interface NotionDataSource {
  id: string;
  databaseId: string | null;
  name: string;
  url: string | null;
  icon: string | null;
}

export interface UnsupportedNotionProperty {
  name: string;
  type: string;
  reason: string;
}

export interface NotionCellError {
  pageId: string;
  property: string;
  type: string;
  message: string;
}

export interface NotionMaterializedTable {
  fileName: string;
  sheetName: null;
  headers: string[];
  rows: string[][];
  warnings: string[];
  unsupportedProperties: UnsupportedNotionProperty[];
  cellErrors: NotionCellError[];
}

type JsonRecord = Record<string, unknown>;
type FetchLike = typeof fetch;

export class NotionApiError extends Error {
  constructor(readonly status: number, message = 'Notion rejected the request.') {
    super(message);
    this.name = 'NotionApiError';
  }
}

export function hashOAuthValue(value: string): string {
  return createHash('sha256').update(value).digest('hex');
}

export function createNotionOAuthAttempt(
  now = new Date(),
  random: (size: number) => Buffer = randomBytes,
): NotionOAuthAttempt {
  const state = random(32).toString('base64url');
  const verifier = random(32).toString('base64url');
  return {
    state,
    stateHash: hashOAuthValue(state),
    verifier,
    verifierHash: hashOAuthValue(verifier),
    expiresAt: new Date(now.getTime() + NOTION_OAUTH_TTL_MS),
  };
}

export function verifyNotionOAuthAttempt(
  verifier: string | undefined,
  expectedHash: string,
  expiresAt: Date,
  now = new Date(),
): boolean {
  if (!verifier || expiresAt.getTime() <= now.getTime()) return false;
  const actual = Buffer.from(hashOAuthValue(verifier), 'hex');
  const expected = Buffer.from(expectedHash, 'hex');
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}

export function notionAuthorizationUrl(input: {
  clientId: string;
  redirectUri: string;
  state: string;
}): string {
  const url = new URL('https://api.notion.com/v1/oauth/authorize');
  url.searchParams.set('client_id', input.clientId);
  url.searchParams.set('redirect_uri', input.redirectUri);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('owner', 'user');
  url.searchParams.set('state', input.state);
  // Notion does not document RFC 7636 parameters. The server-side verifier
  // provides browser binding without sending fields the provider may reject.
  return url.toString();
}

export function notionConnectionMetadata(connection: {
  id: string;
  workspaceId: string;
  workspaceName: string | null;
  workspaceIcon: string | null;
  createdAt: Date;
}): NotionConnectionMetadata {
  return {
    id: connection.id,
    workspaceId: connection.workspaceId,
    workspaceName: connection.workspaceName,
    workspaceIcon: connection.workspaceIcon,
    connectedAt: connection.createdAt.toISOString(),
  };
}

export class NotionClient {
  constructor(private readonly request: FetchLike = fetch) {}

  async exchangeAuthorizationCode(input: {
    clientId: string;
    clientSecret: string;
    code: string;
    redirectUri: string;
  }): Promise<JsonRecord> {
    return this.fetchJson('/oauth/token', undefined, {
      method: 'POST',
      headers: {
        Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString('base64')}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        grant_type: 'authorization_code',
        code: input.code,
        redirect_uri: input.redirectUri,
      }),
    });
  }

  async searchDataSources(accessToken: string): Promise<NotionDataSource[]> {
    const results = await collectNotionPages<JsonRecord>(async (cursor) => {
      const body: JsonRecord = {
        filter: { property: 'object', value: 'data_source' },
        sort: { direction: 'descending', timestamp: 'last_edited_time' },
        page_size: 100,
      };
      if (cursor) body.start_cursor = cursor;
      return this.fetchJson('/search', accessToken, {
        method: 'POST',
        body: JSON.stringify(body),
      });
    }, 2_000);
    return results.map(toDataSource).filter((value): value is NotionDataSource => value !== null);
  }

  async retrieveDataSource(accessToken: string, dataSourceId: string): Promise<JsonRecord> {
    return this.fetchJson(`/data_sources/${encodeURIComponent(dataSourceId)}`, accessToken);
  }

  async queryDataSource(accessToken: string, dataSourceId: string): Promise<JsonRecord[]> {
    return collectNotionPages<JsonRecord>(async (cursor) => {
      const body: JsonRecord = { page_size: 100 };
      if (cursor) body.start_cursor = cursor;
      return this.fetchJson(`/data_sources/${encodeURIComponent(dataSourceId)}/query`, accessToken, {
        method: 'POST',
        body: JSON.stringify(body),
      });
    }, MAX_IMPORT_ROWS + 1);
  }

  async retrievePropertyItems(
    accessToken: string,
    pageId: string,
    propertyId: string,
  ): Promise<JsonRecord[]> {
    return collectNotionPages<JsonRecord>(async (cursor) => {
      const query = new URLSearchParams({ page_size: '100' });
      if (cursor) query.set('start_cursor', cursor);
      return this.fetchJson(
        `/pages/${encodeURIComponent(pageId)}/properties/${encodeURIComponent(propertyId)}?${query}`,
        accessToken,
      );
    }, 10_000);
  }

  private async fetchJson(
    path: string,
    accessToken?: string,
    init: RequestInit = {},
  ): Promise<JsonRecord> {
    const headers = new Headers(init.headers);
    headers.set('Accept', 'application/json');
    headers.set('Notion-Version', NOTION_API_VERSION);
    if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`);
    if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
    const response = await this.request(`${NOTION_API_BASE}${path}`, { ...init, headers });
    if (!response.ok) {
      const message = response.status === 401
        ? 'The Notion connection is no longer authorized. Reconnect it and try again.'
        : `Notion request failed with status ${response.status}.`;
      throw new NotionApiError(response.status, message);
    }
    const value: unknown = await response.json();
    if (!isRecord(value)) throw new NotionApiError(502, 'Notion returned an invalid response.');
    return value;
  }
}

export async function collectNotionPages<T extends JsonRecord>(
  fetchPage: (cursor?: string) => Promise<JsonRecord>,
  maxItems: number,
): Promise<T[]> {
  const collected: T[] = [];
  let cursor: string | undefined;
  for (let page = 0; page < 100; page += 1) {
    const response = await fetchPage(cursor);
    const results = Array.isArray(response.results)
      ? response.results.filter(isRecord) as T[]
      : [];
    collected.push(...results);
    if (collected.length >= maxItems) return collected.slice(0, maxItems);
    if (response.has_more !== true || typeof response.next_cursor !== 'string') return collected;
    cursor = response.next_cursor;
  }
  throw new NotionApiError(422, 'Notion pagination exceeded the safety limit.');
}

export async function materializeNotionDataSource(
  client: NotionClient,
  accessToken: string,
  dataSourceId: string,
): Promise<NotionMaterializedTable> {
  const [source, pages] = await Promise.all([
    client.retrieveDataSource(accessToken, dataSourceId),
    client.queryDataSource(accessToken, dataSourceId),
  ]);
  if (pages.length > MAX_IMPORT_ROWS) {
    throw new NotionApiError(422, `Notion imports may not exceed ${MAX_IMPORT_ROWS} rows.`);
  }

  const schema = isRecord(source.properties) ? source.properties : {};
  const unsupportedProperties: UnsupportedNotionProperty[] = [];
  const supportedHeaders: string[] = [];
  for (const [name, definition] of Object.entries(schema)) {
    const type = isRecord(definition) && typeof definition.type === 'string'
      ? definition.type
      : 'unknown';
    const reason = unsupportedReason(type);
    if (reason) unsupportedProperties.push({ name, type, reason });
    else if (supportedHeaders.length < MAX_IMPORT_COLUMNS - 2) supportedHeaders.push(name);
    else unsupportedProperties.push({
      name,
      type,
      reason: `A15 is limited to ${MAX_IMPORT_COLUMNS} columns so A14 can validate the plan safely.`,
    });
  }

  const cellErrors: NotionCellError[] = [];
  const rows: string[][] = [];
  for (const page of pages) {
    const pageId = typeof page.id === 'string' ? page.id : '';
    const properties = isRecord(page.properties) ? page.properties : {};
    const row = [pageId, typeof page.url === 'string' ? page.url : ''];
    for (const header of supportedHeaders) {
      const property = properties[header];
      if (!isRecord(property)) {
        row.push('');
        cellErrors.push({ pageId, property: header, type: 'unknown', message: 'The page omitted this property.' });
        continue;
      }
      const completed = await completePaginatedProperty(client, accessToken, pageId, property);
      const flattened = flattenNotionProperty(completed);
      if (flattened.error) {
        cellErrors.push({
          pageId,
          property: header,
          type: typeof property.type === 'string' ? property.type : 'unknown',
          message: flattened.error,
        });
        row.push('');
      } else if ((flattened.value ?? '').length > MAX_IMPORT_CELL_CHARS) {
        cellErrors.push({
          pageId,
          property: header,
          type: typeof property.type === 'string' ? property.type : 'unknown',
          message: `The flattened value exceeds ${MAX_IMPORT_CELL_CHARS} characters.`,
        });
        row.push('');
      } else {
        row.push(flattened.value ?? '');
      }
    }
    rows.push(row);
  }

  const warnings = [
    ...unsupportedProperties.map(({ name, type }) => `${name} (${type}) was not imported.`),
    ...(cellErrors.length > 0 ? [`${cellErrors.length} Notion cell values could not be flattened and were left blank.`] : []),
  ];
  return {
    fileName: `Notion - ${notionTitle(source)}`,
    sheetName: null,
    headers: ['Notion page ID', 'Notion page URL', ...supportedHeaders],
    rows,
    warnings,
    unsupportedProperties,
    cellErrors,
  };
}

export function flattenNotionProperty(property: JsonRecord): { value?: string; error?: string } {
  const type = typeof property.type === 'string' ? property.type : 'unknown';
  const value = property[type];
  if (['title', 'rich_text'].includes(type)) return { value: plainText(value) };
  if (type === 'number') return { value: value == null ? '' : String(value) };
  if (type === 'checkbox') return typeof value === 'boolean'
    ? { value: value ? 'true' : 'false' }
    : { error: 'Notion returned a non-boolean checkbox.' };
  if (['url', 'email', 'phone_number', 'created_time', 'last_edited_time'].includes(type)) {
    return { value: value == null ? '' : String(value) };
  }
  if (['select', 'status'].includes(type)) {
    return { value: isRecord(value) && typeof value.name === 'string' ? value.name : '' };
  }
  if (type === 'multi_select') return { value: names(value).join(', ') };
  if (type === 'date') {
    if (!isRecord(value)) return { value: '' };
    const start = typeof value.start === 'string' ? value.start : '';
    const end = typeof value.end === 'string' ? value.end : '';
    return { value: end ? `${start}/${end}` : start };
  }
  if (type === 'people') return { value: people(value).join(', ') };
  if (type === 'files') return { value: fileUrls(value).join(', ') };
  if (type === 'relation') return { value: ids(value).join(', ') };
  if (['created_by', 'last_edited_by'].includes(type)) {
    if (!isRecord(value)) return { value: '' };
    return { value: typeof value.name === 'string' ? value.name : typeof value.id === 'string' ? value.id : '' };
  }
  if (type === 'unique_id') {
    if (!isRecord(value) || typeof value.number !== 'number') return { value: '' };
    return { value: `${typeof value.prefix === 'string' ? value.prefix : ''}${value.number}` };
  }
  if (type === 'formula' || type === 'rollup') {
    if (!isRecord(value) || typeof value.type !== 'string') return { error: `Notion returned an incomplete ${type}.` };
    if (value.type === 'array') {
      if (!Array.isArray(value.array)) return { error: `Notion returned an invalid ${type} array.` };
      const flattened = value.array.filter(isRecord).map(flattenNotionProperty);
      const error = flattened.find((item) => item.error)?.error;
      return error ? { error } : { value: flattened.map((item) => item.value ?? '').filter(Boolean).join(', ') };
    }
    return flattenNotionProperty({ type: value.type, [value.type]: value[value.type] });
  }
  return { error: unsupportedReason(type) ?? `Unsupported Notion property type: ${type}.` };
}

async function completePaginatedProperty(
  client: NotionClient,
  accessToken: string,
  pageId: string,
  property: JsonRecord,
): Promise<JsonRecord> {
  const type = typeof property.type === 'string' ? property.type : '';
  const value = property[type];
  const needsCompletion = type === 'relation'
    ? property.has_more === true
    : ['title', 'rich_text', 'people'].includes(type) && Array.isArray(value) && value.length >= 25;
  if (!needsCompletion || typeof property.id !== 'string' || !pageId) return property;
  const items = await client.retrievePropertyItems(accessToken, pageId, property.id);
  const completedValues = items.map((item) => item[type]).filter((item) => item != null);
  return { ...property, [type]: completedValues, has_more: false };
}

function unsupportedReason(type: string): string | null {
  if (['button', 'verification', 'place'].includes(type)) {
    return `Notion ${type} values do not have a stable tabular representation.`;
  }
  const supported = [
    'title', 'rich_text', 'number', 'checkbox', 'url', 'email', 'phone_number',
    'created_time', 'last_edited_time', 'select', 'status', 'multi_select', 'date',
    'people', 'files', 'relation', 'created_by', 'last_edited_by', 'unique_id',
    'formula', 'rollup',
  ];
  return supported.includes(type) ? null : `Notion property type ${type} is not supported.`;
}

function toDataSource(value: JsonRecord): NotionDataSource | null {
  if (typeof value.id !== 'string') return null;
  const parent = isRecord(value.parent) ? value.parent : {};
  return {
    id: value.id,
    databaseId: typeof parent.database_id === 'string' ? parent.database_id : null,
    name: notionTitle(value),
    url: typeof value.url === 'string' ? value.url : null,
    icon: isRecord(value.icon) && value.icon.type === 'emoji' && typeof value.icon.emoji === 'string'
      ? value.icon.emoji
      : null,
  };
}

function notionTitle(value: JsonRecord): string {
  const title = plainText(value.title);
  return title || (typeof value.name === 'string' ? value.name : 'Untitled data source');
}

function plainText(value: unknown): string {
  if (!Array.isArray(value)) return '';
  return value.filter(isRecord).map((item) => {
    if (typeof item.plain_text === 'string') return item.plain_text;
    const nested = isRecord(item.text) ? item.text : null;
    return nested && typeof nested.content === 'string' ? nested.content : '';
  }).join('');
}

function names(value: unknown): string[] {
  return Array.isArray(value)
    ? value.filter(isRecord).flatMap((item) => typeof item.name === 'string' ? [item.name] : [])
    : [];
}

function people(value: unknown): string[] {
  return Array.isArray(value) ? value.filter(isRecord).map((person) => {
    if (typeof person.name === 'string') return person.name;
    return typeof person.id === 'string' ? person.id : '';
  }).filter(Boolean) : [];
}

function fileUrls(value: unknown): string[] {
  return Array.isArray(value) ? value.filter(isRecord).flatMap((item) => {
    const file = isRecord(item.file) ? item.file : isRecord(item.external) ? item.external : null;
    return file && typeof file.url === 'string' ? [file.url] : [];
  }) : [];
}

function ids(value: unknown): string[] {
  return Array.isArray(value)
    ? value.filter(isRecord).flatMap((item) => typeof item.id === 'string' ? [item.id] : [])
    : [];
}

function isRecord(value: unknown): value is JsonRecord {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}
