import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { randomUUID } from 'crypto';
import { APP_EVENTS } from '../common/constants/events';
import { PunchType, TripStatus } from '../common/constants/enums';
import { docId } from '../common/utils/mongo.util';
import {
  mapMatchCatchUpDebounceMs,
  mapMatchDownsampleMeters,
} from '../config/runtime-config';
import { GpsPoint, GpsPointDocument } from '../gps/schemas/gps-point.schema';
import { TripsService } from '../trips/trips.service';
import { GoogleDirectionsClient } from './google-directions.client';
import { GoogleRoadsClient } from './google-roads.client';
import {
  MatchJob,
  MatchJobDocument,
  MatchReason,
} from './schemas/match-job.schema';
import {
  RouteSegment,
  RouteSegmentDocument,
  RouteSegmentKind,
} from './schemas/route-segment.schema';
import { filterTrajectoryOutliers } from '../common/utils/geo.util';
import {
  classifySegmentKind,
  confidenceForKind,
  downsamplePath,
  encodePipePolyline,
  haversineMeters,
  pathLengthMeters,
  roundKm,
  type LatLng,
} from './map-matching.util';

export interface MatchedRouteResultDto {
  requestId: string;
  status: 'pending' | 'ready' | 'failed';
  engine?: string;
  matchedAt?: string;
  officialDistanceKm?: number;
  provisionalDistanceKm?: number;
  coveragePct?: number;
  matchedPct?: number;
  estimatedPct?: number;
  legs: Array<{
    legId: string;
    officialDistanceKm?: number;
    provisionalDistanceKm?: number;
    confidence?: number;
    estimatedPct?: number;
    matchedPolylineEncoded?: string;
    segments: Array<{
      segId: string;
      legId?: string;
      kind: RouteSegmentKind;
      confidence: number;
      lengthM: number;
      fromTimestamp?: string;
      toTimestamp?: string;
      polylineEncoded: string;
      matchMethod?: string;
    }>;
  }>;
  segments: Array<{
    segId: string;
    legId?: string;
    kind: RouteSegmentKind;
    confidence: number;
    lengthM: number;
    fromTimestamp?: string;
    toTimestamp?: string;
    polylineEncoded: string;
    matchMethod?: string;
  }>;
  error?: string;
}

@Injectable()
export class MapMatchingService {
  private readonly catchUpTimers = new Map<string, NodeJS.Timeout>();
  private readonly running = new Set<string>();

  constructor(
    @InjectModel(GpsPoint.name)
    private readonly gpsModel: Model<GpsPointDocument>,
    @InjectModel(RouteSegment.name)
    private readonly segmentModel: Model<RouteSegmentDocument>,
    @InjectModel(MatchJob.name)
    private readonly jobModel: Model<MatchJobDocument>,
    private readonly trips: TripsService,
    private readonly roads: GoogleRoadsClient,
    private readonly directions: GoogleDirectionsClient,
    private readonly config: ConfigService,
    private readonly events: EventEmitter2,
  ) {}

  async getMatchedRoute(requestId: string): Promise<MatchedRouteResultDto> {
    const trip = await this.trips.findDocumentByTripId(requestId);
    const id = trip.tripId;

    const latestJob = await this.jobModel
      .findOne({ requestId: id })
      .sort({ createdAt: -1 })
      .lean()
      .exec();

    const segments = await this.segmentModel
      .find({ requestId: id })
      .sort({ fromTimestamp: 1, createdAt: 1 })
      .lean()
      .exec();

    if (!segments.length) {
      return {
        requestId: id,
        status: (latestJob?.status as MatchedRouteResultDto['status']) ?? 'pending',
        engine: latestJob?.engine,
        error: latestJob?.error,
        legs: [],
        segments: [],
      };
    }

    return this.buildResultFromPersisted(
      trip.tripId,
      trip,
      segments as unknown as Record<string, unknown>[],
      latestJob as unknown as Record<string, unknown> | null,
    );
  }

  /**
   * Enqueue/run a match. Returns pending immediately if a run is already in flight,
   * otherwise runs and returns the ready/failed result.
   */
  async triggerMatch(
    requestId: string,
    reason: MatchReason = 'manual',
  ): Promise<MatchedRouteResultDto> {
    const trip = await this.trips.findDocumentByTripId(requestId);
    const id = trip.tripId;

    if (this.running.has(id)) {
      return { requestId: id, status: 'pending', legs: [], segments: [] };
    }

    return this.runMatch(id, reason);
  }

  /** Debounced automatic rematch after GPS catch-up batches. */
  scheduleCatchUp(requestId: string) {
    const debounce = mapMatchCatchUpDebounceMs(this.config);
    const existing = this.catchUpTimers.get(requestId);
    if (existing) clearTimeout(existing);
    const timer = setTimeout(() => {
      this.catchUpTimers.delete(requestId);
      void this.triggerMatch(requestId, 'catch_up');
    }, debounce);
    this.catchUpTimers.set(requestId, timer);
  }

  @OnEvent(APP_EVENTS.GPS_BATCH_PERSISTED)
  onGpsBatch(payload: { tripId?: string; count?: number }) {
    if (!payload?.tripId || !payload.count) return;
    // Skip tiny live single-point noise; catch-up batches are larger.
    if (payload.count < 3) return;
    this.scheduleCatchUp(payload.tripId);
  }

  @OnEvent(APP_EVENTS.PUNCH_RECORDED)
  onPunch(payload: { tripId?: string; type?: string }) {
    if (!payload?.tripId || !payload.type) return;
    if (payload.type !== PunchType.ARRIVAL) return;
    void this.handleArrivalMatch(payload.tripId);
  }

  private async handleArrivalMatch(requestId: string) {
    const trip = await this.trips.findDocumentByTripId(requestId);
    const reason: MatchReason =
      trip.status === TripStatus.COMPLETED || trip.status === TripStatus.RETURN_TRIP
        ? 'trip_end'
        : 'incremental';
    // Prefer trip_end when return leg arrived / completed.
    const legs = Array.isArray(trip.tripLegs) ? trip.tripLegs : [];
    const last = legs[legs.length - 1] as Record<string, unknown> | undefined;
    const isReturn = last?.isReturnLeg === true;
    await this.triggerMatch(
      trip.tripId,
      isReturn || trip.status === TripStatus.COMPLETED ? 'trip_end' : reason,
    );
  }

  async runMatch(
    requestId: string,
    reason: MatchReason,
  ): Promise<MatchedRouteResultDto> {
    const trip = await this.trips.findDocumentByTripId(requestId);
    const id = trip.tripId;
    const tripDbId = docId(trip);

    if (this.running.has(id)) {
      return { requestId: id, status: 'pending', legs: [], segments: [] };
    }
    this.running.add(id);

    const jobId = `match_${id}_${Date.now()}_${randomUUID().slice(0, 8)}`;
    const engine = this.roads.hasApiKey()
      ? 'google_roads'
      : 'trajectory_fallback';

    await this.jobModel.create({
      jobId,
      requestId: id,
      tripDbId,
      status: 'pending',
      reason,
      engine,
    });

    try {
      const points = await this.gpsModel
        .find({ tripId: tripDbId })
        .sort({ timestamp: 1 })
        .select('latitude longitude timestamp accuracy legId source speed')
        .lean()
        .exec();

      if (points.length < 2) {
        const failed: MatchedRouteResultDto = {
          requestId: id,
          status: 'failed',
          engine,
          error: 'Not enough GPS points to match',
          legs: [],
          segments: [],
        };
        await this.finishJob(jobId, 'failed', failed.error);
        return failed;
      }

      // Keep source so google_gap_filler / gap_resume survive outlier filter.
      const cleaned = filterTrajectoryOutliers(
        points.map((p) => ({
          latitude: p.latitude,
          longitude: p.longitude,
          timestamp: p.timestamp,
          accuracy: p.accuracy ?? undefined,
          legId: p.legId,
          source: (p as { source?: string }).source,
          speed: (p as { speed?: number }).speed,
        })),
      );

      if (cleaned.length < 2) {
        const failed: MatchedRouteResultDto = {
          requestId: id,
          status: 'failed',
          engine,
          error: 'Not enough valid GPS points after teleport filter',
          legs: [],
          segments: [],
        };
        await this.finishJob(jobId, 'failed', failed.error);
        return failed;
      }

      const spacing = mapMatchDownsampleMeters(this.config);
      const rawPath: LatLng[] = cleaned.map((p) => ({
        lat: p.latitude,
        lng: p.longitude,
      }));
      // Fill kill/reopen GPS gaps with Directions BEFORE snap — avoids orange
      // straight chords across city blocks in the official matched polyline.
      const gapFilled = await this.fillGpsGapsWithDirections(cleaned);
      const down = downsamplePath(gapFilled, spacing);
      const provisionalM = pathLengthMeters(rawPath);

      let matchedPath: LatLng[] = [];
      let matchMethod = engine;

      if (this.roads.hasApiKey()) {
        // interpolate=true after Directions gap-fill so curves follow roads
        // instead of straight chords through buildings.
        matchedPath = await this.roads.snapToRoads(down, true);
        if (matchedPath.length < 2) {
          matchedPath = down;
          matchMethod = 'gps_downsample_fallback';
        } else {
          matchMethod = 'google_roads+directions_gaps';
        }
      } else {
        matchedPath = down;
        matchMethod = this.directions.hasApiKey()
          ? 'directions_gap_fill'
          : 'trajectory_fallback';
      }

      const meanAccuracy =
        cleaned.reduce((s, p) => s + (p.accuracy ?? 40), 0) / cleaned.length;

      // Build per-leg segments when legIds exist; else one trip-level segment.
      const legIds = [
        ...new Set(
          cleaned
            .map((p) => (p as { legId?: string }).legId)
            .filter((x): x is string => !!x?.trim()),
        ),
      ];

      const segmentDocs: Array<Partial<RouteSegment>> = [];
      const legMetrics: MatchedRouteResultDto['legs'] = [];

      if (legIds.length > 0) {
        for (const legId of legIds) {
          const legPts = cleaned.filter(
            (p) => (p as { legId?: string }).legId === legId,
          );
          if (legPts.length < 2) continue;
          const legRaw = legPts.map((p) => ({
            lat: p.latitude,
            lng: p.longitude,
          }));
          const legGapFilled = await this.fillGpsGapsWithDirections(legPts);
          const legDown = downsamplePath(legGapFilled, spacing);
          let legMatched =
            this.roads.hasApiKey()
              ? await this.roads.snapToRoads(legDown, true)
              : legDown;
          if (legMatched.length < 2) legMatched = legDown;

          const gapSeconds = this.maxGapSeconds(legPts);
          const kind = classifySegmentKind({
            meanAccuracy:
              legPts.reduce((s, p) => s + (p.accuracy ?? 40), 0) /
              legPts.length,
            gapSeconds,
          });
          const lengthM = pathLengthMeters(legMatched);
          const polylineEncoded = encodePipePolyline(legMatched);
          const confidence = confidenceForKind(
            kind,
            legPts.reduce((s, p) => s + (p.accuracy ?? 40), 0) / legPts.length,
          );
          const segId = `${id}_${legId}_${kind}`;
          segmentDocs.push({
            requestId: id,
            tripDbId,
            segId,
            legId,
            kind,
            confidence,
            lengthM,
            fromTimestamp: legPts[0].timestamp,
            toTimestamp: legPts[legPts.length - 1].timestamp,
            polylineEncoded,
            matchMethod,
            matchJobId: jobId,
          });
          legMetrics.push({
            legId,
            officialDistanceKm: roundKm(lengthM),
            provisionalDistanceKm: roundKm(pathLengthMeters(legRaw)),
            confidence,
            estimatedPct: kind === 'estimated' ? 1 : 0,
            matchedPolylineEncoded: polylineEncoded,
            segments: [
              {
                segId,
                legId,
                kind,
                confidence,
                lengthM,
                fromTimestamp: legPts[0].timestamp.toISOString(),
                toTimestamp: legPts[legPts.length - 1].timestamp.toISOString(),
                polylineEncoded,
                matchMethod,
              },
            ],
          });
        }
      }

      if (segmentDocs.length === 0) {
        const gapSeconds = this.maxGapSeconds(cleaned);
        const kind = classifySegmentKind({ meanAccuracy, gapSeconds });
        const lengthM = pathLengthMeters(matchedPath);
        const polylineEncoded = encodePipePolyline(matchedPath);
        const confidence = confidenceForKind(kind, meanAccuracy);
        const segId = `${id}_full_${kind}`;
        segmentDocs.push({
          requestId: id,
          tripDbId,
          segId,
          kind,
          confidence,
          lengthM,
          fromTimestamp: cleaned[0].timestamp,
          toTimestamp: cleaned[cleaned.length - 1].timestamp,
          polylineEncoded,
          matchMethod,
          matchJobId: jobId,
        });
      }

      await this.segmentModel.deleteMany({ requestId: id }).exec();
      await this.segmentModel.insertMany(segmentDocs);

      const officialM = segmentDocs.reduce(
        (s, seg) => s + (seg.lengthM ?? 0),
        0,
      );
      const estimatedM = segmentDocs
        .filter((s) => s.kind === 'estimated')
        .reduce((s, seg) => s + (seg.lengthM ?? 0), 0);
      const matchedM = officialM - estimatedM;

      await this.applyToTrip(trip, {
        officialKm: roundKm(officialM),
        provisionalKm: roundKm(provisionalM),
        engine: matchMethod,
        legMetrics,
        fullPolyline:
          segmentDocs.length === 1
            ? segmentDocs[0].polylineEncoded!
            : encodePipePolyline(matchedPath),
      });

      const result: MatchedRouteResultDto = {
        requestId: id,
        status: 'ready',
        engine: matchMethod,
        matchedAt: new Date().toISOString(),
        officialDistanceKm: roundKm(officialM),
        provisionalDistanceKm: roundKm(provisionalM),
        coveragePct: 1,
        matchedPct: officialM > 0 ? matchedM / officialM : 0,
        estimatedPct: officialM > 0 ? estimatedM / officialM : 0,
        legs: legMetrics,
        segments: segmentDocs.map((s) => ({
          segId: s.segId!,
          legId: s.legId,
          kind: s.kind as RouteSegmentKind,
          confidence: s.confidence!,
          lengthM: s.lengthM!,
          fromTimestamp: s.fromTimestamp?.toISOString(),
          toTimestamp: s.toTimestamp?.toISOString(),
          polylineEncoded: s.polylineEncoded!,
          matchMethod: s.matchMethod,
        })),
      };

      await this.finishJob(jobId, 'ready', undefined, {
        officialDistanceKm: result.officialDistanceKm,
        segmentCount: result.segments.length,
      });

      this.events.emit(APP_EVENTS.ROUTE_MATCHED, result);
      this.events.emit(APP_EVENTS.TRIP_UPDATED, {
        tripId: id,
        officialDistanceKm: result.officialDistanceKm,
      });

      return result;
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      await this.finishJob(jobId, 'failed', message);
      const failed: MatchedRouteResultDto = {
        requestId: id,
        status: 'failed',
        engine,
        error: message,
        legs: [],
        segments: [],
      };
      this.events.emit(APP_EVENTS.ROUTE_MATCHED, failed);
      return failed;
    } finally {
      this.running.delete(id);
    }
  }

  /**
   * Replace kill/reopen GPS chords with Google Directions road geometry.
   * Mirrors Flutter GpsGapRoadFill policy (short-time impossible hops included).
   */
  private async fillGpsGapsWithDirections(
    points: Array<{ latitude: number; longitude: number; timestamp: Date }>,
  ): Promise<LatLng[]> {
    if (points.length < 2) {
      return points.map((p) => ({ lat: p.latitude, lng: p.longitude }));
    }
    if (!this.directions.hasApiKey()) {
      return points.map((p) => ({ lat: p.latitude, lng: p.longitude }));
    }

    const out: LatLng[] = [
      { lat: points[0].latitude, lng: points[0].longitude },
    ];
    let filled = 0;

    for (let i = 1; i < points.length; i++) {
      const prev = points[i - 1];
      const next = points[i];
      const prevLl = { lat: prev.latitude, lng: prev.longitude };
      const nextLl = { lat: next.latitude, lng: next.longitude };
      const distM = haversineMeters(prevLl, nextLl);
      const dtSec = Math.max(
        0,
        (next.timestamp.getTime() - prev.timestamp.getTime()) / 1000,
      );

      const wantsFill = this.shouldDirectionsFillGap(distM, dtSec);
      if (wantsFill) {
        const routes = await this.directions.fetchDrivingRoutes({
          origin: prevLl,
          destination: nextLl,
          alternatives: true,
        });
        const route = this.pickSaneRoute(routes, distM);
        const poly = route?.polylinePoints;
        if (poly && poly.length >= 2) {
          for (let j = 1; j < poly.length; j++) {
            out.push(poly[j]);
          }
          filled++;
          continue;
        }
      }
      out.push(nextLl);
    }

    if (filled > 0) {
    }
    return out;
  }

  /** Brief kills (60s / 400m) or classic 90s silence — not normal GPS cadence. */
  private shouldDirectionsFillGap(distM: number, dtSec: number): boolean {
    if (distM <= 250 || distM > 15000) return false;
    if (dtSec >= 90) return true;
    return dtSec >= 60 && distM >= 400;
  }

  /** Reject absurd detours; allow ~2× for bridges vs straight-line. */
  private pickSaneRoute(
    routes: Array<{ polylinePoints: LatLng[]; distanceMeters: number }>,
    straightM: number,
  ): { polylinePoints: LatLng[]; distanceMeters: number } | null {
    let best: { polylinePoints: LatLng[]; distanceMeters: number } | null =
      null;
    for (const r of routes) {
      const roadM = r.distanceMeters;
      if (roadM < 1 || straightM < 1) continue;
      if (roadM < straightM * 0.5) continue;
      const ok = roadM <= straightM * 2.0 || roadM <= straightM + 600;
      if (!ok) continue;
      if (!best || roadM < best.distanceMeters) best = r;
    }
    return best;
  }

  private maxGapSeconds(
    points: Array<{ timestamp: Date }>,
  ): number {
    let max = 0;
    for (let i = 1; i < points.length; i++) {
      const gap =
        (points[i].timestamp.getTime() - points[i - 1].timestamp.getTime()) /
        1000;
      if (gap > max) max = gap;
    }
    return max;
  }

  private async finishJob(
    jobId: string,
    status: 'ready' | 'failed',
    error?: string,
    resultSummary?: Record<string, unknown>,
  ) {
    await this.jobModel
      .updateOne(
        { jobId },
        {
          $set: {
            status,
            error,
            resultSummary,
            finishedAt: new Date(),
          },
        },
      )
      .exec();
  }

  private async applyToTrip(
    trip: Awaited<ReturnType<TripsService['findDocumentByTripId']>>,
    data: {
      officialKm: number;
      provisionalKm: number;
      engine: string;
      legMetrics: MatchedRouteResultDto['legs'];
      fullPolyline: string;
    },
  ) {
    const legs = Array.isArray(trip.tripLegs)
      ? trip.tripLegs.map((l) => ({ ...l }))
      : [];
    const byLeg = new Map(data.legMetrics.map((l) => [l.legId, l]));

    if (legs.length > 0) {
      for (let i = 0; i < legs.length; i++) {
        const leg = legs[i] as Record<string, unknown>;
        const legId = String(leg.legId ?? '');
        const m = byLeg.get(legId);
        if (m) {
          leg.officialDistanceKm = m.officialDistanceKm;
          leg.provisionalDistanceKm = m.provisionalDistanceKm;
          leg.matchedRoutePolylineEncoded = m.matchedPolylineEncoded;
          leg.matchConfidence = m.confidence;
          leg.estimatedPct = m.estimatedPct;
          // Keep GPS field as provisional mirror for older clients.
          if (m.provisionalDistanceKm != null) {
            leg.actualDistanceKmFromTrack =
              leg.actualDistanceKmFromTrack ?? m.provisionalDistanceKm;
          }
        } else if (legs.length === 1) {
          leg.officialDistanceKm = data.officialKm;
          leg.provisionalDistanceKm = data.provisionalKm;
          leg.matchedRoutePolylineEncoded = data.fullPolyline;
          leg.matchConfidence = 0.75;
        }
        legs[i] = leg;
      }
      trip.set('tripLegs', legs);
      trip.markModified('tripLegs');
    }

    trip.officialDistanceKm = data.officialKm;
    trip.provisionalDistanceKm = data.provisionalKm;
    trip.matchedAt = new Date();
    trip.matchEngine = data.engine;
    trip.totalDistance = data.officialKm * 1000;
    await trip.save();
  }

  private buildResultFromPersisted(
    requestId: string,
    trip: Awaited<ReturnType<TripsService['findDocumentByTripId']>>,
    segments: Array<Record<string, unknown>>,
    latestJob: Record<string, unknown> | null,
  ): MatchedRouteResultDto {
    const mapped = segments.map((s) => ({
      segId: String(s.segId ?? ''),
      legId: s.legId ? String(s.legId) : undefined,
      kind: s.kind as RouteSegmentKind,
      confidence: Number(s.confidence ?? 0),
      lengthM: Number(s.lengthM ?? 0),
      fromTimestamp: s.fromTimestamp
        ? new Date(s.fromTimestamp as Date).toISOString()
        : undefined,
      toTimestamp: s.toTimestamp
        ? new Date(s.toTimestamp as Date).toISOString()
        : undefined,
      polylineEncoded: String(s.polylineEncoded ?? ''),
      matchMethod: s.matchMethod ? String(s.matchMethod) : undefined,
    }));

    const officialM = mapped.reduce((s, x) => s + x.lengthM, 0);
    const estimatedM = mapped
      .filter((x) => x.kind === 'estimated')
      .reduce((s, x) => s + x.lengthM, 0);

    const legsRaw = Array.isArray(trip.tripLegs) ? trip.tripLegs : [];
    const legs: MatchedRouteResultDto['legs'] = legsRaw.map((leg) => {
      const l = leg as Record<string, unknown>;
      const legId = String(l.legId ?? '');
      const legSegs = mapped.filter((s) => s.legId === legId);
      return {
        legId,
        officialDistanceKm:
          Number(l.officialDistanceKm ?? 0) ||
          (legSegs.length
            ? roundKm(legSegs.reduce((s, x) => s + x.lengthM, 0))
            : undefined),
        provisionalDistanceKm:
          Number(l.provisionalDistanceKm ?? 0) ||
          Number(l.actualDistanceKmFromTrack ?? 0) ||
          undefined,
        confidence: Number(l.matchConfidence ?? 0) || undefined,
        estimatedPct: Number(l.estimatedPct ?? 0) || undefined,
        matchedPolylineEncoded:
          (l.matchedRoutePolylineEncoded as string | undefined) ??
          (legSegs.length
            ? legSegs.map((s) => s.polylineEncoded).join('|')
            : undefined),
        segments: legSegs,
      };
    });

    return {
      requestId,
      status: (latestJob?.status as MatchedRouteResultDto['status']) ?? 'ready',
      engine:
        (latestJob?.engine as string | undefined) ?? trip.matchEngine ?? undefined,
      matchedAt: trip.matchedAt?.toISOString(),
      officialDistanceKm:
        trip.officialDistanceKm ?? (roundKm(officialM) || undefined),
      provisionalDistanceKm: trip.provisionalDistanceKm,
      matchedPct: officialM > 0 ? (officialM - estimatedM) / officialM : 0,
      estimatedPct: officialM > 0 ? estimatedM / officialM : 0,
      legs,
      segments: mapped,
      error: latestJob?.error ? String(latestJob.error) : undefined,
    };
  }
}
