import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Plugin } from 'vite';

import { Buffer } from 'node:buffer';
import { readFileSync } from 'node:fs';
import { isIP } from 'node:net';
import { relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const PRODUCTION_VERSION = '0.1.0' as const;
const DEVELOPMENT_RELEASE = `${PRODUCTION_VERSION}-dev` as const;
const DEVELOPMENT_BASE_PATH = `/onlyoffice-plugins/${DEVELOPMENT_RELEASE}/eln-template-filler`;
const PLUGIN_GUID = 'asc.{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565}';
const PRODUCTION_IFRAME_URL = `index.html?v=${PRODUCTION_VERSION}`;
const RELEASE_PATH_PREFIX = '/onlyoffice-plugins/';
const SDK_PATH_PREFIX = '/onlyoffice-sdk/';
const SDK_PROXY_ERROR = 'ONLYOFFICE SDK 代理失败\n';
const SDK_PROXY_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const SDK_PROXY_TIMEOUT_MS = 5_000;
const EMPTY_TRANSLATIONS_MANIFEST = '[]\n';
const SDK_RESOURCES = new Map([
  ['/onlyoffice-sdk/v1/plugins.css', '/sdkjs-plugins/v1/plugins.css'],
  ['/onlyoffice-sdk/v1/plugins.js', '/sdkjs-plugins/v1/plugins.js'],
]);
const SDK_REQUEST_HEADERS = ['accept', 'if-none-match', 'if-modified-since'] as const;
const SDK_RESPONSE_HEADERS = ['content-type', 'etag', 'last-modified'] as const;
const NO_STORE_HEADERS = {
  'Cache-Control': 'no-store, no-cache, must-revalidate',
  Expires: '0',
  Pragma: 'no-cache',
} as const;
const pluginRoot = fileURLToPath(new URL('..', import.meta.url));

const environmentNames = {
  devHost: 'ONLYOFFICE_PLUGIN_DEV_HOST',
  devPort: 'ONLYOFFICE_PLUGIN_DEV_PORT',
  docsUrl: 'ONLYOFFICE_DOCS_URL',
  hmrClientPort: 'ONLYOFFICE_PLUGIN_HMR_CLIENT_PORT',
  hmrHost: 'ONLYOFFICE_PLUGIN_HMR_HOST',
  hmrPath: 'ONLYOFFICE_PLUGIN_HMR_PATH',
  hmrProtocol: 'ONLYOFFICE_PLUGIN_HMR_PROTOCOL',
  publicHost: 'ONLYOFFICE_PLUGIN_PUBLIC_HOST',
} as const;

export interface OnlyOfficeDevOptions {
  basePath: string;
  docsUrl: string;
  hmrClientPort: number;
  hmrHost: string;
  hmrPath?: string;
  hmrProtocol: 'ws' | 'wss';
  host: string;
  manifestUrl: string;
  port: number;
  publicHost: string;
  release: '0.1.0-dev';
  startupId: string;
}

interface OnlyOfficeSdkProxyOptions {
  docsUrl: string;
  onError?: (category: 'network' | 'status' | 'timeout') => void;
  timeoutMs?: number;
}

type OnlyOfficeSdkProxy = (request: IncomingMessage, response: ServerResponse, next: () => void) => Promise<void>;

function setNoStoreHeaders(response: ServerResponse): void {
  for (const [name, value] of Object.entries(NO_STORE_HEADERS)) response.setHeader(name, value);
}

function endSdkProxyError(response: ServerResponse, method: string | undefined): void {
  if (response.destroyed || response.writableEnded) return;
  response.statusCode = 502;
  response.setHeader('Content-Type', 'text/plain; charset=utf-8');
  response.end(method === 'HEAD' ? undefined : SDK_PROXY_ERROR);
}

async function cancelResponseBody(response: Response): Promise<void> {
  try {
    await response.body?.cancel();
  } catch {
    // The upstream connection may already be closed or aborted.
  }
}

async function readBoundedResponseBody(response: Response): Promise<Buffer> {
  const contentLengthHeader = response.headers.get('content-length');
  const contentLength =
    response.headers.has('content-encoding') || contentLengthHeader === null || !/^\d+$/u.test(contentLengthHeader) ? undefined : Number(contentLengthHeader);
  if (contentLength !== undefined && (!Number.isSafeInteger(contentLength) || contentLength > SDK_PROXY_MAX_RESPONSE_BYTES)) {
    await cancelResponseBody(response);
    throw new Error('response-too-large');
  }

  if (!response.body) return Buffer.alloc(0);

  const buffer = Buffer.allocUnsafe(contentLength ?? SDK_PROXY_MAX_RESPONSE_BYTES);
  const reader = response.body.getReader();
  let bytesRead = 0;
  try {
    while (true) {
      const result = await reader.read();
      if (result.done) break;
      if (bytesRead + result.value.byteLength > SDK_PROXY_MAX_RESPONSE_BYTES) {
        await reader.cancel();
        throw new Error('response-too-large');
      }
      buffer.set(result.value, bytesRead);
      bytesRead += result.value.byteLength;
    }
  } finally {
    reader.releaseLock();
  }

  if (contentLength !== undefined && bytesRead !== contentLength) {
    throw new Error('response-truncated');
  }
  return buffer.subarray(0, bytesRead);
}

export function createOnlyOfficeSdkProxy({
  docsUrl,
  onError = () => undefined,
  timeoutMs = SDK_PROXY_TIMEOUT_MS,
}: OnlyOfficeSdkProxyOptions): OnlyOfficeSdkProxy {
  return async (request, response, next) => {
    const requestUrl = request.url ?? '/';
    const queryIndex = requestUrl.indexOf('?');
    const pathname = queryIndex === -1 ? requestUrl : requestUrl.slice(0, queryIndex);
    const upstreamPath = SDK_RESOURCES.get(pathname);
    if (!upstreamPath) {
      next();
      return;
    }

    setNoStoreHeaders(response);
    if (request.method !== 'GET' && request.method !== 'HEAD') {
      response.statusCode = 405;
      response.setHeader('Allow', 'GET, HEAD');
      response.end('Method Not Allowed');
      return;
    }

    const headers = new Headers();
    for (const name of SDK_REQUEST_HEADERS) {
      const value = request.headers[name];
      if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(', ') : value);
    }
    headers.set('accept-encoding', 'identity');

    // Closing each upstream response lets fetch detect a declared-length truncation immediately.
    headers.set('connection', 'close');
    const abortController = new AbortController();
    let clientDisconnected = false;
    let timedOut = false;
    const abortForClientDisconnect = (): void => {
      clientDisconnected = true;
      abortController.abort();
    };
    const abortForPrematureClose = (): void => {
      if (!response.writableFinished) abortForClientDisconnect();
    };
    request.once('aborted', abortForClientDisconnect);
    response.once('close', abortForPrematureClose);
    const timeout = setTimeout(() => {
      timedOut = true;
      abortController.abort();
    }, timeoutMs);
    timeout.unref();

    try {
      const upstreamResponse = await fetch(`${docsUrl}${upstreamPath}`, {
        headers,
        method: request.method,
        redirect: 'error',
        signal: abortController.signal,
      });
      if (upstreamResponse.status === 304) {
        response.statusCode = 304;
        for (const name of SDK_RESPONSE_HEADERS) {
          const value = upstreamResponse.headers.get(name);
          if (value !== null) response.setHeader(name, value);
        }
        response.end();
        return;
      }
      if (!upstreamResponse.ok) {
        await cancelResponseBody(upstreamResponse);
        onError('status');
        endSdkProxyError(response, request.method);
        return;
      }

      const body = request.method === 'HEAD' ? undefined : await readBoundedResponseBody(upstreamResponse);
      if (clientDisconnected) return;

      response.statusCode = upstreamResponse.status;
      for (const name of SDK_RESPONSE_HEADERS) {
        const value = upstreamResponse.headers.get(name);
        if (value !== null) response.setHeader(name, value);
      }
      if (request.method === 'HEAD') response.end();
      else response.end(body);
    } catch {
      if (clientDisconnected) return;
      onError(timedOut ? 'timeout' : 'network');
      endSdkProxyError(response, request.method);
    } finally {
      clearTimeout(timeout);
      request.off('aborted', abortForClientDisconnect);
      response.off('close', abortForPrematureClose);
    }
  };
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function containsWhitespaceOrControl(value: string): boolean {
  for (const character of value) {
    const codePoint = character.codePointAt(0);
    if (character.trim() === '' || (codePoint !== undefined && (codePoint < 32 || codePoint === 127))) {
      return true;
    }
  }
  return false;
}

function parseUrlHost(value: string, environmentName: string): string {
  if (!value || containsWhitespaceOrControl(value) || /[\\/?#@]/u.test(value)) {
    throw new Error(`${environmentName} 必须是不含协议、路径、空白或控制字符的 host`);
  }

  const isBracketed = value.startsWith('[') && value.endsWith(']');
  if ((!isBracketed && value.includes(':')) || value.startsWith('[') !== value.endsWith(']')) {
    throw new Error(`${environmentName} 的 IPv6 host 必须使用方括号且不能包含端口`);
  }

  try {
    const parsed = new URL(`http://${value}`);
    if (!parsed.hostname || parsed.port || parsed.pathname !== '/') {
      throw new Error('invalid host');
    }
  } catch {
    throw new Error(`${environmentName} 不是有效的 host`);
  }

  return value;
}

function parseListenHost(value: string, environmentName: string): string {
  if (value.startsWith('[') || value.endsWith(']')) {
    throw new Error(`${environmentName} 的监听 IPv6 host 必须使用裸地址`);
  }
  if (value.includes(':')) {
    if (isIP(value) !== 6) {
      throw new Error(`${environmentName} 不是有效的监听 host`);
    }
    return value;
  }

  return parseUrlHost(value, environmentName);
}

function parsePort(value: string | undefined, fallback: number, environmentName: string): number {
  if (value === undefined) {
    return fallback;
  }
  if (!/^[1-9]\d*$/u.test(value)) {
    throw new Error(`${environmentName} 必须是 1..65535 的十进制整数`);
  }

  const port = Number(value);
  if (port > 65_535) {
    throw new Error(`${environmentName} 必须是 1..65535 的十进制整数`);
  }
  return port;
}

function parseHmrPath(value: string | undefined): string | undefined {
  if (value === undefined) return undefined;
  if (!/^\/[A-Za-z0-9/_-]+$/u.test(value) || value === '/') {
    throw new Error(`${environmentNames.hmrPath} 必须是以 / 开头且不含空白、query 或 fragment 的路径`);
  }
  return value;
}

function parseDocsUrl(value: string): string {
  let parsed: URL;
  try {
    if (containsWhitespaceOrControl(value)) {
      throw new Error('invalid URL');
    }
    parsed = new URL(value);
  } catch {
    throw new Error(`${environmentNames.docsUrl} 必须是有效的 http(s) URL`);
  }

  if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
    throw new Error(`${environmentNames.docsUrl} 必须是无 username/password、query 或 hash 的 http(s) URL`);
  }

  const pathname = parsed.pathname.replace(/\/+$/u, '');
  return `${parsed.origin}${pathname}`;
}

function createStartupId(now: Date): string {
  if (!Number.isFinite(now.getTime()) || now.getUTCFullYear() < 0 || now.getUTCFullYear() > 9999) {
    throw new Error('now 必须是可表示为四位 UTC 年份的有效日期');
  }

  const pad = (value: number): string => String(value).padStart(2, '0');
  return `${String(now.getUTCFullYear()).padStart(4, '0')}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`;
}

export function resolveOnlyOfficeDevOptions(environment: NodeJS.ProcessEnv, now: Date = new Date()): OnlyOfficeDevOptions {
  const host = parseListenHost(environment[environmentNames.devHost] ?? '0.0.0.0', environmentNames.devHost);
  const port = parsePort(environment[environmentNames.devPort], 4173, environmentNames.devPort);
  const publicHost = parseUrlHost(environment[environmentNames.publicHost] ?? 'localhost', environmentNames.publicHost);
  const hmrHost = parseUrlHost(environment[environmentNames.hmrHost] ?? publicHost, environmentNames.hmrHost);
  const hmrPath = parseHmrPath(environment[environmentNames.hmrPath]);
  const hmrProtocol = environment[environmentNames.hmrProtocol] ?? 'ws';
  if (hmrProtocol !== 'ws' && hmrProtocol !== 'wss') {
    throw new Error(`${environmentNames.hmrProtocol} 只接受 ws 或 wss`);
  }
  const hmrClientPort = parsePort(environment[environmentNames.hmrClientPort], port, environmentNames.hmrClientPort);
  const docsUrl = parseDocsUrl(environment[environmentNames.docsUrl] ?? 'http://localhost:20000');
  const startupId = createStartupId(now);
  const publicProtocol = hmrProtocol === 'wss' ? 'https' : 'http';

  return {
    basePath: DEVELOPMENT_BASE_PATH,
    docsUrl,
    hmrClientPort,
    hmrHost,
    ...(hmrPath ? { hmrPath } : {}),
    hmrProtocol,
    host,
    manifestUrl: `${publicProtocol}://${publicHost}:${port}${DEVELOPMENT_BASE_PATH}/config.json?v=${startupId}`,
    port,
    publicHost,
    release: DEVELOPMENT_RELEASE,
    startupId,
  };
}

export function createDevelopmentManifest(source: unknown, options: OnlyOfficeDevOptions): Record<string, unknown> {
  if (!isRecord(source) || source.guid !== PLUGIN_GUID) {
    throw new Error('ONLYOFFICE plugin manifest GUID 必须保持正式身份');
  }
  if (source.version !== PRODUCTION_VERSION) {
    throw new Error(`ONLYOFFICE plugin manifest version 必须为 ${PRODUCTION_VERSION}`);
  }

  const variations = source.variations;
  const firstVariation = Array.isArray(variations) ? variations[0] : undefined;
  if (!isRecord(firstVariation) || firstVariation.url !== PRODUCTION_IFRAME_URL) {
    throw new Error(`ONLYOFFICE plugin manifest 的首个 variation URL 必须为 ${PRODUCTION_IFRAME_URL}`);
  }

  const manifest = structuredClone(source);
  const manifestVariations = manifest.variations as Record<string, unknown>[];
  manifestVariations[0].url = `index.html?v=${options.startupId}`;
  return manifest;
}

export function onlyOfficeDevelopmentPlugin(options: OnlyOfficeDevOptions): Plugin {
  const source = JSON.parse(readFileSync(resolve(pluginRoot, 'config.json'), 'utf8')) as unknown;
  const manifest = `${JSON.stringify(createDevelopmentManifest(source, options), null, 2)}\n`;
  const manifestPath = `${options.basePath}/config.json`;
  const indexPath = `${options.basePath}/index.html`;
  const translationsPath = `${options.basePath}/translations/langs.json`;
  const developmentResourcePath = `${options.basePath}/`;
  const developmentResourcePrefixes = ['@fs/', '@id/', '@vite/', 'node_modules/', 'src/'];
  const fullReloadFiles = new Set(['src/composables/useTemplateAnnotator.ts', 'src/main.ts']);
  const iconPaths = new Map([
    [`${options.basePath}/resources/icon.png`, 'resources/icon.png'],
    [`${options.basePath}/resources/icon@2x.png`, 'resources/icon@2x.png'],
  ]);
  const sdkProxy = createOnlyOfficeSdkProxy({
    docsUrl: options.docsUrl,
    onError: (category) => console.error(`[onlyoffice-sdk-proxy] ${category}`),
  });
  let viteBase = '/';

  return {
    apply: 'serve',
    name: 'onlyoffice-development',
    config() {
      return {
        server: {
          headers: NO_STORE_HEADERS,
        },
      };
    },
    configResolved(config) {
      viteBase = config.base;
    },
    configureServer(server) {
      let manifestUrlPrinted = false;
      const printManifestUrl = (): void => {
        if (manifestUrlPrinted) return;
        manifestUrlPrinted = true;
        server.config.logger.info(options.manifestUrl);
      };
      if (server.httpServer?.listening) printManifestUrl();
      else server.httpServer?.once('listening', printManifestUrl);

      server.middlewares.use((request, response, next) => {
        setNoStoreHeaders(response);

        const requestUrl = request.url ?? '/';
        const queryIndex = requestUrl.indexOf('?');
        const pathname = queryIndex === -1 ? requestUrl : requestUrl.slice(0, queryIndex);
        const query = queryIndex === -1 ? '' : requestUrl.slice(queryIndex);

        if (pathname.startsWith(SDK_PATH_PREFIX)) {
          void sdkProxy(request, response, () => {
            response.statusCode = 404;
            response.end('Not Found');
          });
          return;
        }

        if (pathname === manifestPath) {
          if (request.method !== 'GET' && request.method !== 'HEAD') {
            response.statusCode = 405;
            response.setHeader('Allow', 'GET, HEAD');
            response.end('Method Not Allowed');
            return;
          }

          response.statusCode = 200;
          response.setHeader('Content-Type', 'application/json; charset=utf-8');
          if (request.method === 'HEAD') response.end();
          else response.end(manifest);
          return;
        }

        if (pathname === translationsPath) {
          if (request.method !== 'GET' && request.method !== 'HEAD') {
            response.statusCode = 405;
            response.setHeader('Allow', 'GET, HEAD');
            response.end('Method Not Allowed');
            return;
          }
          response.statusCode = 200;
          response.setHeader('Content-Type', 'application/json; charset=utf-8');
          if (request.method === 'HEAD') response.end();
          else response.end(EMPTY_TRANSLATIONS_MANIFEST);
          return;
        }

        if (pathname === indexPath) {
          request.url = `${server.config.base}index.html${query}`;
          next();
          return;
        }

        const iconPath = iconPaths.get(pathname);
        if (iconPath && (request.method === 'GET' || request.method === 'HEAD')) {
          request.url = `${server.config.base}${iconPath}${query}`;
          next();
          return;
        }

        if (pathname.startsWith(developmentResourcePath)) {
          const resourcePath = pathname.slice(developmentResourcePath.length);
          if (developmentResourcePrefixes.some((prefix) => resourcePath.startsWith(prefix))) {
            next();
            return;
          }
        }

        if (pathname.startsWith(RELEASE_PATH_PREFIX)) {
          response.statusCode = 404;
          response.end('Not Found');
          return;
        }

        next();
      });
    },
    handleHotUpdate(context) {
      const file = relative(pluginRoot, resolve(context.file)).replaceAll('\\', '/');
      if (file === 'config.json') {
        context.server.config.logger.warn('config.json 已变化，请重启 Dev Server 后重新打开编辑器会话。');
        context.server.ws.send({ path: '*', type: 'full-reload' });
        return [];
      }
      if (fullReloadFiles.has(file) || file.startsWith('src/onlyoffice/')) {
        context.server.ws.send({ path: '*', type: 'full-reload' });
        return [];
      }
    },
    transformIndexHtml: {
      order: 'post',
      handler(html) {
        const sdkScriptPath = new URL('../v1/plugins.js', `http://vite.invalid${viteBase}index.html`).pathname;
        const sdkStylePath = new URL('../v1/plugins.css', `http://vite.invalid${viteBase}index.html`).pathname;
        return html.replaceAll(sdkStylePath, '/onlyoffice-sdk/v1/plugins.css').replaceAll(sdkScriptPath, '/onlyoffice-sdk/v1/plugins.js');
      },
    },
  };
}
