/**
 * Care Context Packet Builder — assembles a plant's real context
 * (environment, setup, placement, region, weather, indoor/outdoor, sun
 * exposure) into a compact prompt block for the AI.
 *
 * NON-FATAL: every piece is optional. If any context is missing, the packet
 * still builds with what's there — care never blocks on missing context.
 */
import type { CareContextPacket, RegionContext, WeatherSnapshot } from '../../types/care';
import type { RoomProfile, PlantSetupProfile } from '../../types/garden';
import type { SavedPlantProfile } from '../../types/plantScan';
import { renderRegionLine } from '../weather/climateService';
import { renderWeatherLine, compressWeatherSignals } from '../weather/weatherService';
import {
  asFiniteNumber,
  asInlineText,
  daysBetweenDateKeys,
  daysUntilLocalDate,
  formatDateKey,
  hashString,
  isRecord,
  normalizeDateKey,
  uniqueStrings,
} from './serviceUtils';

/**
 * CARE_LOGIC_VERSION — single source of truth for the care-logic salt (M18).
 * Bump this whenever the AI prompt's care logic changes in a way that SHOULD
 * refresh already-enriched plants (e.g. a new directive). It's salted into the
 * context-packet fingerprint so a bump forces exactly one re-fetch, then the
 * fingerprint stabilizes again. Keep in sync with the prompt logic in
 * plantEnrichment.ts / careMetricsUpdater.ts.
 */
export const CARE_LOGIC_VERSION = '9';

function quotedInline(value: unknown, maxLength = 1_000): string | undefined {
  const text = asInlineText(value, maxLength);
  return text ? JSON.stringify(text) : undefined;
}

function scalarInline(value: unknown, maxLength = 1_000): string | undefined {
  if (typeof value === 'number' && Number.isFinite(value)) return String(value).slice(0, maxLength);
  if (typeof value === 'boolean') return String(value);
  return asInlineText(value, maxLength);
}

const LIGHT_LABELS: Record<string, string> = {
  low_light: 'low light',
  medium_light: 'medium light',
  bright_indirect: 'bright indirect light',
  direct_sun: 'direct sun',
};

const HUMIDITY_LABELS: Record<string, string> = {
  dry: 'dry air',
  average: 'average humidity',
  humid: 'humid air',
};

const WINDOW_LABELS: Record<string, string> = {
  north: 'north-facing',
  south: 'south-facing',
  east: 'east-facing',
  west: 'west-facing',
  northeast: 'northeast-facing',
  northwest: 'northwest-facing',
  southeast: 'southeast-facing',
  southwest: 'southwest-facing',
  multiple: 'multiple windows',
  none: 'no window',
  unsure: 'unknown window direction',
};

const DISTANCE_LABELS: Record<string, string> = {
  windowsill: 'on the windowsill',
  one_to_three_ft: '1-3 ft from a window',
  four_to_six_ft: '4-6 ft from a window',
  six_plus_ft: '6+ ft from a window',
  not_applicable: 'not near a window',
  unsure: 'distance from window unknown',
};

const OUTDOOR_LIGHT_LABELS: Record<string, string> = {
  direct_sun: 'direct sun',
  filtered_light: 'filtered light',
  partial_shade: 'partial shade',
  full_shade: 'full shade',
  unsure: 'unknown outdoor light',
};

const INDOOR_OUTDOOR_LABELS: Record<string, string> = {
  indoor: 'indoor',
  outdoor: 'outdoor',
  public_park_trail: 'outdoor (park/trail)',
  garden_yard: 'outdoor (garden/yard)',
  public_spaces: 'outdoor (public space)',
  street_tree: 'outdoor (street tree)',
  nursery_store: 'nursery/store',
  home: 'at home',
  unsure: 'indoor/outdoor unknown',
};

function describeEnvironment(room?: RoomProfile): string | undefined {
  if (!room) return undefined;
  const parts: string[] = [];
  if (room.lightProfile) parts.push(LIGHT_LABELS[room.lightProfile] || room.lightProfile);
  if (room.humidityProfile) parts.push(HUMIDITY_LABELS[room.humidityProfile] || room.humidityProfile);
  if (Array.isArray(room.windowDirections) && room.windowDirections.length) {
    const dirs = room.windowDirections
      .filter((d: string) => d !== 'none' && d !== 'unsure')
      .map((d: string) => WINDOW_LABELS[d] || d);
    if (dirs.length) parts.push(dirs.join(' + '));
    else if (room.windowDirections.includes('none')) parts.push(WINDOW_LABELS.none);
    else if (room.windowDirections.includes('unsure')) parts.push(WINDOW_LABELS.unsure);
  }
  if (room.temperatureEstimate) parts.push(`${room.temperatureEstimate} temperature`);
  if (room.outdoorLightQuality) parts.push(OUTDOOR_LIGHT_LABELS[room.outdoorLightQuality] || room.outdoorLightQuality);
  if (Array.isArray(room.outdoorSunTimings) && room.outdoorSunTimings.length) {
    const timings = uniqueStrings(room.outdoorSunTimings);
    if (timings.length) parts.push(`sun: ${timings.join(', ')}`);
  }
  if (room.usesAmbientArtificialLight) parts.push('ambient artificial light');
  // Climate & air (Bekky, 2026-08-21): fold the space's climate devices +
  // window into the AI's picture so it tailors care to the real environment.
  const climate = describeClimateAir(room);
  if (climate) parts.push(climate);
  // Elevation + outdoor exposure (Bekky, 2026-08-23): tells the AI whether the
  // space is on a high floor (more sun/wind) and how covered it is (roof/walls/
  // facing change how weather actually reaches the plants).
  if (room.elevation && room.elevation !== 'unsure') {
    parts.push(ELEVATION_LABELS[room.elevation] || room.elevation);
  }
  const exposure = describeExposure(room);
  if (exposure) parts.push(exposure);
  return parts.length ? parts.join(', ') : undefined;
}

const ELEVATION_LABELS: Record<string, string> = {
  ground: 'at ground level',
  floor_1: 'on the 1st floor',
  floor_2_3: 'on an upper floor (2-3)',
  floor_4_plus: 'on a high floor (4+)',
};

function describeExposure(room: RoomProfile): string | undefined {
  const exp = room.exposure;
  if (!exp) return undefined;
  const parts: string[] = [];
  if (exp.roofType && exp.roofType !== 'unsure') {
    if (exp.roofType === 'glass') {
      parts.push(
        exp.glassTint === 'clear' || !exp.glassTint
          ? 'under a clear glass roof (rain blocked, heat traps inside)'
          : exp.glassTint === 'tinted' || exp.glassTint === 'frosted'
            ? 'under a UV-tinted/frosted glass roof (rain blocked, less light, gentler heat)'
            : 'under a glass roof'
      );
    } else {
      parts.push(
        exp.roofType === 'none' ? 'open to the sky'
        : exp.roofType === 'solid' ? 'under a solid roof (rain/sun blocked)'
        : exp.roofType === 'permeable' ? 'under a permeable lattice/vine covering (partial rain/sun, more wind)'
        : exp.roofType
      );
    }
  }
  if (exp.walls && exp.walls !== 'unsure' && exp.walls !== 'none') {
    parts.push(`${exp.walls} enclosed side(s)`);
  }
  if (Array.isArray(exp.openFaces) && exp.openFaces.length) {
    const faces = uniqueStrings(exp.openFaces);
    if (faces.length) parts.push(`open sides facing ${faces.join(' and ')}`);
  }
  return parts.length ? `exposure: ${parts.join(', ')}` : undefined;
}

/** Describe the space's climate devices + window for the AI (Bekky, 2026-08-21). */
function describeClimateAir(room: RoomProfile): string | undefined {
  const dev = room.climateDevices;
  const active: string[] = [];
  const freqText = (f: string | undefined) => f === 'all_the_time'
    ? 'all the time'
    : f === 'occasionally'
      ? 'occasionally'
      : 'never';
  // Reach (felt engagement) + heat type now reach the AI so care guidance can
  // reason about how a serious device affects the room's air, climate-relative.
  // 'only_extreme' is device-specific (A/C = extreme heat, heater = extreme cold).
  const reachText = (r: string | undefined, kind: 'airCon' | 'heater') =>
    r === 'never' ? 'does not have it' : r === 'only_extreme' ? (kind === 'airCon' ? 'only in extreme heat' : 'only in extreme cold') : r === 'when_bit_warm' ? 'at the slightest warmth/cold' : r === 'basically_always' ? 'basically always when its season is on' : 'when it is clearly hot/cold';
  const heatText = (h: string | undefined): string | undefined =>
    h === 'forced_air'
      ? 'forced-air'
      : h === 'woodstove_fireplace'
        ? 'woodstove/fireplace'
        : h === 'radiant'
          ? 'radiant'
          : undefined;
  const devReach = (d: unknown): string | undefined => {
    if (d && typeof d === 'object' && !Array.isArray(d)) {
      const o = d as { reach?: string; freq?: unknown };
      if (o.reach) return o.reach;
      const f = o.freq === 'all_the_time' ? 'basically_always' : o.freq === 'never' ? 'never' : undefined;
      if (f) return f;
      return undefined;
    }
    return d === true ? 'basically_always' : d === false ? 'never' : (d as string) === 'never' ? 'never' : undefined;
  };
  const devHeat = (d: unknown): string | undefined =>
    d && typeof d === 'object' && !Array.isArray(d) ? (d as { heatType?: string }).heatType : undefined;
  const normalizeFrequency = (value: unknown): 'all_the_time' | 'occasionally' | 'never' | undefined =>
    value === 'all_the_time' || value === 'occasionally' || value === 'never'
      ? value
      : undefined;
  const devFreq = (d: unknown): 'all_the_time' | 'occasionally' | 'never' | undefined =>
    d && typeof d === 'object' && !Array.isArray(d)
      ? normalizeFrequency((d as { freq?: unknown }).freq)
      : d === true
        ? 'all_the_time'
        : d === false
          ? 'never'
          : normalizeFrequency(d);
  const ac = dev?.airCon, heater = dev?.heater;
  const acReach = devReach(ac);
  const heaterReach = devReach(heater);
  const fanFrequency = devFreq(dev?.fan);
  const humidifierFrequency = devFreq(dev?.humidifier);
  if (ac && acReach && acReach !== 'never') active.push(`air con (reaches for it ${reachText(acReach, 'airCon')})`);
  if (heater && heaterReach && heaterReach !== 'never') {
    const type = heatText(devHeat(heater));
    active.push(`heater (reaches for it ${reachText(heaterReach, 'heater')}${type ? `, ${type} type` : ''})`);
  }
  if (fanFrequency && fanFrequency !== 'never') active.push(`fan (${freqText(fanFrequency)})`);
  if (humidifierFrequency && humidifierFrequency !== 'never') active.push(`humidifier (${freqText(humidifierFrequency)})`);
  const freq = room.windowOpenFrequency;
  const hasWindow = Array.isArray(room.windowDirections) && room.windowDirections.length
    ? !room.windowDirections.includes('none')
    : false;
  const bits: string[] = [];
  if (active.length) bits.push(`climate devices: ${active.join(', ')}`);
  if (hasWindow) {
    bits.push(
      freq === 'all_the_time'
        ? 'window opened all the time'
        : freq === 'occasionally'
          ? 'window opened occasionally'
          : freq === 'seldom'
            ? 'window opened seldom'
            : 'has a window (kept closed)',
    );
  }
  return bits.length ? bits.join('; ') : undefined;
}

// Pot types that are NOT a pot at all — the plant is planted directly in the
// ground / a bed. These must NEVER be rendered with the word "pot" or the AI
// tails care advice to a pot. NOTE: 'outdoor_container' is deliberately NOT
// here — it IS a container, so it must not trigger the "in the ground / not a
// pot" directive (H9). It keeps its own phrase below.
const IN_GROUND_POT_TYPES: ReadonlySet<string> = new Set([
  'ground',
  'raised_bed',
  'garden_bed',
]);

const POT_TYPE_PHRASES: Record<string, string> = {
  ground: 'planted directly in the ground',
  raised_bed: 'planted in a raised bed',
  garden_bed: 'planted in a garden bed',
  outdoor_container: 'in an outdoor container',
  // Everyday pot MATERIALS carry dry-down behavior so the AI can group (Bekky,
  // 2026-08-27, Chunk 2): terracotta is porous and dries fast; plastic/glazed
  // ceramic are non-porous and hold moisture. This feeds BOTH cohort grouping
  // AND per-plant cadence. A terracotta and a plastic pot of the same species
  // in the same space must NOT share a watering cadence.
  plastic: 'in a non-porous plastic pot that holds moisture much longer between waterings',
  ceramic: 'in a non-porous glazed ceramic pot that holds moisture much longer between waterings',
  terracotta: 'in a porous unglazed terracotta pot that wicks moisture and dries out faster',
  nursery_pot: 'in a thin nursery/grow pot that drains freely',
  self_watering: 'in a self-watering pot with a reservoir that keeps the soil consistently moist',
  hanging: 'in a hanging pot/basket that drains freely and dries out quickly',
  grow_bag: 'in a breathable fabric grow bag that drains fast and dries quickly',
  window_box: 'in a window box (shallow, dries out faster)',
  shared_planter: 'in a large/shared planter with multiple plants (competing for soil moisture)',
  custom: 'in a custom container',
  other: 'in a container',
  unsure: 'in a container',
};

// Human-readable watering-method phrases so the AI tailors water guidance to
// HOW the plant is actually watered (e.g. an automated sprinkler vs hand
// watering). The raw enum ("sprinkler") is too terse for the model to act on.
const WATERING_METHOD_PHRASES: Record<string, string> = {
  top_watering: 'watered from the top by hand',
  bottom_watering: 'watered from the bottom (tray)',
  watering_can: 'watered by hand with a watering can',
  hose: 'watered by hand with a hose',
  soak: 'watered by soaking',
  self_watering: 'watered by a self-watering system',
  wick: 'watered by a wick system',
  drip: 'watered by a drip irrigation system',
  sprinkler: 'watered by an automated sprinkler',
  mist: 'misted for humidity',
  humidifier: 'kept humid with a humidifier',
  custom: 'watered by a custom method',
};

// Growing-medium behavior phrases so the AI tailors water/fert cadence to how
// the medium RETAINS or DRAINS water (Bekky, 2026-08-27, Chunk 2). The bare
// enum name ("peat_moss medium") tells the AI what it is, not how it behaves;
// this carries the retention/drainage character. Feeds BOTH cohort grouping
// AND per-plant cadence — a perlite-heavy plant and a peat plant in the same
// space must NOT share a watering cadence.
const MEDIUM_TYPE_PHRASES: Record<string, string> = {
  soil: 'native soil',
  potting_mix: 'a balanced potting mix',
  potting_soil: 'potting soil (balanced, water-retentive)',
  general_purpose: 'a general-purpose potting mix',
  indoor_mix: 'an indoor potting mix',
  organic_all: 'an organic potting mix',
  moisture_control: 'a moisture-control mix that holds water longer',
  succulent_cactus: 'a sharply draining succulent/cactus mix that dries very fast',
  cactus_mix: 'a sharply draining cactus mix that dries very fast',
  seed_starting: 'a light seed-starting mix',
  orchid: 'a chunky free-draining orchid mix',
  orchid_bark: 'chunky free-draining orchid bark',
  vegetable: 'a vegetable/herb soil',
  acid_loving: 'an acid-loving mix (ericaceous)',
  garden_soil: 'native garden soil',
  coco_coir: 'coco coir (holds moisture, airy)',
  peat_moss: 'highly water-retentive peat moss',
  perlite: 'sharp-draining perlite that dries very fast',
  vermiculite: 'vermiculite that retains moisture and wicks water',
  sphagnum_moss: 'sphagnum moss that holds moisture around the roots',
  sand: 'fast-draining sand',
  sand_grit: 'coarse sand / horticultural grit (sharp drainage)',
  compost: 'rich compost (holds nutrients + some moisture)',
  pumice: 'free-draining pumice',
  lava_rock: 'free-draining lava rock',
  charcoal: 'charcoal (drainage, no water retention)',
  worm_castings: 'worm castings (rich, water-retentive)',
  bark_chips: 'chunky bark chips that drain freely',
  leca: 'LECA clay pebbles (no water retention; wicks from a reservoir)',
  water: 'grown in water only',
  hydroponic: 'a hydroponic/soil-less system',
};

function describeSetup(setup?: PlantSetupProfile): string | undefined {
  if (!setup) return undefined;
  const parts: string[] = [];
  const potType = setup.potType;
  if (potType) {
    if (IN_GROUND_POT_TYPES.has(potType)) {
      // Ground/bed planting — say what it IS, never "ground pot".
      parts.push(POT_TYPE_PHRASES[potType] || `planted in ${potType.replace(/_/g, ' ')}`);
    } else if (POT_TYPE_PHRASES[potType]) {
      // Everyday pot material/type — carry the dry-down behavior (Bekky, 2026-08-27,
      // Chunk 2) so the AI knows terracotta dries fast vs plastic/glazed hold
      // moisture. Feeds BOTH cohort grouping AND per-plant cadence.
      parts.push(POT_TYPE_PHRASES[potType]);
    } else {
      parts.push(`${potType.replace(/_/g, ' ')} pot`);
    }
  }
  // Skip meaningless pot-size/drainage values for ground/bed plants (stored as
  // 'not_applicable') so we never send the AI "not_applicable size".
  if (setup.potSize && setup.potSize !== 'not_applicable') parts.push(`${setup.potSize} size`);
  if (setup.drainage && setup.drainage !== 'not_applicable') parts.push(setup.drainage === 'has_holes' ? 'drainage holes' : setup.drainage === 'no_holes' ? 'no drainage holes' : setup.drainage);
  if (setup.mediumType) {
    parts.push(MEDIUM_TYPE_PHRASES[setup.mediumType] || `${setup.mediumType.replace(/_/g, ' ')} medium`);
  }
  // Custom-mix components (Bekky, 2026-08-27, Chunk 2) — each part carries its
  // own retention/drainage so the AI can judge the blend (e.g. perlite-heavy).
  if (Array.isArray(setup.customMediumComponents) && setup.customMediumComponents.length) {
    const phrased = uniqueStrings(setup.customMediumComponents)
      .map((c) => MEDIUM_TYPE_PHRASES[c] || c.replace(/_/g, ' '))
      .filter(Boolean);
    if (phrased.length) parts.push(`custom mix of ${phrased.join(', ')}`);
  }
  if (setup.wateringMethod) {
    const phrase = WATERING_METHOD_PHRASES[setup.wateringMethod];
    if (phrase) parts.push(phrase);
  }
  // Setup text boxes (Bekky, 2026-08-30 "wire the straws" — row 7 family):
  // the gardener's OWN typed words on watering / dressing / mix, previously
  // stored but never shown to any AI kitchen. Render only when non-empty.
  const customWatering = quotedInline(setup.customWatering);
  const customMedium = quotedInline(setup.customMedium);
  const customTopDressing = quotedInline(setup.customTopDressing);
  const fertilizer = quotedInline(setup.fertilizer);
  if (customWatering) parts.push(`specific watering notes: ${customWatering}`);
  if (customMedium) parts.push(`mix description: ${customMedium}`);
  if (customTopDressing) parts.push(`top dressing: ${customTopDressing}`);
  if (fertilizer) {
    // FERTILIZER (Bekky, 2026-09-02): the user's reported feed — mark it as a
    // LOCAL product the user said they use, and judge it against the region,
    // not a generic assumption. A regional/branded or location-specific feed is
    // common ("what everyone uses here"); the AI should treat it as authoritative
    // for this plant and not second-guess it to a generic NPK.
    parts.push(`fertilizer the user uses: ${fertilizer} (their local/regional product — treat as valid for this climate/location)`);
  }
  if (setup.usesDedicatedArtificialLight) {
    const hours = asFiniteNumber(setup.dedicatedArtificialLightHoursPerDay);
    parts.push(hours !== null && hours > 0
      ? `dedicated grow light ${Math.round(hours * 10) / 10}h/day`
      : 'dedicated grow light');
  }
  return parts.filter(Boolean).join(', ') || undefined;
}

// Proximity levels → natural phrases for the AI (Bekky, 2026-08-22, placement pass).
const PROXIMITY_PHRASES: Record<string, string> = {
  right_next_to: 'right next to',
  nearby: 'nearby',
  across_room: 'across the room',
};
const CLIMATE_DEVICE_PHRASES: Record<string, string> = {
  airCon: 'the air conditioning',
  heater: 'the heater',
  fan: 'the fan',
  humidifier: 'the humidifier',
};

/** Room-type character the AI should weigh (Bekky, 2026-08-22). Maps the app's
 * space types to a short, natural phrase that captures how that kind of space
 * tends to behave for plants (e.g. a bathroom gets steamy). */
const ROOM_TYPE_PHRASES: Record<string, string> = {
  bathroom: 'a bathroom (gets steamy from showers)',
  kitchen: 'a kitchen (steam + cooking heat, airflow near appliances)',
  living_room: 'a living room',
  bedroom: 'a bedroom',
  office: 'an office',
  nursery: 'a plant nursery (many plants together)',
  balcony: 'a balcony (outdoor)',
  patio: 'a patio (outdoor)',
  greenhouse: 'a greenhouse (high humidity, strong sun)',
  grow_tent: 'a grow tent (controlled environment, often high humidity)',
  windowsill: 'a windowsill (bright, close to glass)',
  propagation_station: 'a propagation station (high humidity)',
  seed_starting_area: 'a seed-starting area (often under grow lights)',
  garden: 'a garden (outdoor, in the ground)',
  orchard: 'an orchard (outdoor, trees)',
};

function describePlacement(plant?: SavedPlantProfile, room?: RoomProfile): string | undefined {
  const parts: string[] = [];
  // Room TYPE first — the character of the space (bathroom steam, greenhouse
  // humidity, grow-tent enclosure) is what most shapes care (Bekky, 2026-08-22).
  const roomType = room?.spaceType ? ROOM_TYPE_PHRASES[room.spaceType] : undefined;
  if (roomType) parts.push(roomType);
  // Proximity to the window + climate devices the room actually has.
  const prox = plant?.placementProximity;
  if (prox) {
    if (prox.window && prox.window !== 'unsure') parts.push(`${PROXIMITY_PHRASES[prox.window] || prox.window} the window`);
    for (const key of ['airCon', 'heater', 'fan', 'humidifier'] as const) {
      const level = prox[key];
      const phrase = CLIMATE_DEVICE_PHRASES[key];
      if (level && level !== 'unsure' && phrase) {
        parts.push(`${PROXIMITY_PHRASES[level] || level} ${phrase}`);
      }
    }
  }
  return parts.length ? parts.join(', ') : undefined;
}

function describeSunExposure(room?: RoomProfile, setup?: PlantSetupProfile): string | undefined {
  const parts: string[] = [];
  if (room?.lightProfile) parts.push(LIGHT_LABELS[room.lightProfile] || room.lightProfile);
  if (room?.outdoorLightQuality) parts.push(OUTDOOR_LIGHT_LABELS[room.outdoorLightQuality] || room.outdoorLightQuality);
  if (setup?.distanceFromWindow) parts.push(DISTANCE_LABELS[setup.distanceFromWindow] || setup.distanceFromWindow);
  return parts.length ? parts.join(', ') : undefined;
}

/**
 * Build the context packet for a plant. All inputs optional — the packet
 * assembles whatever context is available.
 */
export function buildCareContextPacket(input: {
  plant: SavedPlantProfile;
  room?: RoomProfile;
  setup?: PlantSetupProfile;
  region?: RegionContext;
  weather?: WeatherSnapshot;
}): CareContextPacket {
  if (!input?.plant) return { plantName: 'Plant' } as CareContextPacket;
  try {
    const { plant, room, setup, region, weather } = input;
    let regionLine: string | undefined;
    let weatherLine: string | undefined;
    try {
      regionLine = region ? renderRegionLine(region) : undefined;
    } catch {
      regionLine = undefined;
    }
    try {
      weatherLine = weather ? renderWeatherLine(weather) : undefined;
    } catch {
      weatherLine = undefined;
    }
    const plantNote = asInlineText(plant.notes, 2_000) || asInlineText(plant.manualNotes, 2_000);
    const spaceNote = asInlineText(room?.notes, 2_000);
    const treatmentPreferences = uniqueStrings(
      Array.isArray(plant.treatmentPreferences) ? plant.treatmentPreferences : [],
    )
      .map((preference) => TREATMENT_PREF_LABELS[preference] || preference.replace(/_/g, ' '));
    const locationContext = asInlineText(plant.locationContext, 200);
    return {
      plantName: asInlineText(plant.name, 200) || asInlineText(plant.commonName, 200) || 'Plant',
      scientificName: asInlineText(plant.scientificName, 200),
      indoorOutdoor: locationContext
        ? INDOOR_OUTDOOR_LABELS[locationContext] || locationContext
        : undefined,
      environment: describeEnvironment(room),
      setup: describeSetup(setup),
      placement: describePlacement(plant, room),
      region: regionLine,
      weather: weatherLine,
      sunExposure: describeSunExposure(room, setup),
      growthStage: asInlineText(plant.growthStage, 200),
      careDifficulty: asInlineText(plant.careDifficulty, 200),
      // Root-inference raw signals (Bekky, 2026-08-27, Chunk 2): pass the facts
      // the AI can't guess — how long the user has owned the plant + how long
      // since its last repot. The AI infers root type + "as above, so below"
      // size from species + photos; these two are user-known facts it needs.
      timeOwned: asInlineText(plant.timeOwned, 200),
      timeSinceRepot: describeTimeSinceRepot(plant),
      // GARDENER CONTEXT SHELVES (Bekky, 2026-08-30 "wire the straws"):
      notes: plantNote,
      spaceNotes: spaceNote,
      careWords: describeCareWords(plant),
      lightNeeds: asInlineText(plant.lightNeeds, 1_000),
      treatmentPreferences: treatmentPreferences.length ? treatmentPreferences.join(', ') : undefined,
      safetyNote: describeSafetyNote(plant),
      diary: describeDiary(plant),
      weekAhead: describeWeekAhead(weather?.daily),
      // SEED PROFILE (Bekky, 2026-09-08): for a plant grown from seed, fold the
      // rich seed identification data into the packet so the AI care call guides
      // care from day one for THIS seed's particular needs + timeline.
      seed: describeSeed(plant),
    };
  } catch {
    return {
      plantName: asInlineText(input.plant.name, 200)
        || asInlineText(input.plant.commonName, 200)
        || 'Plant',
    } as CareContextPacket;
  }
}

/** Build a terse seed-profile line for a plant grown from seed (Bekky,
 *  2026-09-08). Pulls the germination timeline + key facts from the seed's AI
 *  identification result so the care call knows when to expect germination,
 *  how to water a seed vs a seedling, and what it will grow into. */
function describeSeed(plant: SavedPlantProfile): string | undefined {
  const seed = plant.seed;
  if (!seed) return undefined;
  const bits: string[] = [];
  const daysToGerminate = scalarInline(seed.daysToGerminate, 200);
  if (daysToGerminate) bits.push(`germinates in ~${daysToGerminate}`);
  const germinationRate = scalarInline(seed.germinationRate, 200);
  if (germinationRate) bits.push(`germination rate: ${germinationRate}`);
  const seedType = scalarInline(seed.seedType, 200);
  if (seedType) bits.push(`seed type: ${seedType}`);
  const plantingDepth = scalarInline(seed.plantingDepth, 200);
  if (plantingDepth) bits.push(`planting depth: ${plantingDepth}`);
  const lightRequirement = scalarInline(seed.lightRequirement, 300);
  if (lightRequirement) bits.push(`light: ${lightRequirement}`);
  const temperatureRange = scalarInline(seed.temperatureRange, 200);
  if (temperatureRange) bits.push(`temp: ${temperatureRange}`);
  const moisture = scalarInline(seed.moisture, 300);
  if (moisture) bits.push(`moisture: ${moisture}`);
  if (seed.stratification?.needed) bits.push(`stratify: ${scalarInline(seed.stratification.duration, 200) || 'yes'}`);
  if (seed.scarification?.needed) bits.push(`scarify: yes`);
  const expectedOutcome = scalarInline(seed.expectedOutcome, 500);
  if (expectedOutcome) bits.push(`grows into: ${expectedOutcome}`);
  const timeToHarvest = scalarInline(seed.timeToHarvest, 200);
  if (timeToHarvest) bits.push(`time to harvest: ${timeToHarvest}`);
  const timeToFruit = scalarInline(seed.timeToFruit, 200);
  if (timeToFruit) bits.push(`time to fruit: ${timeToFruit}`);
  if (seed.comesTrueFromSeed === false) bits.push('does NOT come true from seed');
  return bits.length ? bits.join(', ') : undefined;
}

/** Humanize the time since the plant's most recent repot (Bekky, 2026-08-27,
 *  Chunk 2). Fresh soil holds water differently than a root-bound pot, so the
 *  cadence must change after a repot. Derived from the newest 'repotting' plant
 *  event — no new stored field needed. Returns a short phrase or undefined. */
function describeTimeSinceRepot(plant: SavedPlantProfile): string | undefined {
  const events = plant.events || plant.plantEvents;
  if (!Array.isArray(events)) return undefined;
  const repotDates = events
    .filter((event) => event && !event.isDeleted && event.type === 'repotting')
    .map((event) => normalizeDateKey(event.eventDate))
    .filter((date): date is string => Boolean(date) && (daysUntilLocalDate(date) ?? 1) <= 0)
    .sort()
    .reverse();
  const latestDate = repotDates[0];
  if (!latestDate) return undefined;
  const until = daysUntilLocalDate(latestDate);
  if (until === null || until > 0) return undefined;
  const days = Math.max(0, -until);
  if (days === 0) return `repotted today (${latestDate}; fresh soil holds water differently)`;
  if (days < 30) return `repotted ${days} day${days === 1 ? '' : 's'} ago (${latestDate}; fresh soil holds water differently)`;
  const months = Math.floor(days / 30);
  if (months < 24) return `repotted ${months} month${months === 1 ? '' : 's'} ago (${latestDate})`;
  const years = Math.floor(months / 12);
  return `repotted ${years} year${years === 1 ? '' : 's'} ago (${latestDate})`;
}

/** Render the packet as a compact prompt block for the AI. */
export function renderCareContextPacket(packet: CareContextPacket): string {
  if (!packet) return '';
  try {
    const lines: string[] = [];
    if (packet.plantName) lines.push(`Plant: ${packet.plantName}`);
    if (packet.scientificName) lines.push(`Species: ${packet.scientificName}`);
    if (packet.indoorOutdoor) lines.push(`Setting: ${packet.indoorOutdoor}`);
    if (packet.environment) lines.push(`Environment: ${packet.environment}`);
    if (packet.setup) lines.push(`Setup: ${packet.setup}`);
    if (packet.placement) lines.push(`Placement: ${packet.placement}`);
    if (packet.sunExposure) lines.push(`Sun exposure: ${packet.sunExposure}`);
    if (packet.region) lines.push(`Region: ${packet.region}`);
    if (packet.weather) lines.push(`Weather: ${packet.weather}`);
    if (packet.growthStage) lines.push(`Growth stage: ${packet.growthStage}`);
    if (packet.seed) lines.push(`Seed profile: ${packet.seed}`);
    if (packet.timeOwned) lines.push(`Time owned: ${packet.timeOwned}`);
    if (packet.timeSinceRepot) lines.push(packet.timeSinceRepot);
    if (packet.rootType) lines.push(`Root type: ${packet.rootType}`);
    if (packet.plantSizeRatio) lines.push(`Plant size ratio (as-above-so-below): ${packet.plantSizeRatio}`);
    if (packet.careDifficulty) lines.push(`Care difficulty: ${packet.careDifficulty}`);
    // GARDENER CONTEXT SHELVES (Bekky, 2026-08-30 "wire the straws"):
    if (packet.safetyNote) lines.push(`Safety: ${packet.safetyNote}`);
    if (packet.treatmentPreferences) lines.push(`Treatment preferences: ${packet.treatmentPreferences}`);
    if (packet.lightNeeds) lines.push(`Species light needs: ${packet.lightNeeds}`);
    if (packet.notes) lines.push(`Gardener's plant note: ${packet.notes}`);
    if (packet.spaceNotes) lines.push(`Space notes: ${packet.spaceNotes}`);
    if (packet.careWords) lines.push(`Care feedback words: ${packet.careWords}`);
    if (packet.diary) lines.push(`Recent events: ${packet.diary}`);
    if (packet.weekAhead) lines.push(packet.weekAhead);
    return lines.join('\n');
  } catch {
    return '';
  }
}

// ============================================================================
// TWO-TIER PACKETS (Bekky, 2026-08-27) — cohort grouping vs cohort care.
//
// Cohort GROUPING only needs a compact "care signature" per plant: the fields
// that determine whether plants genuinely share a care rhythm (root type, pot
// dry-down, medium retention, light, stage, time owned, time since repot). A
// short, signal-dense signature is FASTER (fits the AI timeout), cheaper, and
// makes the model focus on dry-down classes instead of wading through prose.
//
// Cohort CARE REPLACES per-plant care, so it must be HIGH QUALITY — it keeps
// the rich care-relevant fields, but scoped to the action (water vs fertilize)
// so each call reasons about ONE concern, not both at once. This is the
// "build on the previous call, break into chunks" pattern: grouping finds the
// cohorts, then one care call per cohort per action builds the cadence.
// ============================================================================

/** Compact pot dry-down label — the single most important grouping signal.
 *  Terracotta/porous dries fast; plastic/glazed holds moisture; ground dries
 *  by soil. Returns a short phrase or undefined. */
function dryDownLabel(potType: string | undefined): string | undefined {
  if (typeof potType !== 'string') return undefined;
  switch (potType) {
    case 'terracotta': return 'terracotta (dries fast)';
    case 'plastic': return 'plastic (holds moisture)';
    case 'ceramic': return 'glazed ceramic (holds moisture)';
    case 'nursery_pot': return 'nursery pot (drains freely)';
    case 'hanging': return 'hanging (drains fast)';
    case 'grow_bag': return 'grow bag (drains fast)';
    case 'window_box': return 'window box (shallow, dries fast)';
    case 'self_watering': return 'self-watering (keeps moist)';
    case 'shared_planter': return 'shared planter (competing)';
    case 'ground': return 'in-ground (soil drains)';
    case 'raised_bed': return 'raised bed';
    case 'garden_bed': return 'garden bed';
    case 'outdoor_container': return 'outdoor container';
    default: return potType && potType !== 'not_applicable'
      ? `${potType.replace(/_/g, ' ')} pot`
      : undefined;
  }
}

/** Compact medium retention label — the second key grouping signal. */
function mediumRetentionLabel(mediumType: string | undefined, customComponents: string[] | undefined): string | undefined {
  if (Array.isArray(customComponents) && customComponents.length) {
    const drain = customComponents.some(c => ['perlite', 'sand', 'sand_grit', 'pumice', 'lava_rock', 'charcoal', 'bark_chips'].includes(c));
    const retain = customComponents.some(c => ['peat_moss', 'coco_coir', 'vermiculite', 'worm_castings', 'compost', 'sphagnum_moss', 'potting_soil'].includes(c));
    if (drain && retain) return 'custom mix (balanced retention)';
    if (drain) return 'custom mix (fast-draining)';
    if (retain) return 'custom mix (water-retentive)';
    return 'custom mix';
  }
  if (typeof mediumType !== 'string') return undefined;
  switch (mediumType) {
    case 'cactus_mix': case 'succulent_cactus': case 'perlite': case 'sand': case 'sand_grit': case 'pumice': case 'lava_rock': case 'charcoal': case 'bark_chips': case 'orchid': case 'orchid_bark': case 'leca':
      return 'fast-draining medium';
    case 'peat_moss': case 'coco_coir': case 'vermiculite': case 'sphagnum_moss': case 'worm_castings': case 'compost': case 'moisture_control':
      return 'water-retentive medium';
    case 'water': return 'grown in water';
    case 'hydroponic': return 'hydroponic';
    default: return mediumType ? `${mediumType.replace(/_/g, ' ')} medium` : undefined;
  }
}

/** Compact light label for grouping — light class drives water need. */
function lightLabel(room?: RoomProfile, setup?: PlantSetupProfile): string | undefined {
  const l = room?.lightProfile
    ? LIGHT_LABELS[room.lightProfile] || room.lightProfile.replace(/_/g, ' ')
    : undefined;
  const o = room?.outdoorLightQuality
    ? OUTDOOR_LIGHT_LABELS[room.outdoorLightQuality] || room.outdoorLightQuality.replace(/_/g, ' ')
    : undefined;
  const distance = setup?.distanceFromWindow
    ? DISTANCE_LABELS[setup.distanceFromWindow] || setup.distanceFromWindow.replace(/_/g, ' ')
    : undefined;
  return l || o || distance;
}

/**
 * Compact cohort GROUPING signature — a single short line per plant with only
 * the fields that determine whether plants genuinely share a care rhythm.
 * Fast + focused: the model compares dry-down / root / light classes instead
 * of parsing verbose environment prose. Non-fatal (any missing field is just
 * omitted — the model works with what it has).
 */
export function buildCohortSignature(input: {
  plant: SavedPlantProfile;
  room?: RoomProfile;
  setup?: PlantSetupProfile;
  weather?: WeatherSnapshot;
  /** id → display name for this space's plants (powers the neighbors line). */
  neighborNames?: Map<string, string>;
}): string {
  if (!input?.plant) return 'plant';
  try {
    const { plant, room, setup, weather, neighborNames } = input;
    const bits: string[] = [];
    const displayName = asInlineText(plant.name, 200) || asInlineText(plant.commonName, 200) || asInlineText(plant.scientificName, 200) || 'plant';
    bits.push(displayName);
    const scientificName = asInlineText(plant.scientificName, 200);
    if (scientificName && scientificName !== displayName) bits.push(scientificName);
    const dd = dryDownLabel(setup?.potType);
    if (dd) bits.push(dd);
    const med = mediumRetentionLabel(setup?.mediumType, setup?.customMediumComponents);
    if (med) bits.push(med);
    const light = lightLabel(room, setup);
    if (light) bits.push(light);
    // GROUPING DELIVERY FIX (Bekky, 2026-08-30 "wire the straws" rows 14-16):
    // the grouping prompt PROMISES weather/season/placement — the signature now
    // delivers them, kept terse so the big space blast stays lean (captions,
    // not paragraphs — the grouping stays the lean blast by the ratio law).
    if (room) {
      const placement = describePlacement(plant, room);
      if (placement) bits.push(`placement: ${placement}`);
      const exposure = describeExposure(room);
      if (exposure) bits.push(exposure);
    }
    if (weather) {
      try {
        const weatherLine = renderWeatherLine(weather);
        if (weatherLine) bits.push(`today: ${weatherLine}`);
      } catch {
        // Weather context is optional; malformed weather must not block grouping.
      }
    }
    bits.push(`season: ${currentSeasonLabel()}`);
    // GARDENER'S MIX WORDS (Bekky, 2026-08-30 "probably for grouping"): the
    // typed mix description is the one setup note that directly bears on dry-down
    // class, so it joins the LEAN signature — truncated; the full text rides the
    // CARE packets. customWatering/customTopDressing stay care-only.
    const customMix = asInlineText(setup?.customMedium, 61);
    if (customMix) {
      const clipped = customMix.length > 60 ? `${customMix.slice(0, 60)}…` : customMix;
      bits.push(`mix: ${JSON.stringify(clipped)}`);
    }
    const growthStage = asInlineText(plant.growthStage, 200);
    const timeOwned = asInlineText(plant.timeOwned, 200);
    const careDifficulty = asInlineText(plant.careDifficulty, 200);
    if (growthStage) bits.push(`stage: ${growthStage.replace(/_/g, ' ')}`);
    if (timeOwned) bits.push(`owned: ${timeOwned.replace(/_/g, ' ')}`);
    if (careDifficulty) bits.push(`care: ${careDifficulty}`);
    // time since repot — fresh soil holds water differently
    const repot = describeTimeSinceRepot(plant);
    if (repot) bits.push(repot);
    // RHYTHM LINES (Bekky, 2026-08-30, Chunk B core §B2): the merge decision is
    // RHYTHM compatibility (how often the plant actually needs care), never
    // PHASE alignment (whether it's due the same day). Give the AI both facts
    // so it can group phase-misaligned plants whose rhythms genuinely overlap.
    const sched = Array.isArray(plant.careSchedule?.scheduled) ? plant.careSchedule.scheduled : [];
    const cadenceBits: string[] = [];
    for (const item of sched) {
      if (item.action !== 'water' && item.action !== 'fertilize') continue;
      const days = asFiniteNumber(item.everyDays);
      if (days === null || days <= 0) continue;
      cadenceBits.push(`${item.action} baseline every ${Math.max(1, Math.round(days))} days`);
    }
    if (cadenceBits.length) bits.push(`cadence: ${cadenceBits.join(', ')}`);
    const lastDone = plant.careSchedule?.lastDone || {};
    const recency = (key?: string) => {
      const until = daysUntilLocalDate(key);
      if (until === null || until > 0) return undefined;
      const days = -until;
      return days === 0 ? 'today' : `${days} days ago`;
    };
    const recencyBits: string[] = [];
    const rw = recency(lastDone.water); if (rw) recencyBits.push(`watered ${rw}`);
    const rf = recency(lastDone.fertilize); if (rf) recencyBits.push(`fed ${rf}`);
    if (recencyBits.length) bits.push(`history: ${recencyBits.join(', ')}`);
    // NEIGHBOR PLANTS (Bekky, 2026-08-30 "wire the straws" row 14): stored
    // symmetric on the plant, never previously shown to any AI. Grouping needs
    // to know who shares shelf air/moisture — render names, terse.
    if (Array.isArray(plant.neighborPlantIds) && plant.neighborPlantIds.length && neighborNames?.size) {
      const names = uniqueStrings(plant.neighborPlantIds
        .filter((id: string) => id !== plant.id)
        .map((id: string) => neighborNames.get(id))
        .filter((n: string | undefined): n is string => Boolean(n)));
      if (names.length) bits.push(`neighbors: ${names.join(', ')}`);
    }
    return bits.join('; ');
  } catch {
    return asInlineText(input.plant.name, 200)
      || asInlineText(input.plant.commonName, 200)
      || 'plant';
  }
}

/**
 * ACTION-SCOPED rich care packet — used for cohort CARE (which replaces
 * per-plant care, so it must be high quality). Keeps the rich care-relevant
 * fields but scoped to ONE action so the call reasons about watering OR
 * feeding, not both. Water needs pot dry-down / medium / light / weather;
 * fertilize needs growth stage / feeder class / medium / season.
 */
/**
 * PARITY LAW season helper (Bekky, 2026-08-30): cohort member lines need the
 * season word, but importing currentSeason from services/plantEnrichment would
 * create an import cycle (plantEnrichment already imports from this module) —
 * so the identical tiny month→season mapping lives here, cycle-free.
 */
function currentSeasonLabel(): string {
  const month = new Date().getMonth();
  const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
  const northern = month >= 2 && month <= 4
    ? 'spring'
    : month >= 5 && month <= 7
      ? 'summer'
      : month >= 8 && month <= 10
        ? 'autumn'
        : 'winter';
  const southern = northern === 'spring'
    ? 'autumn'
    : northern === 'summer'
      ? 'winter'
      : northern === 'autumn'
        ? 'spring'
        : 'summer';
  return `${monthNames[month]} (${northern} in the Northern Hemisphere; ${southern} in the Southern Hemisphere)`;
}

/**
 * GARDENER SHELF HELPERS (Bekky, 2026-08-30 "wire the straws").
 */
export const TREATMENT_PREF_LABELS: Record<string, string> = {
  strictly_organic: 'strictly organic products only',
  diy_first: 'DIY/home-remedy first',
  store_bought_organic: 'store-bought organic ok',
  chemical_escalation_opt_in: 'chemical options only if gardener opts in',
};

/** Dry/wet nudge → the user's own words for the AI (Lane E). Sign meaning:
 *  -1 = plant dries FASTER than the schedule assumed (shorten), +1 = holds
 *  water LONGER (stretch). Read the WORDS from the newest care-log entry. */
function describeCareWords(plant: SavedPlantProfile): string | undefined {
  const log = plant.careSchedule?.careLog;
  if (!Array.isArray(log) || !log.length) return undefined;
  const phrases: string[] = [];
  const latest = log[log.length - 1];
  const adj = plant.careSchedule?.feedbackAdjustment;
  const stateAtComplete = asInlineText(latest?.stateAtComplete, 300);
  if (latest?.path === 'complete' && stateAtComplete) {
    phrases.push(`last check-in: soil was ${JSON.stringify(stateAtComplete)}`);
  }
  if (adj?.water === -1) phrases.push('gardener reports it dries FASTER than the cadence assumes — consider watering sooner/more');
  else if (adj?.water === 1) phrases.push('gardener reports it holds water LONGER than the cadence assumes — consider watering less/less often');
  if (adj?.fertilize === 1) phrases.push('fertilizer: last feed found it not ready (dormant/slow) — stretch feeding');
  return phrases.length ? phrases.join('; ') : undefined;
}

/** Pet/child safety in the AI's own words (Lane D): never a bare enum. */
function describeSafetyNote(plant: SavedPlantProfile): string | undefined {
  const parts: string[] = [];
  const toxicityWarning = asInlineText(plant.toxicityWarning, 1_000);
  if (toxicityWarning) parts.push(toxicityWarning);
  if (plant.petChildCaution === true || plant.petChildCaution === 'yes') {
    parts.push('Pet/child caution: keep out of reach of pets and children');
  }
  if (plant.petChildCaution === 'unsure') parts.push('Pet/child safety unconfirmed — verify before placement advice');
  return parts.join('; ') || undefined;
}

/** EVENT DIARY (Bekky, 2026-08-30 "wire the straws" row 9): care passes used to
 *  read ONLY repots from the plant's event history. Feed the newest 3 diary
 *  events (pests, yellow leaves, new growth, treatments…) as terse captions —
 *  repots excluded (they already get their own dedicated line). Terse by the
 *  ratio law: type + how long ago + optional short note. */
const DIARY_SKIP_TYPES = new Set(['repotting', 'progress_photo', 'care_reminder', 'identification']);
function describeDiary(plant: SavedPlantProfile, maxEvents = 3): string | undefined {
  const events = plant.events || plant.plantEvents;
  if (!Array.isArray(events)) return undefined;
  const candidates: Array<{ event: (typeof events)[number]; date: string; type: string }> = [];
  for (const event of events) {
    if (!event || event.isDeleted || typeof event.type !== 'string' || DIARY_SKIP_TYPES.has(event.type)) continue;
    const date = normalizeDateKey(event.eventDate);
    if (!date) continue;
    candidates.push({ event, date, type: event.type });
  }
  candidates.sort((a, b) => b.date.localeCompare(a.date));
  const limit = Number.isFinite(maxEvents) ? Math.max(0, Math.floor(maxEvents)) : 3;
  const items = candidates.slice(0, limit);
  const bits: string[] = [];
  for (const { event, date, type } of items) {
    const until = daysUntilLocalDate(date);
    const when = until === null
      ? ''
      : until > 0
        ? ` (in ${until} day${until === 1 ? '' : 's'})`
        : until === 0
          ? ' (today)'
          : ` (${-until} day${-until === 1 ? '' : 's'} ago)`;
    const note = asInlineText(event.note, 81);
    const clippedNote = note && note.length > 80 ? `${note.slice(0, 80)}…` : note;
    bits.push(`${type.replace(/_/g, ' ')} on ${formatDateKey(date) || date}${when}${clippedNote ? ` (${JSON.stringify(clippedNote)})` : ''}`);
  }
  return bits.length ? bits.join(', ') : undefined;
}

/**
 * WEEK-AHEAD SUMMARY (Bekky, 2026-08-30, cost-hygiene law: compress the 7-day
 * forecast to ONE local sentence — no new AI call). Flags only rain days and
 * hot days, the two things that change watering decisions. Exported: the cohort
 * care scaffold says it once for the whole crew.
 */
export function describeWeekAhead(daily?: WeatherSnapshot['daily']): string | undefined {
  if (!Array.isArray(daily) || !daily.length) return undefined;
  const upcoming: Array<{ date: string; lead: number; precipitationMm: number | null; tempMaxC: number | null }> = [];
  const seenDates = new Set<string>();
  for (const day of daily) {
    if (!isRecord(day)) continue;
    const date = normalizeDateKey(day.date);
    const lead = daysUntilLocalDate(day.date);
    if (!date || lead === null || lead < 0 || lead > 6 || seenDates.has(date)) continue;
    seenDates.add(date);
    const precipitation = asFiniteNumber(day.precipitationMm);
    const temperature = asFiniteNumber(day.tempMaxC);
    if (precipitation === null && temperature === null) continue;
    upcoming.push({
      date,
      lead,
      precipitationMm: precipitation,
      tempMaxC: temperature,
    });
  }
  upcoming.sort((left, right) => left.lead - right.lead);
  const bounded = upcoming.slice(0, 7);
  if (!bounded.length) return undefined;

  const rainDays = bounded.filter((day) => day.precipitationMm !== null && day.precipitationMm >= 5);
  const hotDays = bounded.filter((day) => day.tempMaxC !== null && day.tempMaxC >= 32);
  const parts: string[] = [];
  if (rainDays.length) {
    const total = rainDays.reduce((sum, day) => sum + (day.precipitationMm || 0), 0);
    parts.push(`rain coming ${rainDays.map((day) => day.date).join(', ')} (~${Math.round(total)}mm total)`);
  }
  if (hotDays.length) {
    parts.push(`hot ${hotDays.map((day) => `${day.tempMaxC!.toFixed(0)}°C on ${day.date}`).join(', ')}`);
  }
  return parts.length
    ? `week ahead: ${parts.join('; ')}`
    : 'week ahead: no meaningful rain or heat spikes';
}

export function buildCohortCarePacket(
  action: 'water' | 'fertilize',
  input: {
    plant: SavedPlantProfile;
    room?: RoomProfile;
    setup?: PlantSetupProfile;
    weather?: WeatherSnapshot;
  }
): string {
  if (!input?.plant || (action !== 'water' && action !== 'fertilize')) return '';
  try {
    const { plant, room, setup, weather } = input;
    const packet = buildCareContextPacket({ plant, room, setup, weather });
    const bits: string[] = [];
    const plantName = asInlineText(plant.name, 200)
      || asInlineText(plant.commonName, 200)
      || asInlineText(plant.scientificName, 200)
      || 'Plant';
    bits.push(`Plant: ${plantName}`);
    const scientificName = asInlineText(plant.scientificName, 200);
    if (scientificName) bits.push(`Species: ${scientificName}`);
    if (packet.growthStage) bits.push(`Growth stage: ${packet.growthStage}`);
    if (packet.plantSizeRatio) bits.push(packet.plantSizeRatio);

    if (action === 'water') {
      // Water care: pot dry-down, medium retention, light, environment, weather,
      // repot. PARITY LAW (Bekky, 2026-08-30): cohort member lines carry the SAME
      // facts a solo plant's packet would — compressed onto one line, never
      // thinner. Full Setup line (dry-down, drainage, size, watering method) is
      // strictly richer than the terse Pot line it replaces; Sun exposure stacks
      // all labels where the terse Light line took only the first.
      if (setup) {
        const setupDescription = describeSetup(setup);
        if (setupDescription) bits.push(`Setup: ${setupDescription}`);
      }
      // No setup profile → no pot facts exist anywhere; honest silence (parity
      // law: never thinner than facts allow, never invented).
      const med = mediumRetentionLabel(setup?.mediumType, setup?.customMediumComponents);
      if (med) bits.push(`Medium: ${med}`);
      if (packet.sunExposure) bits.push(`Sun exposure: ${packet.sunExposure}`);
      else {
        const light = lightLabel(room, setup);
        if (light) bits.push(`Light: ${light}`);
      }
      if (packet.indoorOutdoor) bits.push(`${packet.indoorOutdoor}`);
      if (packet.environment) bits.push(`Environment: ${packet.environment}`);
      if (packet.careDifficulty) bits.push(`Care difficulty: ${packet.careDifficulty}`);
      if (packet.timeOwned) bits.push(`Time owned: ${packet.timeOwned.replace(/_/g, ' ')}`);
      // Placement proximity (Bekky, 2026-08-30): per-plant distance to the
      // window + each climate device the room actually has — "right next to the
      // aircon" drinks very differently from "across the room" of the same room.
      if (packet.placement) bits.push(`Placement: ${packet.placement}`);
      if (packet.weather) bits.push(`Weather: ${packet.weather}`);
      // Compressed weather signals (Bekky, 2026-09-01): the AI reasons LIVE from
      // these distilled states + each plant's own pot/medium/stage — never a
      // canned pre-saved answer. "Drying pressure" (cloudy/humid/still = low),
      // "saturation risk" (rain vs seasonal normal), heat stress, cold-damp,
      // stormy. This is the "smart, not calculator" layer.
      if (weather) {
        try {
          const sig = compressWeatherSignals(weather);
          if (sig.summary) bits.push(`Weather signals: ${sig.summary}`);
        } catch {
          // Optional weather compression must never block cohort care.
        }
      }
      const repot = describeTimeSinceRepot(plant);
      if (repot) bits.push(repot);
    } else {
      // Fertilize care: growth stage, feeder class, medium nutrient-holding,
      // time owned, season. Not light/water details.
      // PARITY LAW (Bekky, 2026-08-30): carry the same setting/environment/
      // difficulty facts the water line now carries, and split the old
      // "Season/weather" label — the weather line never carried a season word.
      bits.push(`Feeder: ${plant.careDifficulty === 'fussy' ? 'heavy/regular feeder' : plant.growthStage?.includes('young') || plant.growthStage === 'seedling' ? 'growing, needs regular feeding' : 'steady feeder'}`);
      const med = mediumRetentionLabel(setup?.mediumType, setup?.customMediumComponents);
      if (med) bits.push(`Medium: ${med}`);
      const owned = asInlineText(plant.timeOwned, 200);
      if (owned) bits.push(`Owned: ${owned.replace(/_/g, ' ')}`);
      if (packet.region) bits.push(`Region: ${packet.region}`);
      bits.push(`Season: ${currentSeasonLabel()}`);
      if (packet.indoorOutdoor) bits.push(`${packet.indoorOutdoor}`);
      if (packet.environment) bits.push(`Environment: ${packet.environment}`);
      if (packet.careDifficulty) bits.push(`Care difficulty: ${packet.careDifficulty}`);
      if (packet.weather) bits.push(`Weather: ${packet.weather}`);
      // Fertilize-side setup text boxes (row 7 family): mix description + top
      // dressing are medium-family facts, relevant to feeding.
      const customMedium = quotedInline(setup?.customMedium);
      const customTopDressing = quotedInline(setup?.customTopDressing);
      if (customMedium) bits.push(`mix description: ${customMedium}`);
      if (customTopDressing) bits.push(`top dressing: ${customTopDressing}`);
    }
    // GARDENER SHELVES — compressed (Bekky, 2026-08-30 parity law: everything a
    // solo packet has, tighter). Space notes + treatment prefs ride the cohort
    // scaffold ONCE (fetchCohortCare), never repeated per member.
    const lightNeeds = asInlineText(plant.lightNeeds, 1_000);
    if (lightNeeds) bits.push(`light needs: ${lightNeeds}`);
    const note = quotedInline(plant.notes, 2_000) || quotedInline(plant.manualNotes, 2_000);
    if (note) bits.push(`note: ${note}`);
    const words = describeCareWords(plant);
    if (words) bits.push(words);
    const safety = describeSafetyNote(plant);
    if (safety) bits.push(safety);
    const diary = describeDiary(plant, 2);
    if (diary) bits.push(diary);
    return bits.join('; ');
  } catch {
    return asInlineText(input.plant.name, 200)
      || asInlineText(input.plant.commonName, 200)
      || 'Plant';
  }
}

/**
 * Change-detection fingerprint (Bekky, 2026-08-15).
 *
 * Hash the AI-relevant fields of a care context packet so the "Save and refresh
 * Pixie Intelligence" button can SKIP the AI call when nothing meaningful
 * changed. This is a change-detector, not security — a simple deterministic
 * string hash is plenty. Covers exactly the fields the AI tailors care on:
 * environment, setup, placement, sun exposure, region, indoor/outdoor, growth
 * stage, care difficulty. (Weather is deliberately EXCLUDED — it changes daily
 * and would force an unnecessary refresh every tap.)
 */
function stableFingerprintValue(label: string, value: string): string {
  if (label === 'timeSinceRepot') {
    const date = value.match(/\b\d{4}-\d{2}-\d{2}\b/)?.[0];
    if (date) return date;
  }
  if (label === 'diary') {
    return value
      .replace(/\s+\((?:today|in \d+ days?|\d+ days? ago)\)/g, '')
      .replace(/\s+/g, ' ')
      .trim();
  }
  return value;
}

export function fingerprintContextPacket(packet: CareContextPacket): string {
  const source = packet || ({ plantName: '' } as CareContextPacket);
  // Build a stable, field-ordered string. Only set fields contribute, so an
  // unchanged plant produces the same hash before and after a no-op tap.
  const parts: string[] = [];
  const push = (label: string, value: unknown) => {
    if (typeof value === 'string' && value) {
      parts.push(`${label}=${stableFingerprintValue(label, value)}`);
    }
  };
  push('plantName', source.plantName);
  push('scientificName', source.scientificName);
  push('indoorOutdoor', source.indoorOutdoor);
  push('environment', source.environment);
  push('setup', source.setup);
  push('placement', source.placement);
  push('sunExposure', source.sunExposure);
  push('region', source.region);
  push('growthStage', source.growthStage);
  push('timeOwned', source.timeOwned);
  push('timeSinceRepot', source.timeSinceRepot);
  push('rootType', source.rootType);
  push('plantSizeRatio', source.plantSizeRatio);
  push('careDifficulty', source.careDifficulty);
  push('notes', source.notes);
  push('spaceNotes', source.spaceNotes);
  push('careWords', source.careWords);
  push('lightNeeds', source.lightNeeds);
  push('treatmentPreferences', source.treatmentPreferences);
  push('safetyNote', source.safetyNote);
  push('diary', source.diary);
  push('seed', source.seed);
  // LOGIC VERSION: bump this whenever the AI prompt's care logic changes in a
  // way that SHOULD refresh already-enriched plants (e.g. the ground-vs-pot
  // directive added 2026-08-15). Without it, a plant refreshed under old logic
  // has an unchanged data fingerprint → the guard says "Nothing changed" →
  // the improved logic never reaches it. Salted into the hash so a version bump
  // forces exactly one re-fetch, then the fingerprint stabilizes again.
  // Single source of truth (M18) — bump CARE_LOGIC_VERSION below, not a literal.
  push('logicVersion', CARE_LOGIC_VERSION);
  const canonical = parts.join('|');

  return hashString(canonical);
}

/**
 * Change-detection fingerprint of a plant's context PHOTOS (environment +
 * setup + placement). Hashes the ordered URIs so re-analysis only fires when
 * the photo set actually changed. Distinct from fingerprintContextPacket
 * (which covers the text fields) — photos and text change independently.
 */
export function fingerprintContextPhotos(photoUris: string[]): string {
  const canonical = Array.isArray(photoUris)
    ? photoUris
        .filter((uri): uri is string => typeof uri === 'string' && Boolean(uri.trim()))
        .map((uri) => uri.trim())
        .join('|')
    : '';
  return hashString(canonical);
}

/**
 * Per-section change-detection fingerprints (Bekky, 2026-08-19).
 *
 * The user wants the UI to reflect WHAT Pixie is learning from — the chips/
 * choices they picked (text) and/or the photos they added — per section
 * (environment / setup / placement). This hashes each section's text fields
 * and photo URIs separately so the refresh flow can say "learning from your
 * setup" vs "learning from your setup and photos" accurately.
 */
export function fingerprintContextSections(packet: CareContextPacket): {
  environment?: string;
  setup?: string;
  placement?: string;
} {
  const source = packet || ({ plantName: '' } as CareContextPacket);
  const out: { environment?: string; setup?: string; placement?: string } = {};
  if (typeof source.environment === 'string' && source.environment) {
    out.environment = hashString(`environment=${source.environment}`);
  }
  if (typeof source.setup === 'string' && source.setup) {
    out.setup = hashString(`setup=${source.setup}`);
  }
  if (typeof source.placement === 'string' && source.placement) {
    out.placement = hashString(`placement=${source.placement}`);
  }
  return out;
}

export function fingerprintContextSectionPhotos(photoUrisBySection: {
  environment: string[];
  setup: string[];
  placement: string[];
}): { environment?: string; setup?: string; placement?: string } {
  const out: { environment?: string; setup?: string; placement?: string } = {};
  const validUris = (value: unknown): string[] => Array.isArray(value)
    ? value.filter((uri): uri is string => typeof uri === 'string' && Boolean(uri.trim()))
    : [];
  const environment = validUris(photoUrisBySection?.environment);
  const setup = validUris(photoUrisBySection?.setup);
  const placement = validUris(photoUrisBySection?.placement);
  if (environment.length) out.environment = fingerprintContextPhotos(environment);
  if (setup.length) out.setup = fingerprintContextPhotos(setup);
  if (placement.length) out.placement = fingerprintContextPhotos(placement);
  return out;
}

/* =====================================================================
 * COHORT LIFE-HISTORY SECTION (Bekky, 2026-08-31, stitches 5–7)
 * The plant's "cohort life" section, rendered PULLABLE: any AI pass lifts
 * exactly this block — never the whole plant JSON. Append-only data up top,
 * bounded rendering below (packet-compaction doctrine).
 * ===================================================================== */

function historyFmtDay(key?: string): string {
  if (!key) return '—';
  return formatDateKey(key) || asInlineText(key, 100) || '—';
}

function historyDaysBetween(a: string, b: string): number | null {
  return daysBetweenDateKeys(a, b);
}

/**
 * Render a plant's cohort-history ledger as ONE clearly-labeled section.
 * Open memberships first (what's true NOW), past stays condensed one line per
 * stay. Hard cap: ~14 most relevant entries until the future clipping
 * mechanism takes over deprecation duties.
 */
export function renderCohortHistorySection(
  history: import('../../types/cohortHistory').CohortHistoryEntry[] | undefined,
  opts?: { maxEntries?: number },
): string | undefined {
  if (!Array.isArray(history) || !history.length) return undefined;
  const requestedCap = opts?.maxEntries ?? 14;
  const cap = Number.isFinite(requestedCap) ? Math.max(0, Math.floor(requestedCap)) : 14;
  if (cap === 0) return undefined;
  const open = history.filter((entry) => entry && !entry.left).slice(-cap);
  const remaining = Math.max(0, cap - open.length);
  const allPast = history.filter((entry) => entry && entry.left);
  const past = remaining > 0 ? allPast.slice(-remaining) : [];
  const lines: string[] = [];
  for (const e of open) {
    const name = asInlineText(e.name, 200) || 'Unnamed cohort';
    const action = e.action === 'water' || e.action === 'fertilize' ? e.action : 'care';
    let line = `- NOW in ${JSON.stringify(name)} (${action}${e.joined ? `, since ${historyFmtDay(e.joined)}` : ''})`;
    if (typeof e.deviationDays === 'number' && Number.isFinite(e.deviationDays) && e.deviationDays !== 0) {
      line += `, was ${Math.abs(e.deviationDays)} day${Math.abs(e.deviationDays) === 1 ? '' : 's'} ${e.deviationDays > 0 ? 'fresher than' : 'behind'} the crew at join`;
    }
    const foldPlan = asInlineText(e.foldPlan, 1_000);
    if (foldPlan) line += ` — bridge plan: ${foldPlan}`;
    lines.push(line);
  }
  for (const e of past) {
    const name = asInlineText(e.name, 200) || 'Unnamed cohort';
    const action = e.action === 'water' || e.action === 'fertilize' ? e.action : 'care';
    const reasonLeft = asInlineText(e.reasonLeft, 1_000);
    const foldPlan = asInlineText(e.foldPlan, 1_000);
    lines.push(`- PAST: ${JSON.stringify(name)} (${action}, ${historyFmtDay(e.joined)}–${historyFmtDay(e.left)})${reasonLeft ? ` — left: ${reasonLeft}` : ''}${foldPlan ? ` — joined under: ${foldPlan}` : ''}`);
  }
  if (!lines.length) return undefined;
  return `COHORT LIFE HISTORY (the groups this plant has belonged to, and how each joining went):\n${lines.join('\n')}`;
}

/**
 * FOLD-IN FACTS, ANCHORLESS (Bekky, 2026-08-31, stitch 3): for NEW members of
 * a group, report the FACTS — own last-<action> date vs the existing crew's
 * earliest, deviation in days, fresher-vs-behind. CODE REPORTS FACTS ONLY; the
 * bridge plan itself is the AI's horticultural judgment (Bekky §B3 ruling —
 * never a blanket rule). Used when the group has NO anchor yet (brand-new
 * groups, reshuffles) — exactly when the old anchor-gated fact-scan (which
 * needed `anchor && shared && groupNext`) stayed silent, so the care AI got no
 * fold-in facts at precisely the moment a reshuffle needed a plan.
 */
export function buildCohortHistoryFacts(input: {
  action: import('../../types/care').GroupableCareAction;
  newMemberIds: string[];
  plantsById: Map<string, SavedPlantProfile>;
  existingMemberIds: string[];
}): string | undefined {
  if (!input || (input.action !== 'water' && input.action !== 'fertilize')) return undefined;
  const { action, plantsById } = input;
  const newMemberIds = Array.isArray(input.newMemberIds) ? input.newMemberIds : [];
  const existingMemberIds = Array.isArray(input.existingMemberIds) ? input.existingMemberIds : [];
  if (!newMemberIds.length || !(plantsById instanceof Map)) return undefined;
  const doneOf = (id: string) => plantsById.get(id)?.careSchedule?.lastDone?.[action];
  const existingDates = uniqueStrings(existingMemberIds)
    .map(doneOf)
    .map(normalizeDateKey)
    .filter((date): date is string => Boolean(date) && (daysUntilLocalDate(date) ?? 1) <= 0);
  const earliest = existingDates.length ? [...existingDates].sort()[0] : undefined;
  const actionLabel = action === 'water' ? 'watered' : 'fed';
  const lines: string[] = [];
  for (const id of uniqueStrings(newMemberIds)) {
    const plant = plantsById.get(id);
    if (!plant) continue;
    const ownDate = normalizeDateKey(doneOf(id));
    const own = ownDate && (daysUntilLocalDate(ownDate) ?? 1) <= 0 ? ownDate : null;
    const name = asInlineText(plant.name, 200) || asInlineText(plant.commonName, 200) || 'Plant';
    if (!own) {
      lines.push(`- ${JSON.stringify(id)} (${JSON.stringify(name)}): no ${action} date on file yet — fold in gently, plan from the group's rhythm.`);
      continue;
    }
    if (!earliest) {
      lines.push(`- ${JSON.stringify(id)} (${JSON.stringify(name)}): last ${actionLabel} ${historyFmtDay(own)} — no existing-crew ${action} dates to compare (likely a brand-new group).`);
      continue;
    }
    const diff = historyDaysBetween(own, earliest);
    if (diff === null) {
      lines.push(`- ${JSON.stringify(id)} (${JSON.stringify(name)}): last ${actionLabel} ${historyFmtDay(own)} — the date could not be compared reliably with the existing crew.`);
      continue;
    }
    const dir = diff > 0
      ? `FRESHER than the crew's earliest member by ${diff} day${Math.abs(diff) === 1 ? '' : 's'}`
      : diff < 0
        ? `BEHIND the crew's earliest member by ${-diff} day${Math.abs(diff) === 1 ? '' : 's'}`
        : 'in step with the crew';
    lines.push(`- ${JSON.stringify(id)} (${JSON.stringify(name)}): last ${actionLabel} ${historyFmtDay(own)} — ${dir}.`);
  }
  if (!lines.length) return undefined;
  return lines.join('\n');
}
