import { Injectable } from '@nestjs/common';
import { AssetSelectorService } from './asset-selector.service';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { Any, Connection } from 'typeorm';
import {
  decodeBigNumberKey,
  encodeBigNumberKey,
} from '../utils/big-number-key';
import { EventWebhookLogEntity } from '../entities/event-webhook-log';
import { EventActionEntity } from '../entities/event-action.entity';
import axios, { AxiosResponse } from 'axios';
import { v4 as uuidv4 } from 'uuid';

const MAX_EVENTS_ONCE = 1000;
const ERROR_DELAY = 3 * 60 * 1000;
const MAX_EVENT_KEEP_DAYS = 14;
const MAX_ATTEMPT = 5;
const MAX_AWAIT_TIME = 15 * 60 * 1000;

@Injectable()
export class AssetEventService {
  private touchedProjectIds = new Set<string>();
  private initialRun = true;
  private webhookCalled = false;

  constructor(
    private readonly assetSelectorService: AssetSelectorService,
    @InjectDataSource()
    private readonly connection: Connection,
  ) {}

  setTouchedProjectId(projectId: string) {
    this.touchedProjectIds.add(projectId);
  }

  @Cron('*/1 * * * * *')
  async callWebhooksFast() {
    this.callWebhooks(true);
  }

  @Cron('0 */5 * * * *')
  async callWebhooksSlow() {
    this.callWebhooks(false);
  }

  async callWebhooks(fast: boolean) {
    if (fast && this.touchedProjectIds.size === 0 && !this.initialRun) {
      return;
    }
    if (this.webhookCalled) {
      return;
    }
    try {
      this.webhookCalled = true;
      this.initialRun = false;
      const project_ids = fast ? [...this.touchedProjectIds] : [];
      this.touchedProjectIds = new Set();
      const res = (await this.connection.query(
        `
          SELECT DISTINCT ea.project_id, p.webhook_url, ewl.max_time as last_success_actions_to
          FROM event_actions ea
          JOIN projects p ON ea.project_id = p.id AND p.webhook_url IS NOT NULL
          LEFT JOIN (
              SELECT project_id, MAX(actions_to) max_time
              FROM event_webhook_logs
              WHERE(response_status_code IS NULL OR (response_status_code >= 200 AND response_status_code < 300))
              GROUP BY project_id
          ) ewl ON ewl.project_id = p.id
          WHERE ea.processed_at IS NULL AND (ea.created_at > ewl.max_time OR ewl.max_time IS NULL)
          ${project_ids.length > 0 ? 'AND ea.project_id = ANY($1)' : ''}
      `,
        project_ids.length > 0
          ? [project_ids.map((p) => decodeBigNumberKey(p))]
          : [],
      )) as {
        project_id: string;
        webhook_url: string;
        last_success_actions_to: Date | null;
      }[];

      if (res.length === 0) {
        return;
      }

      await Promise.all(
        res.map((r) =>
          this.callProjectWebhook(
            encodeBigNumberKey(r.project_id),
            r.webhook_url,
            r.last_success_actions_to,
          ),
        ),
      );
    } finally {
      this.webhookCalled = false;
    }
  }

  async callProjectWebhook(
    projectId: string,
    webhook_url: string,
    last_success_actions_to: Date | null,
  ) {
    const last_request = await this.connection
      .getRepository(EventWebhookLogEntity)
      .createQueryBuilder('ewl')
      .orderBy('ewl.request_at', 'DESC')
      .where('ewl.project_id = :project_id', {
        project_id: decodeBigNumberKey(projectId),
      })
      .limit(1)
      .getOne();
    let new_attempt = 1;
    if (last_request) {
      const elapsed = Date.now() - last_request.requestAt.getTime();
      if (!last_request.responseStatusCode) {
        if (elapsed < MAX_AWAIT_TIME) {
          return; // Previous request in work
        }
      } else if (
        last_request.responseStatusCode < 200 ||
        last_request.responseStatusCode >= 300
      ) {
        if (elapsed < ERROR_DELAY * last_request.attempt) {
          return; // Delay because of error
        }
        new_attempt = last_request.attempt + 1;
      }
    }

    const actions_to_send_q = this.connection
      .getRepository(EventActionEntity)
      .createQueryBuilder('ea')
      .leftJoin(
        'asset_block_keys',
        'abk',
        'abk.project_id = :project_id AND abk.block_key = target_block_key',
        {
          project_id: decodeBigNumberKey(projectId),
        },
      )
      .where('ea.project_id = :project_id AND ea.processed_at IS NULL', {
        project_id: decodeBigNumberKey(projectId),
      })
      .andWhere(
        `ea.created_at > NOW() - interval '${+MAX_EVENT_KEEP_DAYS} days'`,
      )
      .orderBy('ea.created_at')
      .limit(MAX_EVENTS_ONCE);
    if (last_success_actions_to) {
      actions_to_send_q.andWhere('ea.created_at >= :time', {
        time: last_success_actions_to,
      });
    }

    type ActionToSend = {
      id: string;
      asset_id: string;
      target_block_name: string | null;
      target_block_title: string | null;
      target_block_type: string;
      target_prop: string;
      old_target_content: Record<string, any> | null;
      new_target_content: Record<string, any> | null;
      event_name: string;
      created_at: Date;
    };
    const actions_to_send = await actions_to_send_q
      .select([
        'ea.id as id',
        'ea.asset_id as asset_id',
        'abk.block_name AS target_block_name',
        'abk.block_title AS target_block_title',
        'abk.block_type AS target_block_type',
        'ea.target_prop as target_prop',
        'ea.old_target_content as old_target_content',
        'ea.new_target_content as new_target_content',
        'ea.event_name as event_name',
        'ea.created_at as created_at',
      ])
      .getRawMany<ActionToSend>();
    if (actions_to_send.length === 0) {
      return;
    }
    let status: number;

    const request_id = uuidv4();
    const request = await this.connection
      .getRepository(EventWebhookLogEntity)
      .insert({
        actionsFrom: last_success_actions_to,
        actionsTo: actions_to_send[actions_to_send.length - 1].created_at,
        attempt: new_attempt,
        id: request_id,
        projectId: projectId,
      });
    let last_success_action_index = -1;
    let response_error: string | null = null;
    let response_timeout = false;
    let response_fatal_error = false;
    try {
      const abort_controller = new AbortController();
      let response_done = false;
      const timeout_promise = new Promise((res, rej) => {
        setTimeout(() => {
          if (!response_done) {
            rej(new Error('Webhook request timeout'));
            abort_controller.abort();
            response_timeout = true;
          }
        }, MAX_AWAIT_TIME);
      });
      const request_promise = axios.post(
        webhook_url,
        {
          projectId: projectId,
          actions: actions_to_send.map((a) => {
            return {
              id: a.id,
              assetId: a.asset_id,
              eventName: a.event_name,
              createdAt: a.created_at,
              targetBlockName: a.target_block_name,
              targetBlockTitle: a.target_block_title,
              targetBlockType: a.target_block_type,
              targetProp: a.target_prop,
              oldTargetContent: a.old_target_content,
              newTargetContent: a.new_target_content,
            };
          }),
        },
        {
          headers: {
            'Content-Type': 'application/json',
          },
          transformResponse: (data) => {
            return data;
          },
          validateStatus: () => true,
          responseType: 'text',
          signal: abort_controller.signal,
        },
      );
      const response = (await Promise.race([
        request_promise,
        timeout_promise,
      ])) as AxiosResponse<any, any>;

      response_done = true;
      status = response.status;
      const success = status >= 200 && status < 300;
      response_fatal_error =
        !success && ((status >= 400 && status < 500) || new_attempt >= 5);
      if (response.data) {
        try {
          const response_json = JSON.parse(response.data);
          if (response_json.last) {
            last_success_action_index = actions_to_send.findIndex(
              (a) => a.id === response_json.last,
            );
            if (last_success_action_index < 0 && success) {
              status = -3;
              response_error = 'Wrong webhook last event id';
            }
          }
          if (response_json.error && !success) {
            response_error = response_json.error;
          }
        } catch {
          if (success) {
            status = -2;
            response_error = 'Wrong webhook asnwer';
          } else {
            response_error = response.data;
          }
        }
      } else if (success) {
        last_success_action_index = actions_to_send.length - 1;
      }
    } catch (err) {
      status = -1;
      response_error = err.message;
      if (response_timeout) {
        status = -4;
        last_success_action_index = actions_to_send.length - 1;
      }
    }
    if (response_fatal_error) {
      last_success_action_index = actions_to_send.length - 1;
    }

    await this.connection.transaction(async (tx) => {
      await tx.getRepository(EventWebhookLogEntity).update(
        {
          projectId: projectId,
          id: request_id,
        },
        {
          responseAt: () => 'now()',
          responseStatusCode: status,
          responseError: response_error
            ? response_error.substring(0, 128)
            : null,
        },
      );
      if (last_success_action_index >= 0) {
        const processed_action_ids = actions_to_send
          .slice(0, last_success_action_index + 1)
          .map((a) => a.id);
        await tx.getRepository(EventActionEntity).update(
          {
            projectId: projectId,
            id: Any(processed_action_ids),
          },
          {
            processedAt: () => 'now()',
          },
        );
      }
    });
  }
}
