/**
 * Notion REST API Client
 *
 * Pure fetch-based client for Notion page and block operations.
 * Supports Bearer token authentication, rate limit handling with Retry-After,
 * automatic retry on transient server errors (502/503/504) with exponential backoff,
 * cursor-based pagination, and typed error responses.
 *
 * No external dependencies — uses only the standard fetch API.
 *
 * @see https://developers.notion.com/reference
 */
// ============================================================================
// Error Types
// ============================================================================
/**
 * Typed error for Notion API failures.
 * Wraps fetch errors with status code, Notion error code, and message.
 */
export class NotionApiError extends Error {
    /** HTTP status code from Notion's response */
    status;
    /** Notion-specific error code (e.g., "validation_error", "object_not_found") */
    code;
    /** Parsed error body from Notion (if available) */
    responseBody;
    constructor(message, status, code = 'unknown', responseBody = null, cause) {
        super(message);
        this.name = 'NotionApiError';
        this.status = status;
        this.code = code;
        this.responseBody = responseBody;
        this.cause = cause;
        if (Error.captureStackTrace) {
            Error.captureStackTrace(this, NotionApiError);
        }
    }
    /**
     * Whether this error is due to rate limiting (429 Too Many Requests).
     */
    get isRateLimited() {
        return this.status === 429;
    }
    /**
     * Whether this error is transient and can be retried.
     * Includes rate limiting (429) and server errors (502, 503, 504).
     */
    get isRetryable() {
        return this.status === 429 || this.status === 502 || this.status === 503 || this.status === 504;
    }
    /**
     * Whether this error is due to authentication failure.
     */
    get isAuthError() {
        return this.status === 401;
    }
    /**
     * Whether this error is a not-found response.
     */
    get isNotFound() {
        return this.status === 404 || this.code === 'object_not_found';
    }
    /**
     * Whether this is a validation error.
     */
    get isValidationError() {
        return this.status === 400 || this.code === 'validation_error';
    }
    /**
     * Returns a JSON-serializable representation of the error.
     */
    toJSON() {
        return {
            name: this.name,
            message: this.message,
            status: this.status,
            code: this.code,
            responseBody: this.responseBody,
        };
    }
}
/**
 * Type guard for NotionApiError.
 */
export function isNotionApiError(error) {
    return error instanceof NotionApiError;
}
// ============================================================================
// Constants
// ============================================================================
/** Notion API base URL */
const NOTION_API_BASE = 'https://api.notion.com/v1';
/** Default Notion API version */
const DEFAULT_NOTION_VERSION = '2022-06-28';
/** Default maximum retry count for retryable responses (429, 502, 503, 504) */
const DEFAULT_MAX_RETRIES = 3;
/** Default Retry-After fallback if the header is missing (in seconds) */
const DEFAULT_RETRY_AFTER_SECONDS = 1;
/** Maximum number of blocks per Notion API request (POST /pages or PATCH /blocks/{id}/children) */
const NOTION_BLOCK_BATCH_SIZE = 100;
/** Number of concurrent block delete requests (kept under Notion's 3 req/s rate limit with headroom) */
const CONCURRENT_BLOCK_DELETES = 8;
// ============================================================================
// Client Implementation
// ============================================================================
/**
 * Fetch-based Notion REST API client for page and block operations.
 *
 * Features:
 * - Bearer token authentication (internal integrations or OAuth)
 * - Notion-Version header for API versioning
 * - Automatic retry on 429 (Too Many Requests) with Retry-After
 * - Automatic retry on 502/503/504 (server errors) with exponential backoff
 * - Cursor-based pagination for list endpoints
 * - Typed errors with Notion error codes
 *
 * @example
 * ```typescript
 * const client = new NotionApiClient({ token: 'ntn_...' });
 * const page = await client.getPage('page-uuid');
 * const blocks = await client.getBlocks('page-uuid');
 * ```
 */
export class NotionApiClient {
    token;
    notionVersion;
    maxRetries;
    warnOnRateLimit;
    /** Rate limit state tracked from 429 responses */
    rateLimitState = {
        wasRateLimited: false,
        lastRetryAfterSeconds: null,
        totalRateLimitHits: 0,
    };
    constructor(options) {
        if (!options.token) {
            throw new Error('Notion API token is required');
        }
        this.token = options.token;
        this.notionVersion = options.notionVersion ?? DEFAULT_NOTION_VERSION;
        this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
        this.warnOnRateLimit = options.warnOnRateLimit ?? true;
    }
    /**
     * Returns the current rate limit state.
     */
    getRateLimitState() {
        return { ...this.rateLimitState };
    }
    // --------------------------------------------------------------------------
    // Public API Methods
    // --------------------------------------------------------------------------
    /**
     * Retrieve a page by ID.
     *
     * GET /pages/{page_id}
     *
     * @see https://developers.notion.com/reference/retrieve-a-page
     */
    async getPage(pageId) {
        return this.request('GET', `/pages/${pageId}`);
    }
    /**
     * Retrieve a database by ID.
     *
     * GET /databases/{database_id}
     *
     * Returns the database schema including all property definitions.
     * Used for discovering the title property name and checking which
     * properties (Category, Tags) exist before creating pages.
     *
     * @see https://developers.notion.com/reference/retrieve-a-database
     */
    async getDatabase(databaseId) {
        return this.request('GET', `/databases/${databaseId}`);
    }
    /**
     * Update a database schema (add or modify properties).
     *
     * PATCH /databases/{database_id}
     *
     * Used to auto-create missing properties (e.g., Category as select,
     * Tags as multi_select) when they don't exist in the database schema.
     *
     * @see https://developers.notion.com/reference/update-a-database
     */
    async updateDatabase(databaseId, updates) {
        return this.request('PATCH', `/databases/${databaseId}`, updates);
    }
    /**
     * Retrieve all block children for a given block (or page).
     * Automatically paginates through all results.
     *
     * GET /blocks/{block_id}/children
     *
     * @see https://developers.notion.com/reference/get-block-children
     */
    async getBlocks(blockId) {
        const allBlocks = [];
        let cursor;
        // eslint-disable-next-line no-constant-condition
        while (true) {
            const params = new URLSearchParams({ page_size: '100' });
            if (cursor) {
                params.set('start_cursor', cursor);
            }
            const response = await this.request('GET', `/blocks/${blockId}/children?${params.toString()}`);
            allBlocks.push(...response.results);
            if (!response.has_more || !response.next_cursor) {
                break;
            }
            cursor = response.next_cursor;
        }
        return allBlocks;
    }
    /**
     * Create a new page in a database or as a child of another page.
     *
     * POST /pages
     *
     * @see https://developers.notion.com/reference/post-page
     */
    async createPage(databaseId, properties, children) {
        const body = {
            parent: { database_id: databaseId },
            properties,
            ...(children && children.length > 0 ? { children } : {}),
        };
        return this.request('POST', '/pages', body);
    }
    /**
     * Update page properties.
     *
     * PATCH /pages/{page_id}
     *
     * @see https://developers.notion.com/reference/patch-page
     */
    async updatePage(pageId, properties) {
        const body = { properties };
        return this.request('PATCH', `/pages/${pageId}`, body);
    }
    /**
     * Replace all content blocks of a page.
     *
     * This is a two-step operation:
     * 1. Delete all existing top-level block children
     * 2. Append new block children in batches of 100 (Notion's per-request limit)
     *
     * CAUTION: This is not atomic. If step 2 fails, the page may have partial content.
     *
     * @see https://developers.notion.com/reference/delete-a-block
     * @see https://developers.notion.com/reference/patch-block-children
     */
    async updatePageContent(pageId, blocks) {
        // Step 1: Get existing blocks and delete them concurrently in batches
        // Filter out archived blocks — Notion rejects DELETE on archived blocks with:
        // "Can't edit block that is archived. You must unarchive the block before editing."
        const existingBlocks = await this.getBlocks(pageId);
        const deletableBlocks = existingBlocks.filter(block => !block.archived);
        for (let i = 0; i < deletableBlocks.length; i += CONCURRENT_BLOCK_DELETES) {
            const batch = deletableBlocks.slice(i, i + CONCURRENT_BLOCK_DELETES);
            await Promise.all(batch.map(block => this.request('DELETE', `/blocks/${block.id}`)));
        }
        // Step 2: Append new blocks in batches of 100
        if (blocks.length === 0) {
            return [];
        }
        const allResults = [];
        for (let i = 0; i < blocks.length; i += NOTION_BLOCK_BATCH_SIZE) {
            const batch = blocks.slice(i, i + NOTION_BLOCK_BATCH_SIZE);
            const response = await this.request('PATCH', `/blocks/${pageId}/children`, { children: batch });
            allResults.push(...response.results);
        }
        return allResults;
    }
    /**
     * Append block children to a page or block.
     *
     * Respects Notion's 100-block-per-request limit by batching automatically.
     * Block order is preserved across batches.
     *
     * PATCH /blocks/{block_id}/children
     *
     * @param blockId - The page or block ID to append children to
     * @param blocks - The blocks to append
     * @returns All appended blocks
     *
     * @see https://developers.notion.com/reference/patch-block-children
     */
    async appendBlocks(blockId, blocks) {
        if (blocks.length === 0) {
            return [];
        }
        const allResults = [];
        for (let i = 0; i < blocks.length; i += NOTION_BLOCK_BATCH_SIZE) {
            const batch = blocks.slice(i, i + NOTION_BLOCK_BATCH_SIZE);
            const response = await this.request('PATCH', `/blocks/${blockId}/children`, { children: batch });
            allResults.push(...response.results);
        }
        return allResults;
    }
    /**
     * Query a database with optional filter and cursor-based pagination.
     *
     * POST /databases/{database_id}/query
     *
     * When no cursor is provided and the result has more pages, this method
     * returns only the first page. Callers can use `next_cursor` to fetch
     * additional pages, or use `queryDatabaseAll()` to auto-paginate.
     *
     * @see https://developers.notion.com/reference/post-database-query
     */
    async queryDatabase(databaseId, filter, cursor) {
        const body = {
            page_size: 100,
        };
        if (filter) {
            body.filter = filter;
        }
        if (cursor) {
            body.start_cursor = cursor;
        }
        return this.request('POST', `/databases/${databaseId}/query`, body);
    }
    /**
     * Query a database and automatically paginate through all results.
     *
     * This is a convenience wrapper around `queryDatabase()` that follows
     * `next_cursor` until all pages have been fetched.
     *
     * @param databaseId - The database to query.
     * @param filter - Optional Notion filter object.
     * @returns All pages matching the query.
     */
    async queryDatabaseAll(databaseId, filter) {
        const allPages = [];
        let cursor;
        // eslint-disable-next-line no-constant-condition
        while (true) {
            const response = await this.queryDatabase(databaseId, filter, cursor);
            allPages.push(...response.results);
            if (!response.has_more || !response.next_cursor) {
                break;
            }
            cursor = response.next_cursor;
        }
        return allPages;
    }
    // --------------------------------------------------------------------------
    // Internal: HTTP Request Handling
    // --------------------------------------------------------------------------
    /**
     * Performs an HTTP request to the Notion API with automatic retry on retryable errors.
     *
     * Retry strategy:
     * - 429 (rate limited): Uses Retry-After header value (existing behavior)
     * - 502/503/504 (server errors): Uses exponential backoff starting at 1s (1s, 2s, 4s, ...)
     */
    async request(method, path, body) {
        let lastError;
        for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
            try {
                return await this.executeRequest(method, path, body);
            }
            catch (err) {
                if (err instanceof NotionApiError && err.isRetryable && attempt < this.maxRetries) {
                    let retryDelay;
                    if (err.isRateLimited) {
                        // 429: Use Retry-After header (existing behavior)
                        retryDelay = (this.rateLimitState.lastRetryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS) * 1000;
                    }
                    else {
                        // 502/503/504: Use exponential backoff starting at 1s
                        retryDelay = DEFAULT_RETRY_AFTER_SECONDS * 1000 * Math.pow(2, attempt);
                    }
                    if (this.warnOnRateLimit) {
                        const reason = err.isRateLimited
                            ? 'Rate limited'
                            : `Server error (${err.status})`;
                        console.warn(`[NotionApiClient] ${reason} on ${method} ${path}. ` +
                            `Retrying in ${retryDelay / 1000}s (attempt ${attempt + 1}/${this.maxRetries}).`);
                    }
                    await sleep(retryDelay);
                    lastError = err;
                    continue;
                }
                throw err;
            }
        }
        // Should not reach here, but just in case
        throw lastError ?? new NotionApiError('Max retries exceeded', 429, 'rate_limited');
    }
    /**
     * Executes a single HTTP request to the Notion API (no retry logic).
     */
    async executeRequest(method, path, body) {
        const url = `${NOTION_API_BASE}${path}`;
        const headers = {
            Authorization: `Bearer ${this.token}`,
            'Notion-Version': this.notionVersion,
        };
        if (body !== undefined) {
            headers['Content-Type'] = 'application/json';
        }
        let response;
        try {
            response = await fetch(url, {
                method,
                headers,
                body: body !== undefined ? JSON.stringify(body) : undefined,
            });
        }
        catch (err) {
            throw new NotionApiError(`Network error requesting ${method} ${path}: ${err instanceof Error ? err.message : String(err)}`, 0, 'network_error', null, err instanceof Error ? err : undefined);
        }
        if (!response.ok) {
            await this.handleErrorResponse(response, method, path);
        }
        // DELETE responses may have no body (204 No Content)
        if (response.status === 204 || response.headers.get('content-length') === '0') {
            return undefined;
        }
        return (await response.json());
    }
    // --------------------------------------------------------------------------
    // Internal: Error Handling
    // --------------------------------------------------------------------------
    /**
     * Handles non-OK responses by throwing a NotionApiError.
     */
    async handleErrorResponse(response, method, path) {
        let responseBody = null;
        let errorMessage;
        let errorCode = 'unknown';
        try {
            responseBody = (await response.json());
            errorMessage = responseBody.message ?? `Notion API error: ${response.status}`;
            errorCode = responseBody.code ?? 'unknown';
        }
        catch {
            errorMessage = `Notion API error: ${response.status} ${response.statusText}`;
        }
        // Enrich rate limit error with Retry-After info
        if (response.status === 429) {
            const retryAfter = response.headers.get('Retry-After');
            if (retryAfter) {
                errorMessage = `Rate limited. Retry after ${retryAfter}s. ${errorMessage}`;
            }
            // Track the rate limit hit
            const retryAfterSeconds = retryAfter
                ? parseInt(retryAfter, 10)
                : DEFAULT_RETRY_AFTER_SECONDS;
            this.rateLimitState = {
                wasRateLimited: true,
                lastRetryAfterSeconds: isNaN(retryAfterSeconds)
                    ? DEFAULT_RETRY_AFTER_SECONDS
                    : retryAfterSeconds,
                totalRateLimitHits: this.rateLimitState.totalRateLimitHits + 1,
            };
        }
        throw new NotionApiError(`${method} ${path} failed: ${errorMessage}`, response.status, errorCode, responseBody);
    }
}
// ============================================================================
// Utility Functions
// ============================================================================
/**
 * Parses the Retry-After value from a NotionApiError's response body or falls back to default.
 */
function parseRetryAfterFromError(err) {
    // The Retry-After header is already parsed into the error message;
    // try to extract it. This is a best-effort parse.
    const match = err.message.match(/Retry after (\d+)s/);
    if (match) {
        const seconds = parseInt(match[1], 10);
        if (!isNaN(seconds) && seconds > 0) {
            return seconds;
        }
    }
    return err.responseBody?.status === 429
        ? DEFAULT_RETRY_AFTER_SECONDS
        : DEFAULT_RETRY_AFTER_SECONDS;
}
/**
 * Promise-based sleep utility.
 */
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
// Export utility functions and constants for testing
export { sleep, parseRetryAfterFromError, NOTION_BLOCK_BATCH_SIZE, CONCURRENT_BLOCK_DELETES };
//# sourceMappingURL=notion-api.js.map