/**
 * Weather Service — Open-Meteo (free, no API key, CC BY 4.0).
 *
 * Fetches current + short forecast weather for a location, respecting the
 * user's GPS privacy precision. Caches per (region, day) ONCE PER CALENDAR DAY
 * (Bekky, 2026-08-23) — the forecast is fetched the first time the app opens
 * that day, then reused for the whole day. A true 5am background fetch needs a
 * native APK (expo-background-fetch), deferred to the notifications build.
 * NON-FATAL: if weather is unreachable, callers proceed without it.
 */
import * as FileSystem from 'expo-file-system';

import type { WeatherSnapshot } from '../../types/care';

const FORECAST_ENDPOINT = 'https://api.open-meteo.com/v1/forecast';
const ARCHIVE_ENDPOINT = 'https://archive-api.open-meteo.com/v1/archive';
const CACHE_DIR = `${FileSystem.documentDirectory || ''}pixiesprout-weather/`;
const FORECAST_DAYS = 7;
const REQUEST_TIMEOUT_MS = 10000;

/** GPS privacy precision — controls how much location detail we send. */
export type GpsPrecision = 'rough' | 'precise' | 'skip';

/** Round coords to ~1 mile (2 decimal places) for 'rough' privacy. */
function roundForPrivacy(lat: number, lon: number, precision: GpsPrecision) {
  if (precision === 'precise') return { lat, lon };
  // 'rough' and 'skip' both round to ~1 mile resolution.
  return { lat: Number(lat.toFixed(2)), lon: Number(lon.toFixed(2)) };
}

function cacheKey(lat: number, lon: number): string {
  return `${CACHE_DIR}${lat.toFixed(2)}_${lon.toFixed(2)}.json`;
}

async function readCache(lat: number, lon: number): Promise<WeatherSnapshot | null> {
  try {
    if (!FileSystem.documentDirectory) return null;
    const path = cacheKey(lat, lon);
    const info = await FileSystem.getInfoAsync(path);
    if (!info.exists) return null;
    const raw = await FileSystem.readAsStringAsync(path);
    const cached = JSON.parse(raw) as WeatherSnapshot;
    // Once-per-calendar-day cache (Bekky, 2026-08-23): the forecast is fetched
    // the first time the app opens that day, then reused for the whole day (not
    // a 6h TTL that would re-fetch mid-day). Compare by LOCAL date so a user at
    // GMT+7 (UTC date can lag) still gets a fresh fetch on their new day.
    if (!isSameLocalDay(cached.fetchedAt, new Date().toISOString())) return null;
    return cached;
  } catch {
    return null;
  }
}

/** True if two ISO timestamps fall on the same LOCAL calendar day. */
function isSameLocalDay(isoA: string, isoB: string): boolean {
  const a = new Date(isoA);
  const b = new Date(isoB);
  if (Number.isNaN(a.getTime()) || Number.isNaN(b.getTime())) return false;
  return a.getFullYear() === b.getFullYear()
    && a.getMonth() === b.getMonth()
    && a.getDate() === b.getDate();
}

async function writeCache(lat: number, lon: number, snapshot: WeatherSnapshot): Promise<void> {
  try {
    if (!FileSystem.documentDirectory) return;
    const info = await FileSystem.getInfoAsync(CACHE_DIR);
    if (!info.exists) {
      await FileSystem.makeDirectoryAsync(CACHE_DIR, { intermediates: true });
    }
    await FileSystem.writeAsStringAsync(cacheKey(lat, lon), JSON.stringify(snapshot));
  } catch {
    // Cache is best-effort — never fail the weather fetch on a cache write error.
  }
}

type OpenMeteoResponse = {
  current?: {
    temperature_2m?: number;
    apparent_temperature?: number;
    relative_humidity_2m?: number;
    wind_speed_10m?: number;
    weather_code?: number;
    is_day?: number;
  };
  daily?: {
    time?: string[];
    temperature_2m_max?: number[];
    temperature_2m_min?: number[];
    precipitation_sum?: number[];
    weather_code?: number[];
    relative_humidity_2m_max?: number[];
    wind_speed_10m_max?: number[];
    cloud_cover_mean?: number[];
  };
};

async function fetchFromOpenMeteo(lat: number, lon: number): Promise<WeatherSnapshot | null> {
  const params = new URLSearchParams({
    latitude: String(lat),
    longitude: String(lon),
    current: 'temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code,is_day',
    daily: 'temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code,relative_humidity_2m_max,wind_speed_10m_max,cloud_cover_mean',
    forecast_days: String(FORECAST_DAYS),
    timezone: 'auto',
  });

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const response = await fetch(`${FORECAST_ENDPOINT}?${params.toString()}`, {
      signal: controller.signal,
    });
    if (!response.ok) return null;
    const data = (await response.json()) as OpenMeteoResponse;
    if (!data.current) return null;

    const daily = (data.daily?.time || []).map((date, i) => ({
      date,
      tempMaxC: data.daily?.temperature_2m_max?.[i] ?? 0,
      tempMinC: data.daily?.temperature_2m_min?.[i] ?? 0,
      precipitationMm: data.daily?.precipitation_sum?.[i] ?? 0,
      weatherCode: data.daily?.weather_code?.[i] ?? 0,
      humidityPct: data.daily?.relative_humidity_2m_max?.[i],
      windSpeedKmh: data.daily?.wind_speed_10m_max?.[i],
      cloudCoverPct: data.daily?.cloud_cover_mean?.[i],
    }));

    return {
      temperatureC: data.current.temperature_2m ?? 0,
      feelsLikeC: data.current.apparent_temperature,
      humidityPct: data.current.relative_humidity_2m,
      windSpeedKmh: data.current.wind_speed_10m,
      weatherCode: data.current.weather_code ?? 0,
      isDay: data.current.is_day === 1,
      daily,
      fetchedAt: new Date().toISOString(),
    };
  } catch {
    return null;
  } finally {
    clearTimeout(timeoutId);
  }
}

/**
 * RAIN HISTORY (Bekky, 2026-09-01): fetch the ACTUAL observed precipitation for
 * the past N days from Open-Meteo's archive API. The forecast only looks
 * forward, so it can't know the soil is already soaked from three days of
 * rain. This gives the cadence the compounding picture — mm/day, how many days
 * in a row it rained, how many dry days in between. NEVER throws — returns
 * null on any failure (cadence reasons from forecast alone).
 */
export async function getRainHistory(
  latitude: number,
  longitude: number,
  days = 5
): Promise<WeatherSnapshot['rainHistory'] | null> {
  const end = new Date();
  end.setDate(end.getDate() - 1); // archive lags a day
  const start = new Date(end);
  start.setDate(start.getDate() - (days - 1));
  const fmt = (d: Date) => d.toISOString().slice(0, 10);
  const params = new URLSearchParams({
    latitude: String(latitude),
    longitude: String(longitude),
    daily: 'precipitation_sum',
    start_date: fmt(start),
    end_date: fmt(end),
    timezone: 'auto',
  });
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const response = await fetch(`${ARCHIVE_ENDPOINT}?${params.toString()}`, { signal: controller.signal });
    if (!response.ok) return null;
    const data = (await response.json()) as { daily?: { time?: string[]; precipitation_sum?: number[] } };
    const times = data.daily?.time;
    const precip = data.daily?.precipitation_sum;
    if (!times || !precip || times.length === 0) return null;
    return times.map((date, i) => ({ date, precipitationMm: precip[i] ?? 0 }));
  } catch {
    return null;
  } finally {
    clearTimeout(timeoutId);
  }
}

/**
 * Get a weather snapshot for a location, respecting GPS privacy precision.
 * Returns cached data if fresh; otherwise fetches from Open-Meteo.
 * NEVER throws — returns null on any failure (care proceeds without weather).
 */
/** Best-effort read of the once-per-calendar-day cached forecast (if any and
 *  still today's). Non-fatal. Used at app startup to hydrate care-task
 *  scheduling from the persisted cache BEFORE the network fetch resolves, so
 *  tasks don't compute "weather-less" on first render then snap when weather
 *  lands (Bekky, 2026-09-02 — the vanishing-upcoming-space fix). */
export async function readCachedWeather(latitude: number, longitude: number, precision: GpsPrecision = 'rough'): Promise<WeatherSnapshot | null> {
  const { lat, lon } = roundForPrivacy(latitude, longitude, precision);
  return readCache(lat, lon);
}

export async function getWeather(
  latitude: number,
  longitude: number,
  precision: GpsPrecision = 'rough'
): Promise<WeatherSnapshot | null> {
  const { lat, lon } = roundForPrivacy(latitude, longitude, precision);

  const cached = await readCache(lat, lon);
  if (cached) return cached;

  const snapshot = await fetchFromOpenMeteo(lat, lon);
  if (snapshot) {
    await writeCache(lat, lon, snapshot);
  }
  return snapshot;
}

/** Human-readable label for an Open-Meteo WMO weather code. */
export function describeWeatherCode(code: number): string {
  if (code === 0) return 'Clear';
  if (code === 1) return 'Mostly clear';
  if (code === 2) return 'Partly cloudy';
  if (code === 3) return 'Overcast';
  if (code >= 45 && code <= 48) return 'Foggy';
  if (code >= 51 && code <= 57) return 'Drizzle';
  if (code >= 61 && code <= 67) return 'Rain';
  if (code >= 71 && code <= 77) return 'Snow';
  if (code >= 80 && code <= 82) return 'Rain showers';
  if (code >= 85 && code <= 86) return 'Snow showers';
  if (code >= 95) return 'Thunderstorm';
  return 'Unknown';
}

/** Render a compact weather line for the AI context packet. */
export function renderWeatherLine(snapshot: WeatherSnapshot): string {
  const today = snapshot.daily[0];
  const parts = [
    `${snapshot.temperatureC.toFixed(0)}°C (feels ${(snapshot.feelsLikeC ?? snapshot.temperatureC).toFixed(0)}°C)`,
    describeWeatherCode(snapshot.weatherCode),
  ];
  if (snapshot.humidityPct != null) parts.push(`${snapshot.humidityPct}% humidity`);
  if (snapshot.windSpeedKmh != null) parts.push(`${snapshot.windSpeedKmh.toFixed(0)} km/h wind`);
  if (today) parts.push(`today high ${today.tempMaxC.toFixed(0)}°C / low ${today.tempMinC.toFixed(0)}°C`);
  return parts.join(', ');
}

const CLIMATE_ENDPOINT = 'https://climate-api.open-meteo.com/v1/climate';
/** Avg daily high at or above which a month counts as "hot" (matches reach default 28). */
const HOT_MONTH_C = 28;
/** Avg daily high at or below which a month counts as "cold" (matches reach default 12). */
const COLD_MONTH_C = 12;

/**
 * SEASON PROFILE (Bekky, 2026-08-24): derive which months (0-11) in a location's
 * climate are "hot" and "cold", from Open-Meteo's 30-year climate normals
 * (1991-2020), aggregated to per-month average daily high temps. Deterministic
 * (no AI) — this is the "climate scope" as a BACKEND correlation. Fetched once
 * when the garden location is set, stored on GardenLocation, re-fetched on reset.
 * NEVER throws — returns null on any failure (seasonModifier falls back to month).
 */
export async function getSeasonProfile(latitude: number, longitude: number): Promise<import('../../types/garden').SeasonProfile | null> {
  const params = new URLSearchParams({
    latitude: String(latitude),
    longitude: String(longitude),
    daily: 'temperature_2m_max',
    start_date: '1991-01-01',
    end_date: '2020-12-31',
    models: 'EC_Earth3P_HR',
  });
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const response = await fetch(`${CLIMATE_ENDPOINT}?${params.toString()}`, { signal: controller.signal });
    if (!response.ok) return null;
    const data = (await response.json()) as { daily?: { time?: string[]; temperature_2m_max?: number[] } };
    const times = data.daily?.time;
    const highs = data.daily?.temperature_2m_max;
    if (!times || !highs || times.length === 0) return null;
    // Aggregate per-month average daily high across the ~30 years.
    const monthSum: Record<number, { sum: number; n: number }> = {};
    for (let i = 0; i < times.length; i++) {
      const month = new Date(times[i]).getMonth();
      if (!monthSum[month]) monthSum[month] = { sum: 0, n: 0 };
      monthSum[month].sum += highs[i] ?? 0;
      monthSum[month].n += 1;
    }
    const hotMonths: number[] = [];
    const coldMonths: number[] = [];
    for (let m = 0; m < 12; m++) {
      const rec = monthSum[m];
      if (!rec || rec.n === 0) continue;
      const avg = rec.sum / rec.n;
      if (avg >= HOT_MONTH_C) hotMonths.push(m);
      if (avg <= COLD_MONTH_C) coldMonths.push(m);
    }
    return { hotMonths, coldMonths, fetchedAt: new Date().toISOString() };
  } catch {
    return null;
  } finally {
    clearTimeout(timeoutId);
  }
}

/** Human-readable month names. */
export function monthLabel(month: number): string {
  return ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][month] || String(month);
}

/**
 * WEATHER COMPRESSION (Bekky, 2026-09-01): distill the raw 7-day forecast into
 * a few horticulturally-meaningful signals the cadence + AI can reason from.
 * This is the "smart, not calculator" layer — it turns raw arrays into
 * "drying pressure" / "saturation risk" / "heat stress" / "cold-damp" states.
 * Rule-based, instant, free (no AI call). The AI then reasons LIVE from these
 * compressed signals + each plant's own pot/medium/stage — never a canned
 * pre-saved answer, never raw-array overwhelm. Shared by the cadence
 * (weatherModifier) and the AI care packet so both reason from the SAME state.
 */
export type WeatherSignals = {
  /** How strongly the air is drying the soil (0-3). Cloudy+humid+still = low. */
  dryingPressure: number;
  /** How saturated the soil is likely to be (0-3). Rain vs seasonal normal. */
  saturationRisk: number;
  /** COMPOUNDING wetness (0-3) from ACTUAL past rain (Bekky, 2026-09-01): the
   *  forecast only looks forward, so it can't know the soil is already soaked
   *  from three days of rain. This folds in the observed rain history — mm/day,
   *  how many days in a row it rained, how many dry days in between — so a
   *  week of daily rain reads as "already saturated" even if today is dry. */
  compoundingWetness: number;
  /** Heat stress active (heat streak / baseline temp high). */
  heatStress: boolean;
  /** Cold-damp active (cold + wet — soil won't dry, shock risk). */
  coldDamp: boolean;
  /** Stormy / strong-wind forecast (drives indoor device inference). */
  stormy: boolean;
  /** Human-readable summary for the AI packet. */
  summary: string;
};

export function compressWeatherSignals(weather: WeatherSnapshot): WeatherSignals {
  const week = weather.daily.slice(0, 7);
  if (week.length === 0) {
    return { dryingPressure: 1, saturationRisk: 0, compoundingWetness: 0, heatStress: false, coldDamp: false, stormy: false, summary: 'no forecast' };
  }
  // Drying pressure: sunniness (low cloud), low humidity, wind, warmth all dry.
  // Cloudy + humid + still = low drying pressure = soil stays wet.
  const avgCloud = week.reduce((s, d) => s + (d.cloudCoverPct ?? 50), 0) / week.length;
  const avgHumidity = week.reduce((s, d) => s + (d.humidityPct ?? 60), 0) / week.length;
  const avgWind = week.reduce((s, d) => s + (d.windSpeedKmh ?? 10), 0) / week.length;
  const avgHigh = week.reduce((s, d) => s + d.tempMaxC, 0) / week.length;
  let dryingPressure = 1; // baseline
  if (avgCloud < 30) dryingPressure += 1;      // sunny → dries
  if (avgHumidity < 50) dryingPressure += 1;  // dry air → dries
  if (avgWind > 20) dryingPressure += 1;       // windy → dries
  if (avgCloud > 70) dryingPressure -= 1;      // overcast → doesn't dry
  if (avgHumidity > 75) dryingPressure -= 1;   // humid → doesn't dry
  dryingPressure = Math.max(0, Math.min(3, dryingPressure));

  // Saturation risk: rain relative to the seasonal normal. Raining way more
  // than normal this week → soil saturated → stretch a lot. Without the
  // normal, fall back to absolute rain.
  const totalRain = week.reduce((s, d) => s + d.precipitationMm, 0);
  const normal = weather.monthlyRainNormalMm;
  let saturationRisk = 0;
  if (normal != null && normal > 0) {
    const ratio = totalRain / normal; // week's rain vs a full month's normal
    if (ratio >= 1.5) saturationRisk = 3;
    else if (ratio >= 0.8) saturationRisk = 2;
    else if (ratio >= 0.4) saturationRisk = 1;
  } else {
    if (totalRain >= 40) saturationRisk = 3;
    else if (totalRain >= 20) saturationRisk = 2;
    else if (totalRain >= 8) saturationRisk = 1;
  }

  // COMPOUNDING WETNESS (Bekky, 2026-09-01): the forecast only looks forward,
  // so it can't know the soil is already soaked from three days of rain. Fold
  // in the ACTUAL observed rain history — mm/day, how many days in a row it
  // rained, how many dry days in between. A week of daily rain reads as
  // "already saturated" even if today is dry. 0-3.
  let compoundingWetness = 0;
  const hist = weather.rainHistory;
  if (hist && hist.length > 0) {
    const recent = hist.slice(-5); // last 5 observed days
    const totalHistRain = recent.reduce((s, d) => s + d.precipitationMm, 0);
    // Consecutive rain days (most recent first) — the compounding factor.
    let streak = 0;
    for (let i = recent.length - 1; i >= 0; i--) {
      if (recent[i].precipitationMm >= 1) streak++;
      else break;
    }
    // Dry days since the last rain — recovery time.
    let drySince = 0;
    for (let i = recent.length - 1; i >= 0; i--) {
      if (recent[i].precipitationMm < 1) drySince++;
      else break;
    }
    if (totalHistRain >= 30 || streak >= 3) compoundingWetness = 3;
    else if (totalHistRain >= 15 || streak >= 2) compoundingWetness = 2;
    else if (totalHistRain >= 5 || streak >= 1) compoundingWetness = 1;
    // A long dry spell lets the soil recover even if it rained earlier.
    if (drySince >= 3) compoundingWetness = Math.max(0, compoundingWetness - 1);
  }

  // Heat stress: a heat streak (multiple hot days) or high baseline temp.
  const hotDays = week.filter(d => d.tempMaxC >= 32).length;
  const heatStress = hotDays >= 2 || avgHigh >= 30;

  // Cold-damp: cold + wet — soil won't dry, and cold wet soil can shock roots.
  const coldDays = week.filter(d => d.tempMaxC <= 12).length;
  const coldDamp = coldDays >= 1 && (totalRain >= 8 || avgHumidity > 70);

  // Stormy / strong wind: drives indoor device inference (windows closed,
  // fan less likely to run → less drying).
  const stormy = week.some(d => d.weatherCode >= 95) || avgWind > 30;

  const parts: string[] = [];
  parts.push(`drying pressure ${dryingPressure}/3`);
  parts.push(`saturation risk ${saturationRisk}/3`);
  if (compoundingWetness > 0) parts.push(`soil already wet from past rain (${compoundingWetness}/3)`);
  if (heatStress) parts.push('heat stress');
  if (coldDamp) parts.push('cold-damp');
  if (stormy) parts.push('stormy/windy');
  return { dryingPressure, saturationRisk, compoundingWetness, heatStress, coldDamp, stormy, summary: parts.join(', ') };
}

/**
 * MONTHLY RAIN NORMAL (Bekky, 2026-09-01): the 30-yr average precipitation (mm)
 * for the CURRENT month at a location. Lets the cadence read the forecast as a
 * DELTA from the seasonal normal — "raining way more than normal this week →
 * soil saturated → stretch a lot." Fetched from Open-Meteo's climate normals
 * (1991-2020), aggregated to the current month's average. NEVER throws —
 * returns null on any failure (cadence falls back to absolute rain, not delta).
 */
export async function getMonthlyRainNormal(latitude: number, longitude: number): Promise<number | null> {
  const params = new URLSearchParams({
    latitude: String(latitude),
    longitude: String(longitude),
    daily: 'precipitation_sum',
    start_date: '1991-01-01',
    end_date: '2020-12-31',
    models: 'EC_Earth3P_HR',
  });
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const response = await fetch(`${CLIMATE_ENDPOINT}?${params.toString()}`, { signal: controller.signal });
    if (!response.ok) return null;
    const data = (await response.json()) as { daily?: { time?: string[]; precipitation_sum?: number[] } };
    const times = data.daily?.time;
    const precip = data.daily?.precipitation_sum;
    if (!times || !precip || times.length === 0) return null;
    const nowMonth = new Date().getMonth();
    // Aggregate per-month average precipitation across the ~30 years.
    const monthSum: Record<number, { sum: number; n: number }> = {};
    for (let i = 0; i < times.length; i++) {
      const month = new Date(times[i]).getMonth();
      if (!monthSum[month]) monthSum[month] = { sum: 0, n: 0 };
      monthSum[month].sum += precip[i] ?? 0;
      monthSum[month].n += 1;
    }
    const rec = monthSum[nowMonth];
    if (!rec || rec.n === 0) return null;
    return rec.sum / rec.n;
  } catch {
    return null;
  } finally {
    clearTimeout(timeoutId);
  }
}

/**
 * Geocode a city/ZIP label to coordinates via Open-Meteo's free geocoding API
 * (no API key). Used for the Garden Location "City or ZIP" mode. NEVER throws —
 * returns null on any failure.
 */
export async function geocodeCityZip(label: string): Promise<{ latitude: number; longitude: number; name: string } | null> {
  const trimmed = label.trim();
  if (!trimmed) return null;
  const params = new URLSearchParams({
    name: trimmed,
    count: '1',
    language: 'en',
    format: 'json',
  });
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const response = await fetch(`https://geocoding-api.open-meteo.com/v1/search?${params.toString()}`, {
      signal: controller.signal,
    });
    if (!response.ok) return null;
    const data = (await response.json()) as { results?: Array<{ latitude: number; longitude: number; name: string; country?: string }> };
    const first = data.results?.[0];
    if (!first) return null;
    return {
      latitude: first.latitude,
      longitude: first.longitude,
      name: first.country ? `${first.name}, ${first.country}` : first.name,
    };
  } catch {
    return null;
  } finally {
    clearTimeout(timeoutId);
  }
}
