// ------------------------------------------------------------------------
//  utils.js - Pure helper functions and lookup tables.
//
//  No side-effects, no DOM access, no API calls. All functions are safe
//  to call server-side or in tests.
//
//  Exports (in order of appearance):
//    utciCategory(u)                       UTCI stress band - {label,bg,fg}
//    precipPenalty(precipMm,snowCmH,windMs) SunScope soak-factor (-C penalty)
//    windCompass8(deg)                     bearing - {label, snapped}
//    uvSplit(uv, elevDeg)                  total UV index - {uvA, uvB} estimate
//    SKIN_TYPES                            Fitzpatrick I-VI lookup table
//    sunburnMinutes(uv, skinType)          minutes to MED (sunburn threshold)
//    burnLabel(mins)                       formats burn time as "12m" / "1.5h"
//    VEHICLE_TYPES                         vehicle presets for cabin heat model
//    BUILDING_TYPES                        building presets for indoor heat model
//    cloudCategory(total,low,mid,high)     - 'clear'|'wispy'|'scattered'|'overcast'
//    confidenceBand(i)                     day-tab gradient colour + label
//    moonPhaseFraction(date)               0..1 synodic phase fraction
//    moonGlyph(p)                          phase fraction - moon emoji
//    skyGradientForElev(e, isRising)       solar elevation - {top,bot} hex pair
//    skyFillForElev(e, isRising)           convenience single-colour sky fill
//    grassFillForElev(e)                   solar elevation - {top,bot} ground hex
// ------------------------------------------------------------------------

// -------------------------------------------------------------------
// UTCI THERMAL STRESS BANDS - single source of truth.
// -------------------------------------------------------------------
// Edit this array to change labels, colours or thresholds.
// Each entry: { max, label, bg, fg }
//   max  - upper boundary (exclusive). Omit on the last entry.
//   bg   - background hex for table cells and legend.
//   fg   - foreground hex for text on that background.
// utciCategory() reads from this array - do not duplicate elsewhere.
// -------------------------------------------------------------------
export const UTCI_BANDS = [
  { max: -20, label: 'Extreme cold',  bg: '#9b7fc2', fg: '#ffffff' },
  { max: -10, label: 'Arctic',        bg: '#7fa8d8', fg: '#ffffff' },
  { max:   0, label: 'Freezing',      bg: '#7ec0e8', fg: '#1a1200' },
  { max:   5, label: 'Very cold',     bg: '#a8d4ee', fg: '#1a1200' },
  { max:  10, label: 'Cold',          bg: '#b8e0e8', fg: '#1a1200' },
  { max:  15, label: 'Chilly',        bg: '#b8e8d8', fg: '#1a1200' },
  { max:  19, label: 'Cool',          bg: '#c5e8c0', fg: '#1a1200' },
  { max:  24, label: 'Comfortable',   bg: '#90d090', fg: '#157a15', fontWeight: 700 },
  { max:  29, label: 'Warm',          bg: '#f8e554', fg: '#1a1200' },
  { max:  34, label: 'Hot',           bg: '#f5aa54', fg: '#1a1200' },
  { max:  39, label: 'Very hot',      bg: '#f5884a', fg: '#1a1200' },
  { max:  44, label: 'Extremely hot', bg: '#dd5454', fg: '#000000' },
  {           label: 'Danger',        bg: '#880000', fg: '#ffffff', fontWeight: 700, darkenAmt: 0.18, textShadow: '-1px -1px 0 #3a0000, 1px -1px 0 #3a0000, -1px 1px 0 #3a0000, 1px 1px 0 #3a0000', solid: true },
];

export function utciCategory(u) {
  for (const band of UTCI_BANDS) {
    if (band.max === undefined || u < band.max) return band;
  }
}

// Diagonal sweep gradient for a band background hex colour.
// Pre-blends with 50% white to sit harmoniously alongside lighter table cells,
// then applies a light→mid→dark sweep for depth.
export function bandGradient(hex, darkenAmt = 0.04) {
  const n = parseInt(hex.replace('#',''), 16);
  const wb = (c) => Math.min(255, Math.round(c + (255 - c) * 0.50));
  const [r, g, b] = [wb((n>>16)&255), wb((n>>8)&255), wb(n&255)];
  const lighten = (c) => Math.min(255, Math.round(c + (255 - c) * 0.30));
  const darken  = (c) => Math.round(c * (1 - darkenAmt));
  const light = '#' + [lighten(r), lighten(g), lighten(b)].map(x => x.toString(16).padStart(2,'0')).join('');
  const mid   = '#' + [r, g, b].map(x => x.toString(16).padStart(2,'0')).join('');
  const dark  = '#' + [darken(r),  darken(g),  darken(b) ].map(x => x.toString(16).padStart(2,'0')).join('');
  return `linear-gradient(135deg, ${light} 0%, ${mid} 60%, ${dark} 100%)`;
}

// -------------------------------------------------------------------
// SOAK-FACTOR - SunScope's original rain/snow penalty.
// -------------------------------------------------------------------
// This is what makes "UTCI+P" different from plain UTCI. It subtracts
// extra felt-temperature for rain (wet clothing = evaporative chill)
// and snow (wet snow is brutal). Wind amplifies the rain penalty.
// Calibrated by feel - adjust the multipliers if you find it too
// strong/weak. The big number "7" caps the max rain penalty so a
// freak 50mm/h reading can't make UTCI nonsensical.
// -------------------------------------------------------------------
export function precipPenalty(precipMm, snowCmH, windMs) {
  let penalty = 0;
  if (precipMm > 0) {
    const base = Math.min(7, 1.4 * Math.pow(precipMm, 0.55) + precipMm * 0.28);
    const windMult = 1 + Math.min(0.35, windMs * 0.025);
    penalty += base * windMult;
  }
  if (snowCmH > 0) penalty += Math.min(6, 2.2 + snowCmH * 1.6);
  return -Math.round(penalty * 10) / 10;
}

// -------------------------------------------------------------------
// WIND COMPASS - meteorological bearing (deg FROM) - 8-point label.
// -------------------------------------------------------------------
// 0/360 = wind FROM north. The pointer in WindVane should rotate so
// the arrow's tail points to this bearing (i.e. shows where the wind
// comes from), matching how real weather vanes behave.
// -------------------------------------------------------------------
export function windCompass8(deg) {
  if (deg == null || isNaN(deg)) return { label: '—', snapped: 0 };
  const dirs   = ['N','NE','E','SE','S','SW','W','NW'];
  const idx    = Math.round(((deg % 360) + 360) % 360 / 45) % 8;
  return { label: dirs[idx], snapped: idx * 45 };
}

// -------------------------------------------------------------------
// UV-A / UV-B SPLIT (estimate, not measurement).
// -------------------------------------------------------------------
// Open-Meteo gives a total erythemal UV index. UV-A reaches the
// surface much more reliably than UV-B; UV-B is far more sensitive
// to solar elevation because of atmospheric path length.
//
// Cheap model:
//   At noon (sun overhead) the UV-B share of total UV index is ~15%,
//   UV-A about 85%. Below ~10- solar elevation, UV-B falls off fast.
//   We return two pseudo-"index" numbers so the columns are in the
//   same units the user already understands.
// Label these as estimates in the UI.
// -------------------------------------------------------------------
export function uvSplit(uv, elevDeg) {
  if (!uv || uv <= 0 || elevDeg <= 0) return { uvA: 0, uvB: 0 };
  const sinE  = Math.sin(elevDeg * Math.PI / 180);
  const uvbFr = Math.max(0.005, Math.min(0.15, 0.15 * Math.pow(sinE, 1.6)));
  const uvaFr = 1 - uvbFr;
  return { uvA: uv * uvaFr, uvB: uv * uvbFr };
}

// -------------------------------------------------------------------
// SUNBURN TIME - minutes to MED for the chosen Fitzpatrick skin type.
// -------------------------------------------------------------------
// Standard erythemal model: time_min - base_minutes[type] / UV_index.
// base = minutes to 1 MED at UV = 1, unprotected, no sunscreen.
// Calibrated to the erythemal-dose science (UVI = 40 x E_er W/m-,
// MED_II ~ 250 J/m-), giving ~150/UV min for fair skin rather than
// the more alarmist consumer "10 min at UV 10" rule of thumb.
// Returns Infinity when UV is 0 (night).
// -------------------------------------------------------------------
export const SKIN_TYPES = {
  I:   { name: 'I · Very fair',   base: 100 },
  II:  { name: 'II · Fair',       base: 150 },
  III: { name: 'III · Light',     base: 300 },
  IV:  { name: 'IV · Mid',        base: 450 },
  V:   { name: 'V · Dark',        base: 600 },
  VI:  { name: 'VI · Very dark',  base: 750 },
};
export function sunburnMinutes(uv, skinType = 'II') {
  if (!uv || uv <= 0) return Infinity;
  const base = (SKIN_TYPES[skinType] || SKIN_TYPES.II).base;
  return base / uv;
}
export function burnLabel(mins) {
  if (!isFinite(mins)) return '—';
  if (mins >= 480) return '8h+';
  if (mins >= 60)  return `${(mins/60).toFixed(1)}h`;
  return `${Math.round(mins)}m`;
}

// -------------------------------------------------------------------
// VEHICLE TYPES - presets for the cabin heat model.
// -------------------------------------------------------------------
// Each entry tweaks the physical levers in calcVehicleInteriorTemp:
//   albedo       - how much solar the bodywork reflects (0 = black, 1 = mirror)
//   glazingArea  - relative sun-exposed glass area (1.0 = typical car)
//   bodyU        - effective body/panel conductance into the cabin. Cars are
//                  thin metal/glass boxes; motorhomes/caravans have insulated
//                  sandwich panels, commonly around 25-35 mm thick.
//   hCabinLoss   - effective heat rejection/infiltration from the cabin air.
//   thermalMass  - lower values mean the interior warms more slowly in the hour.
//   retainedWarmth - occupied insulated living spaces hold heat from previous
//                  hours, people, appliances, and background heating.
//   internalGain   - small living-space warmth boost when closed up.
// -------------------------------------------------------------------
export const VEHICLE_TYPES = {
  car:       { name: 'Car / Hatchback',       albedo: 0.25, glazingArea: 1.0,  bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 },
  mpv:       { name: 'MPV / People Carrier',  albedo: 0.25, glazingArea: 1.3,  bodyU: 4.0, hCabinLoss: 20, thermalMass: 1.00, retainedWarmth: false, internalGain: 0.0 },
  suv:       { name: 'SUV / 4x4',             albedo: 0.22, glazingArea: 1.1,  bodyU: 3.8, hCabinLoss: 19, thermalMass: 0.95, retainedWarmth: false, internalGain: 0.0 },
  motorhome: { name: 'Motorhome / Campervan', albedo: 0.55, glazingArea: 0.25, bodyU: 0.9, hCabinLoss: 12, thermalMass: 0.35, retainedWarmth: true,  internalGain: 1.2 },
  caravan:   { name: 'Caravan (towed)',       albedo: 0.60, glazingArea: 0.22, bodyU: 0.8, hCabinLoss: 11, thermalMass: 0.35, retainedWarmth: true,  internalGain: 1.2 },
};

// -------------------------------------------------------------------
// BUILDING TYPES - presets for the indoor temperature model.
// -------------------------------------------------------------------
// Each preset drives both calcIndoorTempPass and calcManagedIndoorTempPass.
//
//  uWall        W/m-K  Effective envelope U-value. Higher = faster response
//                      to outdoor swings, less insulation.
//  lagHours     h      Thermal mass time constant. Heavier construction = longer
//                      lag before indoor temp follows outdoor changes.
//  glazingRatio -      Fraction of floor area that is window. More glass = more
//                      solar gain in summer, more heat loss in winter.
//  gValue       -      Solar heat gain coefficient of glazing. 0.63 = standard
//                      double glazing; 0.3 = modern low-e triple.
//  orientFactor -      Fraction of windows facing the sun at any given time.
//                      0.5 = random orientation; 0.8 = south-facing conservatory.
//  curtainBlock -      Fraction of solar gain blocked when managed (curtains
//                      closed). Thick lined curtains - 0.80; blinds - 0.50.
//  ventAlpha    -      Blending weight per hour when smart ventilation is open.
//                      Higher = more air changes per hour.
//  solarScale   -      Converts effective window solar gain into an indoor
//                      temperature lift. Lower values mean more thermal mass.
//  baseTemp     -      Occupied/retained warmth baseline for normal homes.
//  internalGain -      Small heat gain from people, appliances, and background use.
//  retainedScale -     How strongly the building holds above-outdoor warmth in
//                      cool conditions. Higher = better retained warmth.
// -------------------------------------------------------------------
export const BUILDING_TYPES = {
  brick:       { name: 'Brick (typical)',      uWall: 0.35, lagHours: 4, glazingRatio: 0.16, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.25, solarScale: 0.10, baseTemp: 16.5, internalGain: 0.8, retainedScale: 0.35 },
  modern:      { name: 'Modern / Well insulated', uWall: 0.18, lagHours: 5, glazingRatio: 0.20, gValue: 0.30, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.20, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.55 },
  victorian:   { name: 'Victorian Terrace',       uWall: 0.55, lagHours: 5, glazingRatio: 0.12, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.80, ventAlpha: 0.30, solarScale: 0.10, baseTemp: 16.0, internalGain: 0.7, retainedScale: 0.25 },
  stone:       { name: 'Stone / Granite Cottage', uWall: 0.45, lagHours: 7, glazingRatio: 0.10, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.25, solarScale: 0.08, baseTemp: 15.5, internalGain: 0.6, retainedScale: 0.40 },
  timber:      { name: 'Timber Frame / New Build', uWall: 0.22, lagHours: 2, glazingRatio: 0.22, gValue: 0.35, orientFactor: 0.50, curtainBlock: 0.70, ventAlpha: 0.35, solarScale: 0.08, baseTemp: 17.0, internalGain: 0.8, retainedScale: 0.45 },
  flat:        { name: 'Top-floor Flat',          uWall: 0.40, lagHours: 3, glazingRatio: 0.18, gValue: 0.63, orientFactor: 0.50, curtainBlock: 0.75, ventAlpha: 0.20, solarScale: 0.11, baseTemp: 17.0, internalGain: 0.9, retainedScale: 0.45 },
  conservatory:{ name: 'Conservatory / Sun Room', uWall: 1.20, lagHours: 1, glazingRatio: 0.70, gValue: 0.72, orientFactor: 0.70, curtainBlock: 0.50, ventAlpha: 0.50, solarScale: 0.045, baseTemp: 12.0, internalGain: 0.2, retainedScale: 0.05 },
  office:      { name: 'Office Block',             uWall: 0.25, lagHours: 2, glazingRatio: 0.45, gValue: 0.55, orientFactor: 0.65, curtainBlock: 0.50, ventAlpha: 0.15, solarScale: 0.055, baseTemp: 18.5, internalGain: 2.5, retainedScale: 0.30 },
};

// -------------------------------------------------------------------
// CLOUD CATEGORY - pick one of 4 icon styles from low/mid/high split.
// -------------------------------------------------------------------
// Returns: 'clear' | 'wispy' | 'scattered' | 'overcast'
// Uses total cover for headline level, but biases towards 'wispy'
// when only high cloud is present (cirrus barely blocks the sun).
// -------------------------------------------------------------------
export function cloudCategory(total, low, mid, high) {
  const t = total ?? 0;
  const l = low   ?? 0;
  const m = mid   ?? 0;
  const h = high  ?? 0;
  if (t < 10) return 'clear';
  // Mostly high cloud with little low/mid - wispy regardless of % total
  if (h > 40 && l < 25 && m < 25) return 'wispy';
  if (t < 40) return 'wispy';
  if (t < 75) return 'scattered';
  return 'overcast';
}

// -------------------------------------------------------------------
// CONFIDENCE BANDS - smooth high-noon - sunset gradient on day tabs.
// -------------------------------------------------------------------
// 'i' is the day index (0 = today, 13 = day 14).
//
// Each day gets its own shade, interpolated between two endpoint
// colours. To re-skin the gradient (say, blue-to-purple instead of
// sunset), just change the four RGB endpoint arrays below.
//
//   bg    = tab background    (lighter on day 0, darker on day 13)
//   edge  = active-tab underline + slim border on the banner
//   tint  = soft wash on the confidence banner above the table
//   label = qualitative zone name shown in the banner ("Golden hour")
//
// The label switches in 4 stages so users still see a friendly
// description ("you're in the trustworthy zone" vs "this is an
// outlook"). The tab background itself flows smoothly day to day.
// -------------------------------------------------------------------
export function confidenceBand(i) {
  // Position along the gradient: 0 on day 0, 1 on day 13.
  const t = Math.min(1, Math.max(0, i / 13));

  // ENDPOINTS - change these four RGB arrays to re-skin the gradient.
  const bgStart   = [255, 247, 214];   // #fff7d6  pale yellow (high noon)
  const bgEnd     = [232, 152, 104];   // #e89868  terracotta (sunset)
  const edgeStart = [245, 231, 161];   // #f5e7a1  soft golden
  const edgeEnd   = [196, 115, 64];    // #c47340  burnt umber

  // Linear interpolation between the two endpoints.
  const lerp = (a, b) => Math.round(a + t * (b - a));
  const mix  = (s, e) => [lerp(s[0], e[0]), lerp(s[1], e[1]), lerp(s[2], e[2])];

  const [br, bg, bb] = mix(bgStart, bgEnd);
  const [er, eg, eb] = mix(edgeStart, edgeEnd);

  return {
    bg:   `rgb(${br}, ${bg}, ${bb})`,
    edge: `rgb(${er}, ${eg}, ${eb})`,
    tint: `rgba(${er}, ${eg}, ${eb}, 0.22)`,
    // Qualitative confidence label (camera-focus metaphor -
    // on-brand for SunScope, and instantly readable).
    label: i < 3  ? 'Pin-sharp'    // days 1-3   highest skill
         : i < 7  ? 'Sharp'        // days 4-7   solid
         : i < 10 ? 'Soft focus'   // days 8-10  trends only
         :          'Blurry',      // days 11-14 outlook only
  };
}

// -------------------------------------------------------------------
// MOON PHASE - works out which moon emoji to show on night hours.
// -------------------------------------------------------------------
// Returns a fraction 0..1:
//   0.00 = new moon         0.50 = full moon
//   0.25 = first quarter    0.75 = last quarter
// The maths is a simple synodic-period calculation referenced from
// a known new moon (6 Jan 2000). Accurate to within a few hours.
// -------------------------------------------------------------------
export function moonPhaseFraction(date) {
  const JD = date.getTime() / 86400000 + 2440587.5;
  const syn = 29.530588;
  const ref = 2451549.2303;
  let p = ((JD - ref) % syn) / syn;
  if (p < 0) p += 1;
  return p;
}
export function moonGlyph(p) {
  // +0.5 offset rounds to nearest phase (avoids flickering on exact boundaries)
  return ['🌑','🌒','🌓','🌔','🌕','🌖','🌗','🌘'][Math.floor(p * 8 + 0.5) % 8];
}

// -------------------------------------------------------------------
// SKY GRADIENT - top/bottom colour pair for the SkyScope disk gradient.
// -------------------------------------------------------------------
// isRising: true when sun is climbing (local hour < 12).
//
// Sunrise palette - reds, oranges, yellows at the horizon.
// Sunset palette  - pinks, purples, mauves at the horizon.
// Both share the same deep blue zenith at high elevations.
// -------------------------------------------------------------------
// Sky colour keyframes - [elevation, topHex, botHex] for rising and setting sun.
// Colours are smoothly interpolated between adjacent keyframes.
const SKY_KEYS_RISING = [
  [-18,  '#0e0c28', '#1a1538'],   // deep night
  [-14,  '#1e1a50', '#3a2f60'],   // nautical twilight
  [ -8,  '#2a3a6a', '#a04828'],   // pre-dawn: indigo - burnt sienna
  [ -3,  '#4a7aaa', '#e8622a'],   // horizon band: deep blue - vivid red-orange
  [  3,  '#6aaddb', '#ffb347'],   // golden hour: blue - warm amber
  [ 10,  '#7cc5ec', '#c8e3ee'],   // low sun: pale blue sky
  [ 30,  '#5bb8e8', '#9fd3ef'],   // mid-day blue
  [ 60,  '#2e8fd4', '#7cc8ef'],   // high noon: rich deep blue
  [ 90,  '#1a7abf', '#60b8e8'],   // zenith (sun directly overhead)
];
const SKY_KEYS_SETTING = [
  [-18,  '#0e0c28', '#1a1538'],
  [-14,  '#1e1a50', '#3a2f60'],
  [ -8,  '#5a3572', '#c07080'],   // dusk: purple - dusty rose/mauve
  [ -3,  '#7a5090', '#e8826a'],   // horizon band: violet - coral/pink
  [  3,  '#7b8fc4', '#ffb877'],   // golden hour: blue-violet - golden
  [ 10,  '#7cc5ec', '#c8e3ee'],
  [ 30,  '#5bb8e8', '#9fd3ef'],
  [ 60,  '#2e8fd4', '#7cc8ef'],
  [ 90,  '#1a7abf', '#60b8e8'],
];

function hexToRgb(hex) {
  const n = parseInt(hex.slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex([r, g, b]) {
  return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
}
function lerpRgb(a, b, t) {
  return a.map((v, i) => v + t * (b[i] - v));
}

function interpolateSkyKeys(keys, e) {
  // Clamp to key range
  if (e <= keys[0][0]) return { top: keys[0][1], bot: keys[0][2] };
  if (e >= keys[keys.length - 1][0]) {
    const k = keys[keys.length - 1];
    return { top: k[1], bot: k[2] };
  }
  // Find bracketing keyframes
  let lo = keys[0], hi = keys[1];
  for (let i = 1; i < keys.length; i++) {
    if (keys[i][0] >= e) { lo = keys[i - 1]; hi = keys[i]; break; }
  }
  const t = (e - lo[0]) / (hi[0] - lo[0]);
  const top = rgbToHex(lerpRgb(hexToRgb(lo[1]), hexToRgb(hi[1]), t));
  const bot = rgbToHex(lerpRgb(hexToRgb(lo[2]), hexToRgb(hi[2]), t));
  return { top, bot };
}

export function skyGradientForElev(e, isRising) {
  return interpolateSkyKeys(isRising ? SKY_KEYS_RISING : SKY_KEYS_SETTING, e);
}

// --- skyFillForElev --------------------------------------------------------
// Convenience single-colour fill for contexts that don't need a gradient
// (e.g. solid background chips). Returns the horizon (bottom) colour.
export function skyFillForElev(e, isRising = false) {
  return skyGradientForElev(e, isRising).bot;
}

// -------------------------------------------------------------------
// GRASS FILL - ground-strip colour keyed to solar elevation.
// -------------------------------------------------------------------
// Mirrors the sky palette so the SkyScope disk reads naturally: vivid
// green at noon, amber at golden hour, purple-dark at twilight/night.
// Returns {top, bot} for a subtle two-stop ground gradient.
// -------------------------------------------------------------------
export function grassFillForElev(e) {
  if (e > 60)  return { top: '#92cc50', bot: '#5e9630' };  // blazing noon
  if (e > 30)  return { top: '#86c44a', bot: '#558a2e' };  // bright midday
  if (e > 10)  return { top: '#7ab846', bot: '#4d802c' };  // mid-day
  if (e > 3)   return { top: '#b8a83e', bot: '#7a6a26' };  // golden-hour glow
  if (e > -3)  return { top: '#c08048', bot: '#7a4a2a' };  // sunrise/sunset embers
  if (e > -8)  return { top: '#665884', bot: '#3e3556' };  // civil twilight purple
  if (e > -14) return { top: '#363356', bot: '#1c1a34' };  // nautical night
  return { top: '#201d3a', bot: '#0d0b20' };               // astronomical night
}