import * as React from 'react';
import type { Location } from 'history';
import type { OnAllReadyParams, OnShellReadyParams } from './server/streamRender.js';
import type {
  AppContext,
  ServerContext,
  RouteMatch,
  AppData,
  ServerAppRouterProps,
  RenderOptions,
  Response,
} from './types.js';
import Runtime from './runtime.js';
import { AppContextProvider } from './AppContext.js';
import { getAppData } from './appData.js';
import getAppConfig from './appConfig.js';
import { DocumentContextProvider } from './Document.js';
import { loadRouteModules } from './routes.js';
import { pipeToString, renderToNodeStream } from './server/streamRender.js';
import getRequestContext from './requestContext.js';
import matchRoutes from './matchRoutes.js';
import getCurrentRoutePath from './utils/getCurrentRoutePath.js';
import ServerRouter from './ServerRouter.js';
import addLeadingSlash from './utils/addLeadingSlash.js';
import { renderDocument } from './renderDocument.js';
import { sendResponse, getLocation } from './server/response.js';
import getDocumentData from './server/getDocumentData.js';

/**
 * Render and return the result as html string.
 */
export async function renderToHTML(
  requestContext: ServerContext,
  renderOptions: RenderOptions,
): Promise<Response> {
  const result = await doRender(requestContext, renderOptions);
  const { value } = result;

  if (typeof value === 'string' || typeof value === 'undefined') {
    return result;
  }

  const { pipe, fallback } = value;

  try {
    const entryStr = await pipeToString(pipe);

    return {
      value: entryStr,
      headers: {
        'Content-Type': 'text/html; charset=utf-8',
      },
      statusCode: 200,
    };
  } catch (error) {
    if (renderOptions.disableFallback) {
      throw error;
    }
    console.error('PipeToString error, downgrade to CSR.', error);
    // downgrade to CSR.
    const result = fallback();
    return result;
  }
}

/**
 * Render and send the result to ServerResponse.
 */
export async function renderToResponse(requestContext: ServerContext, renderOptions: RenderOptions) {
  const { req, res } = requestContext;
  const result = await doRender(requestContext, renderOptions);

  const { value } = result;

  if (typeof value === 'string' || typeof value === 'undefined') {
    sendResponse(req, res, result);
  } else {
    const { pipe, fallback } = value;

    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html; charset=utf-8');

    const { streamOptions = {} } = renderOptions;
    const { onShellReady, onShellError, onError, onAllReady } = streamOptions;

    return new Promise<void>((resolve, reject) => {
      // Send stream result to ServerResponse.
      pipe(res, {
        onShellReady: (params: OnShellReadyParams) => {
          onShellReady && onShellReady(params);
        },
        onShellError: async (err) => {
          onShellError && onShellError(err);

          if (renderOptions.disableFallback) {
            reject(err);
            return;
          }

          // downgrade to CSR.
          console.error('PipeToResponse onShellError, downgrade to CSR.');
          console.error(err);
          const result = await fallback();
          sendResponse(req, res, result);
          resolve();
        },
        onError: (err) => {
          onError && onError(err);
          // onError triggered after shell ready, should not downgrade to csr
          // and should not be throw to break the render process
          console.error('PipeToResponse error.');
          console.error(err);
        },
        onAllReady: (params: OnAllReadyParams) => {
          onAllReady && onAllReady(params);
          resolve();
        },
      });
    });
  }
}

function needRevalidate(matchedRoutes: RouteMatch[]) {
  return matchedRoutes.some(({ route }) => route.exports.includes('dataLoader') && route.exports.includes('staticDataLoader'));
}

async function doRender(serverContext: ServerContext, renderOptions: RenderOptions): Promise<Response> {
  const { req, res } = serverContext;
  const {
    app,
    basename,
    serverOnlyBasename,
    createRoutes,
    documentOnly,
    disableFallback,
    assetsManifest,
    runtimeModules,
    renderMode,
    runtimeOptions,
    serverData,
  } = renderOptions;
  const finalBasename = addLeadingSlash(serverOnlyBasename || basename);
  const location = getLocation(req.url);

  const requestContext = getRequestContext(location, serverContext);
  const appConfig = getAppConfig(app);
  const routes = createRoutes({
    requestContext,
    renderMode,
  });
  let appData: AppData;
  const appContext: AppContext = {
    appExport: app,
    routes,
    appConfig,
    appData,
    loaderData: {},
    renderMode,
    assetsManifest,
    basename: finalBasename,
    matches: [],
    requestContext,
    serverData,
  };
  const runtime = new Runtime(appContext, runtimeOptions);
  runtime.setAppRouter<ServerAppRouterProps>(ServerRouter);
  // Load static module before getAppData.
  if (runtimeModules.statics) {
    await Promise.all(runtimeModules.statics.map(m => runtime.loadModule(m)).filter(Boolean));
  }

  const documentData = await getDocumentData({
    loaderConfig: renderOptions.documentDataLoader,
    requestContext,
    documentOnly,
  });
  // @TODO: document should have it's own context, not shared with app.
  appContext.documentData = documentData;

  // Not to execute [getAppData] when CSR.
  if (!documentOnly) {
    try {
      appData = await getAppData(app, requestContext);
    } catch (err) {
      console.error('Error: get app data error when SSR.', err);
    }
  }

  // HashRouter loads route modules by the CSR.
  if (appConfig?.router?.type === 'hash') {
    return renderDocument({ matches: [], routes, renderOptions, documentData });
  }

  const matches = matchRoutes(routes, location, finalBasename);
  const routePath = getCurrentRoutePath(matches);
  if (documentOnly) {
    return renderDocument({ matches, routePath, routes, renderOptions, documentData });
  } else if (!matches.length) {
    return handleNotFoundResponse();
  }

  try {
    const routeModules = await loadRouteModules(matches.map(({ route: { id, lazy } }) => ({ id, lazy })));
    const loaderData = {};
    for (const routeId in routeModules) {
      const { loader } = routeModules[routeId];
      if (loader) {
        const { data, pageConfig } = await loader();
        loaderData[routeId] = {
          data,
          pageConfig,
        };
      }
    }
    const revalidate = renderMode === 'SSG' && needRevalidate(matches);
    runtime.setAppContext({ ...appContext, revalidate, routeModules, loaderData, routePath, matches, appData });
    if (runtimeModules.commons) {
      await Promise.all(runtimeModules.commons.map(m => runtime.loadModule(m)).filter(Boolean));
    }
    /**
       Plugin may register response handlers, for example:
       ```
       addResponseHandler((req) => {
         if (redirect) {
           return {
             statusCode: 302,
             statusText: 'Found',
             headers: {
               location: '/redirect',
             },
           };
         }
       });
       ```
     */
    const responseHandlers = runtime.getResponseHandlers();
    for (const responseHandler of responseHandlers) {
      if (typeof responseHandler === 'function') {
        const response = await responseHandler(req, res);
        if (response) {
          return response as Response;
        }
      }
    }

    return await renderServerEntry({
      runtime,
      matches,
      location,
      renderOptions,
    });
  } catch (err) {
    if (disableFallback) {
      throw err;
    }
    console.error('Warning: render server entry error, downgrade to csr.', err);
    return renderDocument({ matches, routePath, renderOptions, routes, downgrade: true, documentData });
  }
}

// https://github.com/ice-lab/ice-next/issues/133
function handleNotFoundResponse(): Response {
  return {
    statusText: 'Not Found',
    statusCode: 404,
  };
}

interface RenderServerEntry {
  runtime: Runtime;
  matches: RouteMatch[];
  location: Location;
  renderOptions: RenderOptions;
}
/**
 * Render App by SSR.
 */
async function renderServerEntry(
  {
    runtime,
    matches,
    location,
    renderOptions,
  }: RenderServerEntry,
): Promise<Response> {
  const { Document } = renderOptions;
  const appContext = runtime.getAppContext();
  const { routes, routePath, loaderData, basename } = appContext;
  const AppRuntimeProvider = runtime.composeAppProvider() || React.Fragment;
  const AppRouter = runtime.getAppRouter<ServerAppRouterProps>();
  const routerContext: ServerAppRouterProps['routerContext'] = {
    // @ts-expect-error matches type should be use `AgnosticDataRouteMatch[]`
    matches,
    basename,
    loaderData,
    location,
  };

  const documentContext = {
    main: (
      <AppRouter routes={routes} routerContext={routerContext} />
    ),
  };

  const element = (
    <AppContextProvider value={appContext}>
      <AppRuntimeProvider>
        <DocumentContextProvider value={documentContext}>
          {
            Document && <Document pagePath={routePath} />
          }
        </DocumentContextProvider>
      </AppRuntimeProvider>
    </AppContextProvider>
  );

  const pipe = renderToNodeStream(element, {
    renderOptions,
    routerContext,
  });

  const fallback = () => {
    return renderDocument({
      matches,
      routePath,
      renderOptions,
      routes,
      downgrade: true,
      documentData: appContext.documentData,
    });
  };

  return {
    value: {
      pipe,
      fallback,
    },
  };
}
