import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { TrackViewDto } from "./dto/track-view.dto";
import { DeviceType, PageView } from "./entities/page-view.entity";

export interface AnalyticsSummary {
  totalViews: number;
  uniqueVisitors: number;
  viewsToday: number;
  views7d: number;
  views30d: number;
  /** Last 30 days, oldest first, zero-filled for days with no traffic. */
  dailyViews: { date: string; count: number }[];
  /** Top 10 paths by views in the last 30 days. */
  topPages: { path: string; count: number }[];
  deviceBreakdown: { device: string; count: number }[];
  /** Top 10 countries by views in the last 30 days. */
  countryBreakdown: { country: string; countryCode: string | null; count: number }[];
}

// No User-Agent parsing library here — this only needs a coarse
// desktop/mobile/tablet bucket for the admin's device-breakdown chart, not
// full browser/OS detection.
function detectDevice(userAgent: string | undefined): DeviceType {
  if (!userAgent) return "desktop";
  if (/iPad|Tablet(?!.*Mobile)/i.test(userAgent)) return "tablet";
  if (/Mobi|Android|iPhone/i.test(userAgent)) return "mobile";
  return "desktop";
}

function daysAgo(n: number): Date {
  const d = new Date();
  d.setHours(0, 0, 0, 0);
  d.setDate(d.getDate() - n);
  return d;
}

// Plain local-calendar-day key ("YYYY-MM-DD") — deliberately not
// toISOString() (always UTC), which would drift the date backward by a day
// for any server timezone behind UTC and desync from daysAgo()'s local-time
// day boundaries.
function toDateKey(d: Date): string {
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, "0");
  const day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}

// MySQL's DATE() returns either a Date object or "YYYY-MM-DD" string
// depending on the driver/timezone config — normalize both to the plain
// string key the frontend chart expects.
function normalizeDateKey(value: string | Date): string {
  return value instanceof Date ? toDateKey(value) : String(value).slice(0, 10);
}

function fillDailySeries(raw: { date: string | Date; count: string }[], days: number): { date: string; count: number }[] {
  const byDate = new Map(raw.map((r) => [normalizeDateKey(r.date), Number(r.count)]));
  const series: { date: string; count: number }[] = [];
  for (let i = days - 1; i >= 0; i--) {
    const key = toDateKey(daysAgo(i));
    series.push({ date: key, count: byDate.get(key) ?? 0 });
  }
  return series;
}

// Loopback/private ranges never resolve to a real country — skip the
// network call entirely for them (every request in local dev) instead of
// waiting out a lookup that will only fail.
function isPrivateIp(ip: string): boolean {
  if (ip === "::1" || ip === "127.0.0.1") return true;
  if (/^(10\.|192\.168\.|127\.)/.test(ip)) return true;
  if (/^172\.(1[6-9]|2\d|3[0-1])\./.test(ip)) return true;
  return false;
}

// Normalizes the various shapes Express/Node hand back for a client IP —
// an IPv6-mapped IPv4 address ("::ffff:1.2.3.4") or a comma-separated
// X-Forwarded-For chain (takes the first, left-most hop, which is the
// original client when behind a single reverse proxy).
function normalizeIp(ip: string): string {
  const first = ip.split(",")[0].trim();
  return first.startsWith("::ffff:") ? first.slice(7) : first;
}

interface GeoResult {
  country: string;
  countryCode: string;
}

// Free, no-API-key IP geolocation (ip-api.com's non-commercial tier) —
// best-effort only: a short timeout and any failure both resolve to null
// rather than slowing down or breaking the tracking beacon. The raw IP is
// only ever used for this one lookup, never persisted (see PageView —
// only the resolved country is stored).
async function lookupCountry(ip: string): Promise<GeoResult | null> {
  if (isPrivateIp(ip)) return null;
  try {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 1500);
    const res = await fetch(`http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,countryCode`, {
      signal: controller.signal,
    });
    clearTimeout(timeout);
    if (!res.ok) return null;
    const data = (await res.json()) as { status: string; country?: string; countryCode?: string };
    if (data.status !== "success" || !data.country || !data.countryCode) return null;
    return { country: data.country, countryCode: data.countryCode };
  } catch {
    return null;
  }
}

@Injectable()
export class AnalyticsService {
  constructor(@InjectRepository(PageView) private readonly repo: Repository<PageView>) {}

  async track(dto: TrackViewDto, userAgent: string | undefined, ip: string): Promise<void> {
    const geo = await lookupCountry(normalizeIp(ip));
    await this.repo.save(
      this.repo.create({
        path: dto.path,
        referrer: dto.referrer ?? null,
        visitorId: dto.visitorId ?? null,
        device: detectDevice(userAgent),
        country: geo?.country ?? null,
        countryCode: geo?.countryCode ?? null,
      }),
    );
  }

  async summary(): Promise<AnalyticsSummary> {
    const [totalViews, uniqueVisitorsRow, viewsToday, views7d, views30d, dailyRaw, topPagesRaw, deviceRaw, countryRaw] = await Promise.all([
      this.repo.count(),
      this.repo
        .createQueryBuilder("pv")
        .select("COUNT(DISTINCT pv.visitorId)", "count")
        .where("pv.visitorId IS NOT NULL")
        .getRawOne<{ count: string }>(),
      this.repo.createQueryBuilder("pv").where("pv.createdAt >= :start", { start: daysAgo(0) }).getCount(),
      this.repo.createQueryBuilder("pv").where("pv.createdAt >= :start", { start: daysAgo(6) }).getCount(),
      this.repo.createQueryBuilder("pv").where("pv.createdAt >= :start", { start: daysAgo(29) }).getCount(),
      this.repo
        .createQueryBuilder("pv")
        .select("DATE(pv.createdAt)", "date")
        .addSelect("COUNT(*)", "count")
        .where("pv.createdAt >= :start", { start: daysAgo(29) })
        .groupBy("DATE(pv.createdAt)")
        .orderBy("date", "ASC")
        .getRawMany<{ date: string | Date; count: string }>(),
      this.repo
        .createQueryBuilder("pv")
        .select("pv.path", "path")
        .addSelect("COUNT(*)", "count")
        .where("pv.createdAt >= :start", { start: daysAgo(29) })
        .groupBy("pv.path")
        .orderBy("count", "DESC")
        .limit(10)
        .getRawMany<{ path: string; count: string }>(),
      this.repo
        .createQueryBuilder("pv")
        .select("pv.device", "device")
        .addSelect("COUNT(*)", "count")
        .where("pv.createdAt >= :start", { start: daysAgo(29) })
        .groupBy("pv.device")
        .getRawMany<{ device: string; count: string }>(),
      this.repo
        .createQueryBuilder("pv")
        .select("pv.country", "country")
        .addSelect("pv.countryCode", "countryCode")
        .addSelect("COUNT(*)", "count")
        .where("pv.createdAt >= :start", { start: daysAgo(29) })
        .andWhere("pv.country IS NOT NULL")
        .groupBy("pv.country")
        .addGroupBy("pv.countryCode")
        .orderBy("count", "DESC")
        .limit(10)
        .getRawMany<{ country: string; countryCode: string | null; count: string }>(),
    ]);

    return {
      totalViews,
      uniqueVisitors: Number(uniqueVisitorsRow?.count ?? 0),
      viewsToday,
      views7d,
      views30d,
      dailyViews: fillDailySeries(dailyRaw, 30),
      topPages: topPagesRaw.map((r) => ({ path: r.path, count: Number(r.count) })),
      deviceBreakdown: deviceRaw.map((r) => ({ device: r.device, count: Number(r.count) })),
      countryBreakdown: countryRaw.map((r) => ({ country: r.country, countryCode: r.countryCode, count: Number(r.count) })),
    };
  }
}
