import {
  BadRequestException,
  ConflictException,
  Injectable,
  NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.service';
import { Prisma } from '../../generated/prisma/client';
import { ServiceLogger } from '../../common/logging/service-logger';
import { ActivityLogService } from '../activity-logs/activity-log.service';
import { CreateReportDto } from './dto/create-report.dto';
import { ListReportsQueryDto } from './dto/list-reports-query.dto';
import { UpdateReportDto } from './dto/update-report.dto';

const reportResponseSelect = {
  id: true,
  clientId: true,
  reportStartDate: true,
  reportEndDate: true,
  createdAt: true,
  updatedAt: true,
  isDeleted: true,
  version: true,
  businessMetricValue: true,
  businessMetricGrowth: true,
  traffic: true,
  trafficGrowth: true,
  backlinks: true,
  newBacklinks: true,
  notes: true,
  client: {
    select: {
      id: true,
      companyName: true,
      contactName: true,
      email: true,
      primaryMetricType: true,
      status: true,
      isDeleted: true,
    },
  },
  createdBy: {
    select: {
      id: true,
      name: true,
      email: true,
    },
  },
  keywordRanks: {
    select: {
      id: true,
      keywordId: true,
      position: true,
      keyword: {
        select: {
          id: true,
          keyword: true,
        },
      },
    },
  },
} satisfies Prisma.ReportSelect;

@Injectable()
export class ReportsService {
  private readonly logger = new ServiceLogger(ReportsService.name);
  constructor(
    private readonly prisma: PrismaService,
    private readonly activityLogs: ActivityLogService,
  ) { }

  async list(managerId: string, query: ListReportsQueryDto) {
    return this.listReports(query, managerId);
  }

  async listForAdmin(query: ListReportsQueryDto) {
    return this.listReports(query);
  }

  private async listReports(query: ListReportsQueryDto, managerId?: string) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 10;
    const search = query.search?.trim();
    const where: Prisma.ReportWhereInput = {
      ...(query.clientId ? { clientId: query.clientId } : {}),
      ...(managerId
        ? {
          client: {
            is: {
              assignedUserId: managerId,
              isDeleted: false,
            },
          },
        }
        : {}),
      ...(search
        ? {
          OR: [
            {
              client: {
                is: {
                  companyName: { contains: search, mode: 'insensitive' },
                },
              },
            },
            {
              client: {
                is: {
                  contactName: { contains: search, mode: 'insensitive' },
                },
              },
            },
            { notes: { contains: search, mode: 'insensitive' } },
          ],
        }
        : {}),
    };
    const total = await this.prisma.report.count({ where });
    const totalPages = Math.max(1, Math.ceil(total / limit));
    const items = await this.prisma.report.findMany({
      where,
      orderBy: { createdAt: 'desc' },
      skip: (Math.min(page, totalPages) - 1) * limit,
      take: limit,
      select: reportResponseSelect,
    });
    return {
      items,
      meta: { page: Math.min(page, totalPages), limit, total, totalPages },
    };
  }

  async getBookedPeriods(
    clientId: string,
    actorUserId: string,
    restrictToManager = false,
  ) {
    await this.client(clientId, actorUserId, restrictToManager);
    return this.prisma.report.findMany({
      where: {
        clientId,
        isDeleted: false,
      },
      select: {
        id: true,
        reportStartDate: true,
        reportEndDate: true,
      },
      orderBy: { reportStartDate: 'desc' },
    });
  }

  async getBookedPeriodsForAdmin(clientId: string) {
    return this.prisma.report.findMany({
      where: {
        clientId,
        isDeleted: false,
      },
      select: {
        id: true,
        reportStartDate: true,
        reportEndDate: true,
      },
      orderBy: { reportStartDate: 'desc' },
    });
  }

  async create(dto: CreateReportDto, managerId: string) {
    return this.createReport(dto, managerId, true);
  }

  async createForAdmin(dto: CreateReportDto, adminId: string) {
    return this.createReport(dto, adminId, false);
  }

  private async createReport(
    dto: CreateReportDto,
    actorUserId: string,
    restrictToManager: boolean,
  ) {
    const period = this.period(dto.reportStartDate);
    const today = new Date();
    today.setUTCHours(23, 59, 59, 999);
    if (period.start > today) {
      throw new BadRequestException(
        'Reports cannot be created for future dates.',
      );
    }
    await this.client(dto.clientId, actorUserId, restrictToManager);
    await this.assertKeywordRanksBelongToClient(dto.clientId, dto.keywordRanks);
    const overlap = await this.prisma.report.findFirst({
      where: {
        clientId: dto.clientId,
        isDeleted: false,
        reportStartDate: { lte: period.end },
        reportEndDate: { gte: period.start },
      },
    });
    if (overlap) {
      this.logger.warn('report.create.rejected', {
        reason: 'weekly_period_exists', clientId: dto.clientId, actorUserId,
      });
      throw new ConflictException(
        'A weekly report already exists for this client during these dates.',
      );
    }
    const { keywordRanks, ...reportData } = dto;
    let report;

    try {
      report = await this.prisma.report.create({
        data: {
          ...reportData,
          reportStartDate: period.start,
          reportEndDate: period.end,
          notes: dto.notes?.trim() || null,
          createdById: actorUserId,
          ...(keywordRanks && keywordRanks.length > 0
            ? {
              keywordRanks: {
                createMany: {
                  data: keywordRanks.map((kr) => ({
                    keywordId: kr.keywordId,
                    position: Math.min(Math.max(0, kr.position), 100),
                  })),
                },
              },
            }
            : {}),
        },
        select: reportResponseSelect,
      });
    } catch (error) {
      if (this.isUniqueConstraintViolation(error)) {
        this.logger.warn('report.create.rejected', {
          reason: 'weekly_period_exists', clientId: dto.clientId, actorUserId,
        });
        throw new ConflictException(
          'A weekly report already exists for this client during these dates.',
        );
      }

      this.logger.error('report.create.failed', error, {
        clientId: dto.clientId, actorUserId,
      });
      throw error;
    }
    await this.activityLogs.record({
      action: 'report_created',
      entityType: 'report',
      entityId: report.id,
      reportId: report.id,
      clientId: report.clientId,
      actorUserId,
      title: 'Report created',
      description: `${report.client.companyName}: weekly report created for ${this.formatDateOnly(period.start)} to ${this.formatDateOnly(period.end)}.`,
      metadata: JSON.parse(JSON.stringify({ after: this.snapshot(report) })),
    });
    this.logger.log('report.created', {
      reportId: report.id, clientId: report.clientId, actorUserId,
      reportingStartDate: dto.reportStartDate,
    });
    return report;
  }

  async update(id: string, dto: UpdateReportDto, managerId: string) {
    return this.updateReport(id, dto, managerId, true);
  }

  async updateForAdmin(id: string, dto: UpdateReportDto, adminId: string) {
    return this.updateReport(id, dto, adminId, false);
  }

  private async updateReport(
    id: string,
    dto: UpdateReportDto,
    actorUserId: string,
    restrictToManager: boolean,
  ) {
    const current = await this.prisma.report.findFirst({
      where: {
        id,
        isDeleted: false,
        ...(restrictToManager
          ? {
            client: {
              assignedUserId: actorUserId,
              isDeleted: false,
              status: 'active',
            },
          }
          : {}),
      },
      select: reportResponseSelect,
    });
    if (!current) throw new NotFoundException('Report not found.');
    const { keywordRanks, ...updateData } = dto;
    await this.assertKeywordRanksBelongToClient(current.clientId, keywordRanks);
    const updated = await this.prisma.$transaction(async (tx) => {
      await tx.report.update({
        where: { id },
        data: {
          ...updateData,
          ...(dto.notes !== undefined
            ? { notes: dto.notes.trim() || null }
            : {}),
          version: { increment: 1 },
        },
      });

      if (keywordRanks !== undefined) {
        // The editor only submits active keywords. Upserting those values keeps
        // ranks for inactive or soft-deleted keywords as historical data.
        await Promise.all(
          keywordRanks.map((rank) =>
            tx.reportKeywordRank.upsert({
              where: {
                reportId_keywordId: {
                  reportId: id,
                  keywordId: rank.keywordId,
                },
              },
              create: {
                reportId: id,
                keywordId: rank.keywordId,
                position: Math.min(Math.max(0, rank.position), 100),
              },
              update: { position: Math.min(Math.max(0, rank.position), 100) },
            }),
          ),
        );
      }

      return tx.report.findUniqueOrThrow({
        where: { id },
        select: reportResponseSelect,
      });
    });
    const changes = this.changes(current, updated);
    if (changes.length)
      await this.activityLogs.record({
        action: 'report_updated',
        entityType: 'report',
        entityId: id,
        reportId: id,
        clientId: updated.clientId,
        actorUserId,
        title: 'Report updated',
        description: `${updated.client.companyName}: ${changes.join('; ')}.`,
        metadata: JSON.parse(
          JSON.stringify({
            before: this.snapshot(current),
            after: this.snapshot(updated),
            changes,
          }),
        ),
      });
    this.logger.log('report.updated', {
      reportId: updated.id, clientId: updated.clientId, actorUserId,
      changedFieldCount: changes.length,
    });
    return updated;
  }

  async softDelete(id: string, managerId: string) {
    return this.softDeleteReport(id, managerId, true);
  }

  async softDeleteForAdmin(id: string, adminId: string) {
    return this.softDeleteReport(id, adminId, false);
  }

  private async softDeleteReport(
    id: string,
    actorUserId: string,
    restrictToManager: boolean,
  ) {
    const current = await this.prisma.report.findFirst({
      where: {
        id,
        isDeleted: false,
        ...(restrictToManager
          ? {
            client: {
              assignedUserId: actorUserId,
              isDeleted: false,
              status: 'active',
            },
          }
          : {}),
      },
      select: reportResponseSelect,
    });

    if (!current) throw new NotFoundException('Report not found.');

    const deleted = await this.prisma.report.update({
      where: { id },
      data: { isDeleted: true },
      select: reportResponseSelect,
    });

    await this.activityLogs.record({
      action: 'report_deleted',
      entityType: 'report',
      entityId: deleted.id,
      reportId: deleted.id,
      clientId: deleted.clientId,
      actorUserId,
      title: 'Report deleted',
      description: `${deleted.client.companyName}: weekly report for ${this.formatDateOnly(deleted.reportStartDate)} to ${this.formatDateOnly(deleted.reportEndDate)} was deleted.`,
      metadata: JSON.parse(JSON.stringify({ before: this.snapshot(current) })),
    });
    this.logger.log('report.deleted', {
      reportId: deleted.id, clientId: deleted.clientId, actorUserId,
    });

    return deleted;
  }

  async restore(id: string, managerId: string) {
    return this.restoreReport(id, managerId, true);
  }

  async restoreForAdmin(id: string, adminId: string) {
    return this.restoreReport(id, adminId, false);
  }

  private async restoreReport(
    id: string,
    actorUserId: string,
    restrictToManager: boolean,
  ) {
    const current = await this.prisma.report.findFirst({
      where: {
        id,
        isDeleted: true,
        ...(restrictToManager
          ? {
            client: {
              assignedUserId: actorUserId,
              isDeleted: false,
              status: 'active',
            },
          }
          : {}),
      },
      select: reportResponseSelect,
    });

    if (!current) throw new NotFoundException('Deleted report not found.');

    const overlap = await this.prisma.report.findFirst({
      where: {
        clientId: current.clientId,
        id: { not: current.id },
        isDeleted: false,
        reportStartDate: { lte: current.reportEndDate },
        reportEndDate: { gte: current.reportStartDate },
      },
    });

    if (overlap) {
      this.logger.warn('report.restore.rejected', {
        reason: 'weekly_period_exists', reportId: current.id, clientId: current.clientId, actorUserId,
      });
      throw new ConflictException(
        'This report cannot be restored because another report already uses this weekly period.',
      );
    }

    let restored;

    try {
      restored = await this.prisma.report.update({
        where: { id },
        data: { isDeleted: false },
        select: reportResponseSelect,
      });
    } catch (error) {
      if (this.isUniqueConstraintViolation(error)) {
        this.logger.warn('report.restore.rejected', {
          reason: 'weekly_period_exists', reportId: current.id, clientId: current.clientId, actorUserId,
        });
        throw new ConflictException(
          'This report cannot be restored because another report already uses this weekly period.',
        );
      }

      this.logger.error('report.restore.failed', error, {
        reportId: current.id, clientId: current.clientId, actorUserId,
      });
      throw error;
    }

    await this.activityLogs.record({
      action: 'report_restored',
      entityType: 'report',
      entityId: restored.id,
      reportId: restored.id,
      clientId: restored.clientId,
      actorUserId,
      title: 'Report restored',
      description: `${restored.client.companyName}: weekly report for ${this.formatDateOnly(restored.reportStartDate)} to ${this.formatDateOnly(restored.reportEndDate)} was restored.`,
      metadata: JSON.parse(JSON.stringify({ after: this.snapshot(restored) })),
    });
    this.logger.log('report.restored', {
      reportId: restored.id, clientId: restored.clientId, actorUserId,
    });

    return restored;
  }

  private async client(
    id: string,
    actorUserId: string,
    restrictToManager: boolean,
  ) {
    const client = await this.prisma.client.findFirst({
      where: {
        id,
        status: 'active',
        isDeleted: false,
        ...(restrictToManager ? { assignedUserId: actorUserId } : {}),
      },
    });
    if (!client) {
      throw new NotFoundException(
        restrictToManager
          ? 'Assigned active client not found.'
          : 'Active client not found.',
      );
    }
  }

  private async assertKeywordRanksBelongToClient(
    clientId: string,
    keywordRanks?: Array<{ keywordId: string }>,
  ) {
    if (!keywordRanks?.length) return;

    const keywordIds = keywordRanks.map((rank) => rank.keywordId);
    const uniqueKeywordIds = new Set(keywordIds);
    if (uniqueKeywordIds.size !== keywordIds.length) {
      throw new BadRequestException(
        'Each keyword can have only one ranking value per report.',
      );
    }

    const matchingKeywordCount = await this.prisma.clientKeyword.count({
      where: {
        id: { in: [...uniqueKeywordIds] },
        clientId,
      },
    });

    if (matchingKeywordCount !== uniqueKeywordIds.size) {
      throw new BadRequestException(
        'All keyword rankings must belong to the report client.',
      );
    }
  }

  private isUniqueConstraintViolation(error: unknown) {
    return (
      typeof error === 'object' &&
      error !== null &&
      'code' in error &&
      (error as { code?: string }).code === 'P2002'
    );
  }
  private period(value: string) {
    const dateOnly = value.trim();

    if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) {
      throw new ConflictException('Reporting start date is invalid.');
    }

    const start = new Date(`${dateOnly}T00:00:00.000Z`);
    if (Number.isNaN(+start) || start.toISOString().slice(0, 10) !== dateOnly) {
      throw new ConflictException('Reporting start date is invalid.');
    }

    const end = new Date(start);
    end.setUTCDate(end.getUTCDate() + 6);
    return { start, end };
  }
  private snapshot(value: Record<string, unknown>) {
    const scalarFields = Object.fromEntries(
      [
        'businessMetricValue',
        'businessMetricGrowth',
        'traffic',
        'trafficGrowth',
        'backlinks',
        'newBacklinks',
        'notes',
      ].map((key) => [key, value[key]]),
    );

    const keywordRanks = Array.isArray(value.keywordRanks)
      ? (
          value.keywordRanks as Array<{
            keywordId: string;
            position: number;
            keyword?: { keyword: string };
          }>
        ).map((kr) => ({
          keywordId: kr.keywordId,
          keywordName: kr.keyword?.keyword ?? 'Unknown',
          position: kr.position,
        }))
      : [];

    return {
      ...scalarFields,
      keywordRanks,
    };
  }

  private changes(
    before: Record<string, unknown>,
    after: Record<string, unknown>,
  ): string[] {
    const labels: Record<string, string> = {
      businessMetricValue: 'Primary result',
      businessMetricGrowth: 'Primary result growth',
      traffic: 'Traffic',
      trafficGrowth: 'Traffic growth',
      backlinks: 'Total backlinks',
      newBacklinks: 'New backlinks',
      notes: 'Internal notes',
    };

    const diffs: string[] = [];

    for (const [key, label] of Object.entries(labels)) {
      if (before[key] !== after[key]) {
        diffs.push(
          `${label} changed from "${this.displayChangeValue(key, before[key])}" to "${this.displayChangeValue(key, after[key])}"`,
        );
      }
    }

    const beforeRanks = Array.isArray(before.keywordRanks)
      ? (
          before.keywordRanks as Array<{
            keywordId: string;
            position: number;
            keyword?: { keyword: string };
          }>
        )
      : [];
    const afterRanks = Array.isArray(after.keywordRanks)
      ? (
          after.keywordRanks as Array<{
            keywordId: string;
            position: number;
            keyword?: { keyword: string };
          }>
        )
      : [];

    const beforeMap = new Map(beforeRanks.map((kr) => [kr.keywordId, kr]));
    const afterMap = new Map(afterRanks.map((kr) => [kr.keywordId, kr]));
    const allKeywordIds = new Set([...beforeMap.keys(), ...afterMap.keys()]);

    for (const kwId of allKeywordIds) {
      const b = beforeMap.get(kwId);
      const a = afterMap.get(kwId);
      const bPos = b?.position;
      const aPos = a?.position;

      if (bPos !== aPos) {
        const kwName =
          a?.keyword?.keyword || b?.keyword?.keyword || 'Keyword';
        const bDisplay =
          bPos === undefined
            ? 'Not set'
            : bPos === 0
              ? 'Unranked'
              : `#${bPos}`;
        const aDisplay =
          aPos === undefined
            ? 'Not set'
            : aPos === 0
              ? 'Unranked'
              : `#${aPos}`;
        diffs.push(
          `Keyword "${kwName}" rank changed from ${bDisplay} to ${aDisplay}`,
        );
      }
    }

    return diffs;
  }

  private displayChangeValue(_key: string, value: unknown) {
    if (value === undefined || value === null || value === '') {
      return 'Not provided';
    }

    return String(value);
  }

  private formatDateOnly(date: Date): string {
    const day = String(date.getUTCDate()).padStart(2, '0');
    const month = String(date.getUTCMonth() + 1).padStart(2, '0');
    const year = date.getUTCFullYear();
    return `${day}-${month}-${year}`;
  }
}
