import bcrypt from 'bcrypt';
import { DateTime, Duration } from 'luxon';
import crypto from 'node:crypto';
import { ValidationError } from 'objection';

import appConfig from '../config/app.js';
import { hasValidLicense } from '../helpers/license.ee.js';
import userAbility from '../helpers/user-ability.js';
import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js';
import Base from './base.js';
import App from './app.js';
import AccessToken from './access-token.js';
import Connection from './connection.js';
import Config from './config.js';
import Execution from './execution.js';
import ExecutionStep from './execution-step.js';
import Flow from './flow.js';
import Identity from './identity.ee.js';
import Permission from './permission.js';
import Role from './role.js';
import Step from './step.js';
import Subscription from './subscription.ee.js';
import UsageData from './usage-data.ee.js';
import Billing from '../helpers/billing/index.ee.js';
import NotAuthorizedError from '../errors/not-authorized.js';

import deleteUserQueue from '../queues/delete-user.ee.js';
import flowQueue from '../queues/flow.js';
import emailQueue from '../queues/email.js';
import {
  REMOVE_AFTER_30_DAYS_OR_150_JOBS,
  REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../helpers/remove-job-configuration.js';

class User extends Base {
  static tableName = 'users';

  static jsonSchema = {
    type: 'object',
    required: ['fullName', 'email'],

    properties: {
      id: { type: 'string', format: 'uuid' },
      fullName: { type: 'string', minLength: 1 },
      email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
      password: { type: 'string', minLength: 6 },
      status: {
        type: 'string',
        enum: ['active', 'invited'],
        default: 'active',
      },
      resetPasswordToken: { type: ['string', 'null'] },
      resetPasswordTokenSentAt: {
        type: ['string', 'null'],
        format: 'date-time',
      },
      invitationToken: { type: ['string', 'null'] },
      invitationTokenSentAt: {
        type: ['string', 'null'],
        format: 'date-time',
      },
      trialExpiryDate: { type: 'string' },
      roleId: { type: 'string', format: 'uuid' },
      deletedAt: { type: 'string' },
      createdAt: { type: 'string' },
      updatedAt: { type: 'string' },
    },
  };

  static relationMappings = () => ({
    accessTokens: {
      relation: Base.HasManyRelation,
      modelClass: AccessToken,
      join: {
        from: 'users.id',
        to: 'access_tokens.user_id',
      },
    },
    connections: {
      relation: Base.HasManyRelation,
      modelClass: Connection,
      join: {
        from: 'users.id',
        to: 'connections.user_id',
      },
    },
    flows: {
      relation: Base.HasManyRelation,
      modelClass: Flow,
      join: {
        from: 'users.id',
        to: 'flows.user_id',
      },
    },
    steps: {
      relation: Base.ManyToManyRelation,
      modelClass: Step,
      join: {
        from: 'users.id',
        through: {
          from: 'flows.user_id',
          to: 'flows.id',
        },
        to: 'steps.flow_id',
      },
    },
    executions: {
      relation: Base.ManyToManyRelation,
      modelClass: Execution,
      join: {
        from: 'users.id',
        through: {
          from: 'flows.user_id',
          to: 'flows.id',
        },
        to: 'executions.flow_id',
      },
    },
    usageData: {
      relation: Base.HasManyRelation,
      modelClass: UsageData,
      join: {
        from: 'usage_data.user_id',
        to: 'users.id',
      },
    },
    currentUsageData: {
      relation: Base.HasOneRelation,
      modelClass: UsageData,
      join: {
        from: 'usage_data.user_id',
        to: 'users.id',
      },
      filter(builder) {
        builder.orderBy('created_at', 'desc').limit(1).first();
      },
    },
    subscriptions: {
      relation: Base.HasManyRelation,
      modelClass: Subscription,
      join: {
        from: 'subscriptions.user_id',
        to: 'users.id',
      },
    },
    currentSubscription: {
      relation: Base.HasOneRelation,
      modelClass: Subscription,
      join: {
        from: 'subscriptions.user_id',
        to: 'users.id',
      },
      filter(builder) {
        builder.orderBy('created_at', 'desc').limit(1).first();
      },
    },
    role: {
      relation: Base.HasOneRelation,
      modelClass: Role,
      join: {
        from: 'roles.id',
        to: 'users.role_id',
      },
    },
    permissions: {
      relation: Base.HasManyRelation,
      modelClass: Permission,
      join: {
        from: 'users.role_id',
        to: 'permissions.role_id',
      },
    },
    identities: {
      relation: Base.HasManyRelation,
      modelClass: Identity,
      join: {
        from: 'identities.user_id',
        to: 'users.id',
      },
    },
  });

  static get virtualAttributes() {
    return ['acceptInvitationUrl'];
  }

  get authorizedFlows() {
    const conditions = this.can('read', 'Flow');
    return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query();
  }

  get authorizedSteps() {
    const conditions = this.can('read', 'Flow');
    return conditions.isCreator ? this.$relatedQuery('steps') : Step.query();
  }

  get authorizedConnections() {
    const conditions = this.can('read', 'Connection');
    return conditions.isCreator
      ? this.$relatedQuery('connections')
      : Connection.query();
  }

  get authorizedExecutions() {
    const conditions = this.can('read', 'Execution');
    return conditions.isCreator
      ? this.$relatedQuery('executions')
      : Execution.query();
  }

  get acceptInvitationUrl() {
    return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`;
  }

  static async authenticate(email, password) {
    const user = await User.query().findOne({
      email: email?.toLowerCase() || null,
    });

    if (user && (await user.login(password))) {
      const token = await createAuthTokenByUserId(user.id);
      return token;
    }
  }

  async login(password) {
    return await bcrypt.compare(password, this.password);
  }

  async generateResetPasswordToken() {
    const resetPasswordToken = crypto.randomBytes(64).toString('hex');
    const resetPasswordTokenSentAt = new Date().toISOString();

    await this.$query().patch({ resetPasswordToken, resetPasswordTokenSentAt });
  }

  async generateInvitationToken() {
    const invitationToken = crypto.randomBytes(64).toString('hex');
    const invitationTokenSentAt = new Date().toISOString();

    await this.$query().patchAndFetch({
      invitationToken,
      invitationTokenSentAt,
    });
  }

  async resetPassword(password) {
    return await this.$query().patch({
      resetPasswordToken: null,
      resetPasswordTokenSentAt: null,
      password,
    });
  }

  async acceptInvitation(password) {
    return await this.$query().patch({
      invitationToken: null,
      invitationTokenSentAt: null,
      status: 'active',
      password,
    });
  }

  async updatePassword({ currentPassword, password }) {
    if (await User.authenticate(this.email, currentPassword)) {
      const user = await this.$query().patchAndFetch({
        password,
      });

      return user;
    }

    throw new ValidationError({
      data: {
        currentPassword: [
          {
            message: 'is incorrect.',
          },
        ],
      },
      type: 'ValidationError',
    });
  }

  async softRemove() {
    await this.softRemoveAssociations();
    await this.$query().delete();

    const jobName = `Delete user - ${this.id}`;
    const jobPayload = { id: this.id };
    const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
    const jobOptions = {
      delay: millisecondsFor30Days,
    };

    await deleteUserQueue.add(jobName, jobPayload, jobOptions);
  }

  async softRemoveAssociations() {
    const flows = await this.$relatedQuery('flows').where({
      active: true,
    });

    const repeatableJobs = await flowQueue.getRepeatableJobs();

    for (const flow of flows) {
      const job = repeatableJobs.find((job) => job.id === flow.id);

      if (job) {
        await flowQueue.removeRepeatableByKey(job.key);
      }
    }

    const executionIds = (
      await this.$relatedQuery('executions').select('executions.id')
    ).map((execution) => execution.id);
    const flowIds = flows.map((flow) => flow.id);

    await this.$relatedQuery('accessTokens').delete();
    await ExecutionStep.query().delete().whereIn('execution_id', executionIds);
    await this.$relatedQuery('executions').delete();
    await this.$relatedQuery('steps').delete();
    await Flow.query().whereIn('id', flowIds).delete();
    await this.$relatedQuery('connections').delete();
    await this.$relatedQuery('identities').delete();

    if (appConfig.isCloud) {
      await this.$relatedQuery('subscriptions').delete();
      await this.$relatedQuery('usageData').delete();
    }
  }

  async sendResetPasswordEmail() {
    await this.generateResetPasswordToken();

    const jobName = `Reset Password Email - ${this.id}`;

    const jobPayload = {
      email: this.email,
      subject: 'Reset Password',
      template: 'reset-password-instructions.ee',
      params: {
        token: this.resetPasswordToken,
        webAppUrl: appConfig.webAppUrl,
        fullName: this.fullName,
      },
    };

    const jobOptions = {
      removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
      removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
    };

    await emailQueue.add(jobName, jobPayload, jobOptions);
  }

  isResetPasswordTokenValid() {
    if (!this.resetPasswordTokenSentAt) {
      return false;
    }

    const sentAt = new Date(this.resetPasswordTokenSentAt);
    const now = new Date();
    const fourHoursInMilliseconds = 1000 * 60 * 60 * 4;

    return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds;
  }

  async sendInvitationEmail() {
    await this.generateInvitationToken();

    const jobName = `Invitation Email - ${this.id}`;

    const jobPayload = {
      email: this.email,
      subject: 'You are invited!',
      template: 'invitation-instructions',
      params: {
        fullName: this.fullName,
        acceptInvitationUrl: this.acceptInvitationUrl,
      },
    };

    const jobOptions = {
      removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
      removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
    };

    await emailQueue.add(jobName, jobPayload, jobOptions);
  }

  isInvitationTokenValid() {
    if (!this.invitationTokenSentAt) {
      return false;
    }

    const sentAt = new Date(this.invitationTokenSentAt);
    const now = new Date();
    const seventyTwoHoursInMilliseconds = 1000 * 60 * 60 * 72;

    return now.getTime() - sentAt.getTime() < seventyTwoHoursInMilliseconds;
  }

  async generateHash() {
    if (this.password) {
      this.password = await bcrypt.hash(this.password, 10);
    }
  }

  async startTrialPeriod() {
    this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate();
  }

  async isAllowedToRunFlows() {
    if (appConfig.isSelfHosted) {
      return true;
    }

    if (await this.inTrial()) {
      return true;
    }

    if ((await this.hasActiveSubscription()) && (await this.withinLimits())) {
      return true;
    }

    return false;
  }

  async inTrial() {
    if (appConfig.isSelfHosted) {
      return false;
    }

    if (!this.trialExpiryDate) {
      return false;
    }

    if (await this.hasActiveSubscription()) {
      return false;
    }

    const expiryDate = DateTime.fromJSDate(this.trialExpiryDate);
    const now = DateTime.now();

    return now < expiryDate;
  }

  async hasActiveSubscription() {
    if (!appConfig.isCloud) {
      return false;
    }

    const subscription = await this.$relatedQuery('currentSubscription');

    return subscription?.isValid;
  }

  async withinLimits() {
    const currentSubscription = await this.$relatedQuery('currentSubscription');
    const plan = currentSubscription.plan;
    const currentUsageData = await this.$relatedQuery('currentUsageData');

    return currentUsageData.consumedTaskCount < plan.quota;
  }

  async getPlanAndUsage() {
    const usageData = await this.$relatedQuery(
      'currentUsageData'
    ).throwIfNotFound();

    const subscription = await this.$relatedQuery('currentSubscription');

    const currentPlan = Billing.paddlePlans.find(
      (plan) => plan.productId === subscription?.paddlePlanId
    );

    const planAndUsage = {
      usage: {
        task: usageData.consumedTaskCount,
      },
      plan: {
        id: subscription?.paddlePlanId || null,
        name: subscription ? currentPlan.name : 'Free Trial',
        limit: currentPlan?.limit || null,
      },
    };

    return planAndUsage;
  }

  async getInvoices() {
    const subscription = await this.$relatedQuery('currentSubscription');

    if (!subscription) {
      return [];
    }

    const invoices = await Billing.paddleClient.getInvoices(
      Number(subscription.paddleSubscriptionId)
    );

    return invoices;
  }

  async getApps(name) {
    const connections = await this.authorizedConnections
      .clone()
      .select('connections.key')
      .where({ draft: false })
      .count('connections.id as count')
      .groupBy('connections.key');

    const flows = await this.authorizedFlows
      .clone()
      .withGraphJoined('steps')
      .orderBy('created_at', 'desc');

    const duplicatedUsedApps = flows
      .map((flow) => flow.steps.map((step) => step.appKey))
      .flat()
      .filter(Boolean);

    const connectionKeys = connections.map((connection) => connection.key);
    const usedApps = [...new Set([...duplicatedUsedApps, ...connectionKeys])];

    let apps = await App.findAll(name);

    apps = apps
      .filter((app) => {
        return usedApps.includes(app.key);
      })
      .map((app) => {
        const connection = connections.find(
          (connection) => connection.key === app.key
        );

        app.connectionCount = connection?.count || 0;
        app.flowCount = 0;

        flows.forEach((flow) => {
          const usedFlow = flow.steps.find((step) => step.appKey === app.key);

          if (usedFlow) {
            app.flowCount += 1;
          }
        });

        return app;
      })
      .sort((appA, appB) => appA.name.localeCompare(appB.name));

    return apps;
  }

  static async createAdmin({ email, password, fullName }) {
    const adminRole = await Role.findAdmin();

    const adminUser = await this.query().insert({
      email,
      password,
      fullName,
      roleId: adminRole.id,
    });

    await Config.markInstallationCompleted();

    return adminUser;
  }

  static async registerUser(userData) {
    const { fullName, email, password } = userData;

    const role = await Role.query().findOne({ name: 'User' }).throwIfNotFound();

    const user = await User.query().insertAndFetch({
      fullName,
      email,
      password,
      roleId: role.id,
    });

    return user;
  }

  async $beforeInsert(queryContext) {
    await super.$beforeInsert(queryContext);

    this.email = this.email.toLowerCase();
    await this.generateHash();

    if (appConfig.isCloud) {
      await this.startTrialPeriod();
    }
  }

  async $beforeUpdate(opt, queryContext) {
    await super.$beforeUpdate(opt, queryContext);

    if (this.email) {
      this.email = this.email.toLowerCase();
    }

    await this.generateHash();
  }

  async $afterInsert(queryContext) {
    await super.$afterInsert(queryContext);

    if (appConfig.isCloud) {
      await this.$relatedQuery('usageData').insert({
        userId: this.id,
        consumedTaskCount: 0,
        nextResetAt: DateTime.now().plus({ days: 30 }).toISODate(),
      });
    }
  }

  async $afterFind() {
    if (await hasValidLicense()) return this;

    if (Array.isArray(this.permissions)) {
      this.permissions = this.permissions.filter((permission) => {
        const restrictedSubjects = [
          'App',
          'Role',
          'SamlAuthProvider',
          'Config',
        ];

        return !restrictedSubjects.includes(permission.subject);
      });
    }

    return this;
  }

  get ability() {
    return userAbility(this);
  }

  can(action, subject) {
    const can = this.ability.can(action, subject);

    if (!can) throw new NotAuthorizedError('The user is not authorized!');

    const relevantRule = this.ability.relevantRuleFor(action, subject);

    const conditions = relevantRule?.conditions || [];
    const conditionMap = Object.fromEntries(
      conditions.map((condition) => [condition, true])
    );

    return conditionMap;
  }

  cannot(action, subject) {
    const cannot = this.ability.cannot(action, subject);

    if (cannot) throw new NotAuthorizedError();

    return cannot;
  }
}

export default User;
