import {
  CallHandler,
  ExecutionContext,
  Injectable,
  NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import type { Request } from 'express';
import { catchError, from, mergeMap, Observable, throwError } from 'rxjs';
import { AuditService } from './audit.service';
import {
  OPERATION_LOG_METADATA,
  type OperationLogOptions,
} from './operation-log.decorator';

const sensitiveKeys = [
  'password',
  'oldPassword',
  'newPassword',
  'passwordHash',
  'token',
  'key',
  'secret',
  'studentPassword',
];

type RequestWithUser = Request & {
  user?: {
    id?: string;
    name?: string;
    username?: string;
  };
};

@Injectable()
export class OperationLogInterceptor implements NestInterceptor {
  constructor(
    private readonly auditService: AuditService,
    private readonly reflector: Reflector,
  ) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const options = this.reflector.getAllAndOverride<OperationLogOptions>(
      OPERATION_LOG_METADATA,
      [context.getHandler(), context.getClass()],
    );

    if (!options) {
      return next.handle();
    }

    const startedAt = Date.now();
    const request = context.switchToHttp().getRequest<RequestWithUser>();

    return next.handle().pipe(
      mergeMap((result) =>
        from(
          this.writeLog({
            options,
            request,
            result,
            status: 'success',
            durationMs: Date.now() - startedAt,
          }),
        ).pipe(mergeMap(() => [result])),
      ),
      catchError((error: unknown) =>
        from(
          this.writeLog({
            options,
            request,
            status: 'failed',
            durationMs: Date.now() - startedAt,
            error,
          }),
        ).pipe(mergeMap(() => throwError(() => error))),
      ),
    );
  }

  private async writeLog(input: {
    options: OperationLogOptions;
    request: RequestWithUser;
    result?: unknown;
    status: 'success' | 'failed';
    durationMs: number;
    error?: unknown;
  }) {
    const { options, request, result, status, durationMs, error } = input;
    const actor = request.user;

    await this.auditService.write({
      actorId: actor?.id ?? null,
      actorUsername: actor?.name || actor?.username || null,
      action: options.action,
      resourceType: options.resourceType ?? null,
      resourceId: this.getResourceId(options, request, result),
      metadata: {
        status,
        description: options.description,
        durationMs,
        request: this.pickRequestMetadata(request, options),
        error: error ? this.toErrorMetadata(error) : undefined,
      },
      ipAddress: this.getIp(request),
    });
  }

  private getResourceId(
    options: OperationLogOptions,
    request: RequestWithUser,
    result?: unknown,
  ) {
    if (options.resourceIdParam) {
      return String(request.params?.[options.resourceIdParam] ?? '') || null;
    }

    if (options.resourceIdFromResult) {
      const value = this.readPath(result, options.resourceIdFromResult);
      return value == null ? null : String(value);
    }

    return null;
  }

  private pickRequestMetadata(
    request: RequestWithUser,
    options: OperationLogOptions,
  ) {
    return {
      params: this.maskValue(request.params || {}),
      query: this.pickAndMask(request.query, options.metadataFromQuery),
      body: this.pickAndMask(request.body, options.metadataFromBody),
      userAgent: request.headers['user-agent'] ?? null,
    };
  }

  private pickAndMask(value: unknown, keys?: string[]) {
    if (!value || typeof value !== 'object') {
      return null;
    }

    const source = value as Record<string, unknown>;
    if (!keys?.length) {
      return this.maskValue(source);
    }

    const picked: Record<string, unknown> = {};
    for (const key of keys) {
      if (key in source) {
        picked[key] = source[key];
      }
    }

    return this.maskValue(picked);
  }

  private maskValue(value: unknown): unknown {
    if (Array.isArray(value)) {
      return value.map((item) => this.maskValue(item));
    }

    if (!value || typeof value !== 'object') {
      return value;
    }

    const masked: Record<string, unknown> = {};
    for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
      if (this.isSensitiveKey(key)) {
        masked[key] = '******';
      } else {
        masked[key] = this.maskValue(item);
      }
    }

    return masked;
  }

  private isSensitiveKey(key: string) {
    const normalized = key.toLowerCase();
    return sensitiveKeys.some((item) => normalized.includes(item.toLowerCase()));
  }

  private toErrorMetadata(error: unknown) {
    if (error instanceof Error) {
      return {
        name: error.name,
        message: error.message,
      };
    }

    return { message: String(error) };
  }

  private readPath(value: unknown, path: string) {
    return path.split('.').reduce<unknown>((current, key) => {
      if (!current || typeof current !== 'object') {
        return undefined;
      }

      return (current as Record<string, unknown>)[key];
    }, value);
  }

  private getIp(request: Request) {
    const forwardedFor = request.headers['x-forwarded-for'];
    if (typeof forwardedFor === 'string') {
      return forwardedFor.split(',')[0]?.trim() || null;
    }

    return request.ip || request.socket.remoteAddress || null;
  }
}
