import { lt, and, eq } from 'drizzle-orm';
import { notionConnections, notionOauthStates, type Database } from '@pig/db';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { Hono } from 'hono';
import { z } from 'zod';
import type { Config } from '../lib/config';
import { requireAnyTeamCapability } from '../lib/auth';
import type { ApiEnv } from '../lib/mutation';
import { decryptSecret, encryptSecret, encryptionReady } from '../lib/secrets';
import {
  createNotionOAuthAttempt,
  materializeNotionDataSource,
  NotionApiError,
  NotionClient,
  notionAuthorizationUrl,
  notionConnectionMetadata,
  type NotionCredentials,
  verifyNotionOAuthAttempt,
  hashOAuthValue,
} from '../services/notion';

export const NOTION_OAUTH_CALLBACK_PATH = '/api/imports/notion/oauth/callback';
const idSchema = z.string().uuid();
const materializeSchema = z.object({ dataSourceId: z.string().min(1).max(200) }).strict();

export function createNotionImportRoutes(
  config: Config,
  db: Database,
  client = new NotionClient(),
): Hono<ApiEnv> {
  const routes = new Hono<ApiEnv>();
  const oauthCookieName = config.isProduction ? '__Host-pig_notion_oauth' : 'pig_notion_oauth';
  const oauthCookiePath = config.isProduction ? '/' : NOTION_OAUTH_CALLBACK_PATH;

  /*
   * Connecting a Notion workspace is `integration:connect`; materialising a
   * data source into PIG rows is `data:import`. See the same split in
   * google-sheets.ts for why they are not the same authority.
   */
  routes.use('/api/imports/notion/*', async (context, next) => {
    const path = new URL(context.req.url).pathname;
    if (path === NOTION_OAUTH_CALLBACK_PATH) return next();
    const writesRows = path.endsWith('/materialize');
    requireAnyTeamCapability(
      context.get('principal'),
      writesRows ? 'data:import' : 'integration:connect',
    );
    await next();
  });

  routes.get('/api/imports/notion/status', async (context) => {
    const principal = context.get('principal');
    const connections = await db
      .select()
      .from(notionConnections)
      .where(eq(notionConnections.userId, principal.userId));
    return context.json({
      configured: notionConfigured(config),
      connected: connections.length > 0,
      connections: connections.map(notionConnectionMetadata),
    });
  });

  routes.post('/api/imports/notion/oauth/start', async (context) => {
    assertNotionConfigured(config);
    const principal = context.get('principal');
    const now = new Date();
    const attempt = createNotionOAuthAttempt(now);
    await db.transaction(async (tx) => {
      await tx.delete(notionOauthStates).where(lt(notionOauthStates.expiresAt, now));
      await tx.insert(notionOauthStates).values({
        stateHash: attempt.stateHash,
        userId: principal.userId,
        verifierHash: attempt.verifierHash,
        expiresAt: attempt.expiresAt,
        createdAt: now,
      });
    });
    setCookie(context, oauthCookieName, attempt.verifier, {
      httpOnly: true,
      secure: config.isProduction,
      sameSite: 'Lax',
      path: oauthCookiePath,
      maxAge: 600,
    });
    return context.json({
      authorizationUrl: notionAuthorizationUrl({
        clientId: config.NOTION_CLIENT_ID!,
        redirectUri: config.NOTION_REDIRECT_URI!,
        state: attempt.state,
      }),
      expiresAt: attempt.expiresAt.toISOString(),
    });
  });

  routes.get(NOTION_OAUTH_CALLBACK_PATH, async (context) => {
    if (!notionConfigured(config)) return context.json({ error: 'Notion import is not configured.' }, 503);
    const state = context.req.query('state');
    if (!state) return context.json({ error: 'Missing OAuth state.' }, 400);
    const [attempt] = await db
      .delete(notionOauthStates)
      .where(eq(notionOauthStates.stateHash, hashOAuthValue(state)))
      .returning();
    const verifier = getCookie(context, oauthCookieName);
    deleteCookie(context, oauthCookieName, {
      path: oauthCookiePath,
      secure: config.isProduction,
    });
    if (!attempt || !verifyNotionOAuthAttempt(verifier, attempt.verifierHash, attempt.expiresAt)) {
      return context.json({ error: 'The Notion authorization attempt is invalid or expired.' }, 400);
    }
    const providerError = context.req.query('error');
    if (providerError) return redirectToImports(context, config, 'denied');
    const code = context.req.query('code');
    if (!code) return context.json({ error: 'Missing Notion authorization code.' }, 400);

    const token = await client.exchangeAuthorizationCode({
      clientId: config.NOTION_CLIENT_ID!,
      clientSecret: config.NOTION_CLIENT_SECRET!,
      code,
      redirectUri: config.NOTION_REDIRECT_URI!,
    });
    const accessToken = typeof token.access_token === 'string' ? token.access_token : null;
    const workspaceId = typeof token.workspace_id === 'string' ? token.workspace_id : null;
    if (!accessToken || !workspaceId) throw new NotionApiError(502, 'Notion returned an incomplete OAuth grant.');
    const credentials: NotionCredentials = {
      accessToken,
      ...(typeof token.refresh_token === 'string' ? { refreshToken: token.refresh_token } : {}),
      ...(typeof token.expires_in === 'number'
        ? { expiresAt: new Date(Date.now() + token.expires_in * 1_000).toISOString() }
        : {}),
    };
    await db.insert(notionConnections).values({
      userId: attempt.userId,
      workspaceId,
      workspaceName: typeof token.workspace_name === 'string' ? token.workspace_name : null,
      workspaceIcon: typeof token.workspace_icon === 'string' ? token.workspace_icon : null,
      botId: typeof token.bot_id === 'string' ? token.bot_id : null,
      credentialsEncrypted: encryptSecret(
        JSON.stringify(credentials),
        config.PIG_SETTINGS_ENCRYPTION_KEY,
      ),
      updatedAt: new Date(),
    }).onConflictDoUpdate({
      target: [notionConnections.userId, notionConnections.workspaceId],
      set: {
        workspaceName: typeof token.workspace_name === 'string' ? token.workspace_name : null,
        workspaceIcon: typeof token.workspace_icon === 'string' ? token.workspace_icon : null,
        botId: typeof token.bot_id === 'string' ? token.bot_id : null,
        credentialsEncrypted: encryptSecret(
          JSON.stringify(credentials),
          config.PIG_SETTINGS_ENCRYPTION_KEY,
        ),
        updatedAt: new Date(),
      },
    });
    return redirectToImports(context, config, 'connected');
  });

  routes.get('/api/imports/notion/connections/:id/data-sources', async (context) => {
    const connection = await ownedConnection(db, context.get('principal').userId, context.req.param('id'));
    return context.json({ dataSources: await client.searchDataSources(credentials(config, connection).accessToken) });
  });

  routes.post('/api/imports/notion/connections/:id/materialize', async (context) => {
    const input = materializeSchema.safeParse(await context.req.json());
    if (!input.success) return context.json({ error: 'Invalid Notion data source.', issues: input.error.issues }, 400);
    const connection = await ownedConnection(db, context.get('principal').userId, context.req.param('id'));
    return context.json(await materializeNotionDataSource(
      client,
      credentials(config, connection).accessToken,
      input.data.dataSourceId,
    ));
  });

  routes.delete('/api/imports/notion/connections/:id', async (context) => {
    const id = idSchema.safeParse(context.req.param('id'));
    if (!id.success) return context.json({ error: 'Invalid Notion connection.' }, 400);
    const deleted = await db.delete(notionConnections).where(and(
      eq(notionConnections.id, id.data),
      eq(notionConnections.userId, context.get('principal').userId),
    )).returning({ id: notionConnections.id });
    if (!deleted[0]) return context.json({ error: 'Notion connection not found.' }, 404);
    return context.json({ id: deleted[0].id, disconnected: true });
  });

  routes.onError((error, context) => {
    if (error instanceof NotionApiError) {
      const status = error.status >= 400 && error.status < 600 ? error.status : 502;
      return context.json({ error: error.message }, status as 400);
    }
    throw error;
  });
  return routes;
}

export function notionConfigured(config: Config): boolean {
  return Boolean(
    config.NOTION_CLIENT_ID
    && config.NOTION_CLIENT_SECRET
    && config.NOTION_REDIRECT_URI
    && encryptionReady(config.PIG_SETTINGS_ENCRYPTION_KEY),
  );
}

function assertNotionConfigured(config: Config): void {
  if (!notionConfigured(config)) throw new NotionApiError(503, 'Notion import is not configured.');
}

async function ownedConnection(db: Database, userId: string, rawId: string) {
  const id = idSchema.safeParse(rawId);
  if (!id.success) throw new NotionApiError(400, 'Invalid Notion connection.');
  const [connection] = await db.select().from(notionConnections).where(and(
    eq(notionConnections.id, id.data),
    eq(notionConnections.userId, userId),
  )).limit(1);
  if (!connection) throw new NotionApiError(404, 'Notion connection not found.');
  return connection;
}

function credentials(config: Config, connection: { credentialsEncrypted: string }): NotionCredentials {
  try {
    const parsed: unknown = JSON.parse(decryptSecret(
      connection.credentialsEncrypted,
      config.PIG_SETTINGS_ENCRYPTION_KEY,
    ));
    if (typeof parsed !== 'object' || parsed === null || !('accessToken' in parsed)
      || typeof parsed.accessToken !== 'string') throw new Error('invalid');
    return parsed as NotionCredentials;
  } catch {
    throw new NotionApiError(503, 'The Notion connection cannot be decrypted. Reconnect it.');
  }
}

function redirectToImports(context: Parameters<typeof deleteCookie>[0], config: Config, result: string) {
  const url = new URL('/imports', config.PIG_PUBLIC_URL);
  url.searchParams.set('notion', result);
  return context.redirect(url.toString(), 302);
}
