import { Injectable } from '@nestjs/common';
import { type MarketIndex as PrismaMarketIndex, type PropertyType } from '@prisma/client';
import { PrismaService } from '@modules/shared';
import { MarketIndexEntity, type MarketIndexProps } from '../../domain/entities/market-index.entity';
import {
  type IMarketIndexRepository,
  type MarketReportResult,
  type HeatmapDataPoint,
  type WardHeatmapDataPoint,
  type ListingVolumeWardResult,
  type PriceTrendPoint,
  type DistrictStatsResult,
  type MarketHistoryPoint,
} from '../../domain/repositories/market-index.repository';

@Injectable()
export class PrismaMarketIndexRepository implements IMarketIndexRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findById(id: string): Promise<MarketIndexEntity | null> {
    const record = await this.prisma.marketIndex.findUnique({ where: { id } });
    return record ? this.toDomain(record) : null;
  }

  async findByKey(
    district: string,
    city: string,
    propertyType: PropertyType,
    period: string,
  ): Promise<MarketIndexEntity | null> {
    const record = await this.prisma.marketIndex.findUnique({
      where: {
        district_city_propertyType_period: { district, city, propertyType, period },
      },
    });
    return record ? this.toDomain(record) : null;
  }

  async save(entity: MarketIndexEntity): Promise<void> {
    await this.prisma.marketIndex.create({
      data: {
        id: entity.id,
        district: entity.district,
        city: entity.city,
        propertyType: entity.propertyType,
        period: entity.period,
        medianPrice: entity.medianPrice,
        avgPriceM2: entity.avgPriceM2,
        totalListings: entity.totalListings,
        daysOnMarket: entity.daysOnMarket,
        inventoryLevel: entity.inventoryLevel,
        absorptionRate: entity.absorptionRate,
        yoyChange: entity.yoyChange,
      },
    });
  }

  async update(entity: MarketIndexEntity): Promise<void> {
    await this.prisma.marketIndex.update({
      where: { id: entity.id },
      data: {
        medianPrice: entity.medianPrice,
        avgPriceM2: entity.avgPriceM2,
        totalListings: entity.totalListings,
        daysOnMarket: entity.daysOnMarket,
        inventoryLevel: entity.inventoryLevel,
        absorptionRate: entity.absorptionRate,
        yoyChange: entity.yoyChange,
      },
    });
  }

  async getMarketReport(
    city: string,
    period: string,
    propertyType?: PropertyType,
  ): Promise<MarketReportResult[]> {
    const where: Record<string, unknown> = { city, period };
    if (propertyType) where['propertyType'] = propertyType;

    const records = await this.prisma.marketIndex.findMany({
      where,
      orderBy: { district: 'asc' },
    });

    return records.map((r) => ({
      district: r.district,
      city: r.city,
      propertyType: r.propertyType,
      period: r.period,
      medianPrice: r.medianPrice.toString(),
      avgPriceM2: r.avgPriceM2,
      totalListings: r.totalListings,
      daysOnMarket: r.daysOnMarket,
      inventoryLevel: r.inventoryLevel,
      absorptionRate: r.absorptionRate,
      yoyChange: r.yoyChange,
    }));
  }

  async getHeatmap(city: string, period: string): Promise<HeatmapDataPoint[]> {
    const records = await this.prisma.marketIndex.findMany({
      where: { city, period },
      orderBy: { avgPriceM2: 'desc' },
    });

    const districtMap = new Map<string, { totalPrice: number; totalListings: number; count: number; medianPrices: bigint[] }>();

    for (const r of records) {
      const existing = districtMap.get(r.district);
      if (existing) {
        existing.totalPrice += r.avgPriceM2;
        existing.totalListings += r.totalListings;
        existing.count++;
        existing.medianPrices.push(r.medianPrice);
      } else {
        districtMap.set(r.district, {
          totalPrice: r.avgPriceM2,
          totalListings: r.totalListings,
          count: 1,
          medianPrices: [r.medianPrice],
        });
      }
    }

    return Array.from(districtMap.entries()).map(([district, data]) => ({
      district,
      city,
      avgPriceM2: data.totalPrice / data.count,
      totalListings: data.totalListings,
      medianPrice: (data.medianPrices[Math.floor(data.medianPrices.length / 2)] ?? 0).toString(),
    }));
  }

  /**
   * [TEC-3055] Ward-level heatmap.
   * Aggregates active listings directly from the Property/Listing tables using
   * PostGIS-friendly Prisma raw queries. Falls back to an in-memory group-by so
   * the method is testable without PostGIS extension.
   *
   * Algorithm:
   * 1. Join Property → Listing (status=ACTIVE) filtered by city + optionally district.
   * 2. Group by (ward, district) — compute avg(pricePerM2), count, and sort by ward asc.
   * 3. Cache handled upstream by the handler (30 min TTL).
   */
  async getHeatmapWard(city: string, _period: string, district?: string): Promise<WardHeatmapDataPoint[]> {
    type WardRow = { ward: string; district: string; avg_price_m2: number; total_listings: bigint; median_price: bigint };

    const districtFilter = district ? `AND p."district" = ${JSON.stringify(district)}` : '';

    const rows = await this.prisma.$queryRawUnsafe<WardRow[]>(`
      SELECT
        p."ward",
        p."district",
        AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8           AS avg_price_m2,
        COUNT(l."id")::bigint                                        AS total_listings,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
      FROM "Property" p
      JOIN "Listing" l ON l."propertyId" = p."id" AND l."status" = 'ACTIVE'
      WHERE p."city" = $1 ${districtFilter}
        AND p."ward" IS NOT NULL AND p."ward" != ''
      GROUP BY p."ward", p."district"
      ORDER BY p."ward" ASC
    `, city);

    return rows.map((r) => ({
      ward: r.ward,
      district: r.district,
      city,
      avgPriceM2: r.avg_price_m2 ?? 0,
      totalListings: Number(r.total_listings),
      medianPrice: (r.median_price ?? BigInt(0)).toString(),
    }));
  }

  /**
   * [TEC-3055] Listing volume + price aggregation for a specific ward over a period.
   * `wardId` is treated as the ward string (Property.ward) since the schema stores ward
   * as a plain string column (no separate Ward FK at this point).
   * `period` format: "YYYY-QN" (quarterly) or "YYYY-MM" (monthly) — matched against
   * the period column on MarketIndex (where available) or derived from Listing.createdAt.
   */
  async getListingVolumeByWard(wardId: string, period: string): Promise<ListingVolumeWardResult | null> {
    // Derive date range from period string (e.g. "2026-Q1" → Jan-Mar 2026, "2026-03" → Mar 2026)
    const dateRange = this.periodToDateRange(period);
    if (!dateRange) return null;

    type VolumeRow = {
      ward: string;
      district: string;
      city: string;
      total_listings: bigint;
      avg_price_m2: number;
      median_price: bigint;
    };

    const rows = await this.prisma.$queryRawUnsafe<VolumeRow[]>(`
      SELECT
        p."ward",
        p."district",
        p."city",
        COUNT(l."id")::bigint                                            AS total_listings,
        AVG(l."priceVND" / NULLIF(p."areaM2", 0))::float8               AS avg_price_m2,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY l."priceVND")::bigint AS median_price
      FROM "Property" p
      JOIN "Listing" l ON l."propertyId" = p."id"
      WHERE p."ward" = $1
        AND l."createdAt" >= $2
        AND l."createdAt" <  $3
      GROUP BY p."ward", p."district", p."city"
      LIMIT 1
    `, wardId, dateRange.start, dateRange.end);

    if (rows.length === 0) return null;
    const r = rows[0]!;

    return {
      ward: r.ward,
      district: r.district,
      city: r.city,
      period,
      totalListings: Number(r.total_listings),
      avgPriceM2: r.avg_price_m2 ?? 0,
      medianPrice: (r.median_price ?? BigInt(0)).toString(),
    };
  }

  async getPriceTrend(
    district: string,
    city: string,
    propertyType: PropertyType,
    periods: string[],
  ): Promise<PriceTrendPoint[]> {
    const records = await this.prisma.marketIndex.findMany({
      where: {
        district,
        city,
        propertyType,
        period: { in: periods },
      },
      orderBy: { period: 'asc' },
    });

    return records.map((r) => ({
      period: r.period,
      medianPrice: r.medianPrice.toString(),
      avgPriceM2: r.avgPriceM2,
      totalListings: r.totalListings,
    }));
  }

  async getDistrictStats(city: string, period: string): Promise<DistrictStatsResult[]> {
    const records = await this.prisma.marketIndex.findMany({
      where: { city, period },
      orderBy: [{ district: 'asc' }, { propertyType: 'asc' }],
    });

    return records.map((r) => ({
      district: r.district,
      city: r.city,
      propertyType: r.propertyType,
      medianPrice: r.medianPrice.toString(),
      avgPriceM2: r.avgPriceM2,
      totalListings: r.totalListings,
      daysOnMarket: r.daysOnMarket,
      inventoryLevel: r.inventoryLevel,
      absorptionRate: r.absorptionRate,
      yoyChange: r.yoyChange,
    }));
  }

  async getMarketHistory(city: string, periods: string[]): Promise<MarketHistoryPoint[]> {
    const records = await this.prisma.marketIndex.findMany({
      where: {
        city: { equals: city, mode: 'insensitive' },
        period: { in: periods },
      },
      orderBy: { period: 'asc' },
    });

    // Aggregate across all districts/property types per period
    const periodMap = new Map<string, {
      totalAvgPrice: number;
      totalMedian: bigint;
      totalListings: number;
      totalDaysOnMarket: number;
      count: number;
    }>();

    for (const r of records) {
      const existing = periodMap.get(r.period);
      if (existing) {
        existing.totalAvgPrice += r.avgPriceM2;
        existing.totalMedian += r.medianPrice;
        existing.totalListings += r.totalListings;
        existing.totalDaysOnMarket += r.daysOnMarket;
        existing.count++;
      } else {
        periodMap.set(r.period, {
          totalAvgPrice: r.avgPriceM2,
          totalMedian: r.medianPrice,
          totalListings: r.totalListings,
          totalDaysOnMarket: r.daysOnMarket,
          count: 1,
        });
      }
    }

    return Array.from(periodMap.entries()).map(([period, data]) => ({
      date: period,
      avgPrice: Math.round(data.totalAvgPrice / data.count),
      medianPrice: (data.totalMedian / BigInt(data.count)).toString(),
      listingsCount: data.totalListings,
      inquiriesCount: 0, // inquiries not tracked in MarketIndex
      daysOnMarket: Math.round(data.totalDaysOnMarket / data.count),
    }));
  }

  // ---------------------------------------------------------------------------
  // Private helpers
  // ---------------------------------------------------------------------------

  /** Parse period strings like "2026-Q1", "2026-03" into an inclusive date range. */
  private periodToDateRange(period: string): { start: Date; end: Date } | null {
    // Quarterly: YYYY-Q1 … YYYY-Q4
    const quarterly = /^(\d{4})-Q([1-4])$/.exec(period);
    if (quarterly) {
      const year = Number(quarterly[1]);
      const quarter = Number(quarterly[2]);
      const startMonth = (quarter - 1) * 3; // 0-based
      const start = new Date(Date.UTC(year, startMonth, 1));
      const end = new Date(Date.UTC(year, startMonth + 3, 1));
      return { start, end };
    }

    // Monthly: YYYY-MM
    const monthly = /^(\d{4})-(\d{2})$/.exec(period);
    if (monthly) {
      const year = Number(monthly[1]);
      const month = Number(monthly[2]) - 1; // 0-based
      const start = new Date(Date.UTC(year, month, 1));
      const end = new Date(Date.UTC(year, month + 1, 1));
      return { start, end };
    }

    return null;
  }

  private toDomain(raw: PrismaMarketIndex): MarketIndexEntity {
    const props: MarketIndexProps = {
      district: raw.district,
      city: raw.city,
      propertyType: raw.propertyType,
      period: raw.period,
      medianPrice: raw.medianPrice,
      avgPriceM2: raw.avgPriceM2,
      totalListings: raw.totalListings,
      daysOnMarket: raw.daysOnMarket,
      inventoryLevel: raw.inventoryLevel,
      absorptionRate: raw.absorptionRate,
      yoyChange: raw.yoyChange,
    };

    return new MarketIndexEntity(raw.id, props, raw.createdAt);
  }
}
