// src/service.js
// Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved

import env from "./config/env.js";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";

import { NextFunction, Request, Response } from "express";

import { readJsonFile } from "./lib/tools.js";
import log from "./lib/log.js";

const UPDATE_INTERVAL = 1000 * 60 * 15;

export type UriSet = Set<string>;
export type MethodMap = Map<string, UriSet>;

export interface TripwireConfigFileEntry {
  name: string;
  uris: string[];
}
export interface TripwireConfigFile {
  prefixes: string[];
  strings: string[];
  methods: TripwireConfigFileEntry[];
}

export interface TripwireConfig {
  prefixes: string[];
  strings: string[];
  methods: MethodMap;
}

export enum TripwireIncidentType {
  MALFORMED_REQUEST = "malformed-request",
  PREFIX_MATCH = "uri-prefix-match",
  SUBSTRING_MATCH = "uri-substring-match",
  PROHIBITED_METHOD = "prohibited-method",
}

export interface ITripwireIncident {
  type: TripwireIncidentType;
  matching?: string;
}

class TripwireService {
  cacheDirectory = path.join(env.cache.path, "tripwire");
  configuration?: TripwireConfig;
  updateTimerId?: NodeJS.Timeout;

  async start(): Promise<void> {
    await this.prepareCacheDirectory();

    log.info("starting tripwire service");
    await this.loadConfiguration();

    log.info("starting tripwire configuration update interval");
    this.updateTimerId = setInterval(
      this.loadConfiguration.bind(this),
      UPDATE_INTERVAL
    );
  }

  async stop(): Promise<void> {
    if (this.updateTimerId) {
      log.info("stopping tripwire configuration update interval");
      clearInterval(this.updateTimerId);
      delete this.updateTimerId;
    }
    log.info("tripwire service stopped");
  }

  middleware() {
    return async (
      req: Request,
      _res: Response,
      next: NextFunction
    ): Promise<void> => {
      if (!this.configuration) {
        log.error("service configuration not loaded");
        const error = new Error("Configuration not loaded");
        error.name = "TripwireConfigurationError";
        error.statusCode = 503;
        return next(error);
      }

      const { method, url } = req;
      if (method[0] === "\\" || url[0] === "\\") {
        const error = new Error("request is blocked");
        error.name = "TripwireRequestBlocked";
        error.statusCode = 403;
        log.warn(error.message, { method, url });
        this.reportIncident(req, {
          type: TripwireIncidentType.MALFORMED_REQUEST,
        });
        return next(error);
      }

      for (const prefix of this.configuration.prefixes) {
        if (url.startsWith(prefix)) {
          const error = new Error("request is blocked");
          error.statusCode = 403;
          log.warn("blocked request", { method, url });
          this.reportIncident(req, {
            type: TripwireIncidentType.PREFIX_MATCH,
            matching: prefix,
          });
          return next(error);
        }
      }

      for (const text of this.configuration.strings) {
        if (url.includes(text)) {
          const error = new Error("request is blocked");
          error.statusCode = 403;
          log.warn("blocked request", { method, url });
          this.reportIncident(req, {
            type: TripwireIncidentType.SUBSTRING_MATCH,
            matching: text,
          });
          return next(error);
        }
      }

      const blockedMethod = this.configuration.methods.get(method);
      if (!blockedMethod) {
        return;
      }
      if (blockedMethod.has(url)) {
        const error = new Error(`request is blocked`);
        error.statusCode = 403;
        log.warn("blocked request", { method, url });
        this.reportIncident(req, {
          type: TripwireIncidentType.PREFIX_MATCH,
        });
        return next(error);
      }

      next();
    };
  }

  async processError(error: Error, req: Request, res: Response): Promise<void> {
    let interested = req.url.match(/[admin|user]/gi);
    if (!interested) {
      return;
    }
    return this.requestReview(error, req, res);
  }

  async loadConfiguration(): Promise<void> {
    let configFile: TripwireConfigFile | undefined;
    try {
      configFile = await this.fetchConfiguration();
    } catch (error) {
      // fall through
    }

    try {
      if (!configFile) {
        configFile = await this.loadConfigurationFile();
      }
    } catch (error) {
      // fall through
    }

    if (!configFile) {
      const error = new Error("failed to load Tripwire configuration");
      error.statusCode = 503;
      throw error;
    }

    this.configuration = {
      prefixes: configFile.prefixes,
      strings: configFile.strings,
      methods: new Map<string, Set<string>>(),
    };
    for (const entry of configFile.methods) {
      this.configuration.methods.set(entry.name, new Set<string>(entry.uris));
    }
  }

  async fetchConfiguration(): Promise<TripwireConfigFile | undefined> {
    const url = path.join(env.api.url, "configuration");
    log.info("loading tripwire configuration", { url });
    const response = await fetch(url, {
      method: "GET",
      headers: {
        Authorization: `Bearer ${env.api.clientId}:${env.api.clientSecret}`,
      },
    });
    if (!response.ok) {
      log.error("failed to receive tripwire service configuration", {
        status: response.status,
        statusText: response.statusText,
      });
      return;
    }

    const json = await response.json();
    if (!json.success) {
      log.error("failed to receive tripwire service configuration", {
        message: json.message,
      });
      return;
    }

    log.info("tripwire service configuration updated");
    return json.configuration as TripwireConfigFile;
  }

  async loadConfigurationFile(): Promise<TripwireConfigFile | undefined> {
    const filePath = path.join(this.cacheDirectory, "config.json");
    log.info("loading tripwire configuration from cache", { filePath });
    try {
      const config = await readJsonFile<TripwireConfigFile>(filePath);
      return config;
    } catch (error) {
      log.error("failed to load JSON configuration file", { filePath, error });
      return;
    }
  }

  async prepareCacheDirectory(): Promise<void> {
    await fs.promises.mkdir(this.cacheDirectory, { recursive: true });
  }

  async requestReview(
    error: Error,
    req: Request,
    res: Response
  ): Promise<void> {
    const statusCode = error.statusCode || res.statusCode || 500;
    const statusMessage =
      error.message || res.statusMessage || "An error has occured";
    log.error("requesting tripwire analysis and review", {
      req: {
        ip: req.ip,
        url: req.url,
      },
      statusCode,
      statusMessage,
      error,
    });
    try {
      const report = {
        hostname: os.hostname(),
        statusCode,
        statusMessage,
        error,
        request: {
          ip: req.ip,
          ips: req.ips,
          host: req.host,
          method: req.method,
          url: req.url,
          originalUrl: req.originalUrl,
          protocol: req.protocol,
          headers: req.headers,
          body: req.body,
        },
      };
      const body = JSON.stringify(report);
      const response = await fetch(env.n8n.errorUrl, {
        method: "POST",
        headers: {
          "X-DTP-Tripwire": `${env.n8n.authToken}`,
          "Content-Type": "application/json",
          "Content-Length": body.length.toString(),
        },
        body,
      });
      if (!response.ok) {
        const error = new Error("failed to request tripwire review");
        error.statusCode = response.status;
        throw error;
      }
      log.info("tripwire analysis request submitted");
    } catch (error) {
      log.error("failed to send tripwire event", { error });
    }
  }

  async reportIncident(
    req: Request,
    incident: ITripwireIncident
  ): Promise<void> {
    try {
      const report = {
        hostname: os.hostname(),
        incident,
        request: {
          ip: req.ip,
          ips: req.ips,
          host: req.host,
          method: req.method,
          url: req.url,
          originalUrl: req.originalUrl,
          protocol: req.protocol,
          headers: req.headers,
          body: req.body,
        },
      };

      const body = JSON.stringify(report);
      const response = await fetch(env.n8n.eventUrl, {
        method: "POST",
        headers: {
          "X-DTP-Tripwire": `${env.n8n.authToken}`,
          "Content-Type": "application/json",
          "Content-Length": body.length.toString(),
        },
        body,
      });
      if (!response.ok) {
        const error = new Error("failed to report tripwire incident");
        error.statusCode = response.status;
        throw error;
      }
      log.info("tripwire incident reported");
    } catch (error) {
      log.error("failed to send tripwire event", { error });
    }
  }
}

export default new TripwireService();
