'use strict';
// SHADES — Lane C — weather: the wind field everyone samples.
//
// Implements the contracts.js wind surface (PLAN3D §4):
//   wind.sample(pos, t) -> Vector3        m/s, includes gusts & local effects
//   wind.gustTelegraph(t) -> {eta, dir, power} | null
//
// All the maths lives in weather.core.js (pure, no imports). This file is just
// the THREE adapter + storm loading, so the sim stays node-testable and the
// determinism rule can't be broken by accident.

import * as THREE from '../vendor/three.module.js';
import {
  createWindField, validateStorm, validateSiteWind, GUST, RAIN_TIME_COMPRESSION,
  hailBlockFor, stormStats, forecastFor,
} from './weather.core.js';

export {
  GUST, validateStorm, validateSiteWind, RAIN_TIME_COMPRESSION,
  hailBlockFor, stormStats, forecastFor,
};

const kmh = (ms) => ms * 3.6;
const band = (b, fmt) => (b.hi - b.lo < 0.05 ? fmt(b.lo) : `${fmt(b.lo)}–${fmt(b.hi)}`);

/**
 * How vague a night reads when it is `nightsOut` nights away — the week IS the
 * forecast horizon, so tonight is exact (0) and the far end of the week is as
 * vague as this game forecasts (1). Linear, because that maps the week onto
 * forecastFor's documented 0..1 domain with nothing invented in between.
 *
 * For a five-night week: tomorrow reads at lead 0.25 — 75% confidence, a
 * sustained band about ±8% wide. Visibly hedged, still worth rigging to.
 *
 * Lane A: this is the job sheet's missing argument. `forecastLines(def, 0)` is
 * tonight; `forecastLines(tomorrowDef, leadFor(1, wk.nights))` is tomorrow's
 * band. Nights beyond the week (or a one-night week) clamp to 1 and 0.
 *
 * @param {number} nightsOut  0 = tonight, 1 = tomorrow
 * @param {number} [weekNights] nights in the week (5)
 * @returns {number} lead, 0..1
 */
export function leadFor(nightsOut, weekNights = 5) {
  if (!(nightsOut > 0)) return 0;
  const horizon = weekNights - 1;
  if (!(horizon > 0)) return 1;      // a one-night week: anything but tonight is a guess
  return Math.min(1, nightsOut / horizon);
}

/**
 * The forecast card's two stat lines, already worded — DESIGN.md's partial
 * information made visible. Lane A owns the card; this owns the numbers, so the
 * card stays one call instead of re-deriving the storm inline.
 *
 * `lead` is how far out the night is: 0 = tonight (exact numbers, reads exactly
 * as the card always has), 1 = the far end of the week (wide bands, low
 * confidence). The bands ALWAYS contain the truth — a forecast may be vague but
 * it must never rule out what actually happens, or a player who rigs for the top
 * of the stated range gets ambushed.
 *
 * Numbers are MEASURED (stormStats), not estimated: the card's old inline
 * `baseCurve peak + powBase + powRamp` read 30 m/s for storm_02, which really
 * gusts to 32.3, because gust power is drawn per gust and rides a ramp.
 *
 * @param {object} def   parsed storm JSON
 * @param {number} [lead] 0..1
 * @returns {{name, night, wind, rain, confidence, hail, truth}}
 */
export function forecastLines(def, lead = 0) {
  const f = forecastFor(def, lead);
  const i = (v) => v.toFixed(0);

  const sustained = band(f.sustained, i);
  const gusts = band({ lo: kmh(f.gustPeak.lo), hi: kmh(f.gustPeak.hi) }, i);
  const sustainedKmh = band({ lo: kmh(f.sustained.lo), hi: kmh(f.sustained.hi) }, i);

  // rain reads as a word, and a vague forecast hedges across two
  const word = (v) => (v >= 0.8 ? 'heavy' : v >= 0.4 ? 'steady' : 'light');
  const rainWord = word(f.rain.lo) === word(f.rain.hi)
    ? word(f.rain.hi) : `${word(f.rain.lo)}–${word(f.rain.hi)}`;

  const change = f.changeAt
    ? `· southerly change ${lead > 0 ? band(f.changeAt, i) + 's' : `at ${i(f.changeAt.lo)}s`}`
    : '· no change forecast';
  const hail = f.hail.chance === 'none' ? '' : `· hail ${f.hail.chance}`;

  return {
    name: (def.name ?? '').replace(/_/g, ' ').toUpperCase(),
    night: (def.sky?.night ?? (def.sky?.darkness ?? 0) > 0.6),
    wind: `sustained to ${sustained} m/s (${sustainedKmh} km/h) · gusts to ~${gusts} km/h`,
    rain: `rain ${rainWord} ${change} ${hail}`.trim(),
    // Only worth showing when it isn't tonight — "CONFIDENCE 100%" is noise.
    confidence: lead > 0 ? `forecast confidence ${Math.round(f.confidence * 100)}%` : '',
    hail: f.hail,
    truth: f.truth,
  };
}

// Resolved against this module, not the server root: server.py serves the repo
// root (so the 2D prototype stays reachable), but the demo bench serves web/.
// import.meta.url is right under both, and under whatever Lane A does next.
const STORM_DIR = new URL('../data/storms', import.meta.url).href;

/** Fetch + validate a storm def. Throws loud on bad data — storms are content. */
export async function loadStorm(name, dir = STORM_DIR) {
  const url = `${dir}/${name}.json`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`weather: cannot load ${url} (${res.status})`);
  const def = await res.json();
  const { ok, errors } = validateStorm(def, name);
  if (!ok) throw new Error(`weather: ${url} is invalid:\n  ${errors.join('\n  ')}`);
  return def;
}

/**
 * The wind a YARD actually flies — storm def + the SITE's local effects,
 * wired exactly as main.js does at every site load (setVenturi from
 * siteDef.wind.venturi, tree shelters from the anchors). SPRINT13:
 *
 * THREE harnesses have now measured site_02 with the funnel switched OFF by
 * building wind from the storm def alone — B's site_audit (Sprint 11, their
 * sweep.selftest tells the story), C's garden_bench (Sprint 13, where it
 * flipped the $80-line headline: 91.5 FULL funnel-off vs 39.8 TATTERED in
 * D's live replay), and probe4, which never attempted it. The venturi lives
 * in the SITE json, not the storm, so `def.wind.venturi` off a storm def
 * LOOKS right and never fires. Any tool measuring a yard builds its wind
 * HERE, or it is measuring a yard that doesn't ship. The strictly-raises
 * assert in c.test.js goes red if the setVenturi line is ever dropped.
 *
 * @param {object} stormDef  parsed storm JSON
 * @param {object} [siteDef] parsed site JSON (loadSite) — its wind.venturi list
 * @param {Array}  [anchors] world anchors; trees become wind shelters
 * @param {object} [opts]    forwarded to createWind ({seed})
 */
export function windForSite(stormDef, siteDef, anchors = [], opts = {}) {
  const wind = createWind(stormDef, opts);
  wind.setVenturi(siteDef?.wind?.venturi ?? []);
  wind.setSheltersFromTrees(anchors.filter((a) => a.type === 'tree'));
  return wind;
}

/**
 * @param {object} def   parsed storm JSON
 * @param {object} [opts] {seed} — same seed + same def = same storm, every run
 * @returns the `wind` object from contracts.js
 */
export function createWind(def, opts = {}) {
  const field = createWindField(def, opts);
  const scratch = { x: 0, y: 0, z: 0 };

  const wind = {
    /**
     * Wind velocity at a world position, m/s.
     * @param {THREE.Vector3} pos
     * @param {number} t              storm time, seconds
     * @param {THREE.Vector3} [out]   pass one to avoid allocating — sail.js
     *                                samples per-face per-frame, so this matters
     */
    sample(pos, t, out) {
      const v = out || new THREE.Vector3();
      field.vecAt(pos.x, pos.z, t, scratch);
      return v.set(scratch.x, scratch.y, scratch.z);
    },

    /** Scalar speed — for HUD, rain, grass. Cheaper than sample(); no allocation. */
    speedAt(pos, t) {
      return field.speedAt(pos.x, pos.z, t);
    },

    /** {eta, dir, power} while a gust is inbound but hasn't risen yet, else null. */
    gustTelegraph(t) {
      return field.telegraph(t);
    },

    /**
     * Register wind shadows (trees, house). Lane A: call after the yard is built.
     * Until then there are simply no shadows — nothing breaks.
     * @param {Array<{x,z,radius,strength,length}>} list
     */
    setShelters(list) { field.setShelters(list); return wind; },

    /** Convenience: take shadows straight off world.anchors' tree entries. */
    setSheltersFromTrees(trees, o = {}) {
      return wind.setShelters(trees.map((tr) => ({
        x: tr.pos ? tr.pos.x : tr.x,
        z: tr.pos ? tr.pos.z : tr.z,
        radius: o.radius ?? tr.radius ?? 3,
        strength: o.strength ?? 0.45,
        length: o.length ?? 14,
      })));
    },

    /**
     * A site's wind funnels (SPRINT9 site_02). Lane A: call with the site JSON's
     * `wind.venturi` after building the yard. Empty/absent = no funnels, so
     * backyard_01 reads exactly as it always has.
     * @param {Array<{x,z,axis,gain,radius,sharp}>} list
     */
    setVenturi(list) { field.setVenturi(list); return wind; },
    get venturi() { return field.venturi; },

    /** Storm events fired in (a,b] — poll with (t-dt, t). Deterministic. */
    eventsBetween(a, b) { return field.eventsBetween(a, b); },

    /** 0..1 rain intensity — drives drop count and opacity. */
    rainAt(t) { return field.rainAt(t); },

    /** 0..1 hail intensity (SPRINT5 decision 13). Zero for a hail-free storm. */
    hailAt(t) { return field.hailAt(t); },

    /** Stone-size scalar — audio pitch, visual scale, damage weight. */
    get hailSize() { return field.hailSize; },

    /** Rain rate in real-world mm/hr. Ponding (decision 10) reads this. */
    rainMmPerHour(t) { return field.rainMmPerHour(t); },

    /** Real-world mm of water delivered over (t0,t1]. Multiply by
     *  RAIN_TIME_COMPRESSION cloth-side — see weather.core. */
    rainDepthMm(t0, t1) { return field.rainDepthMm(t0, t1); },

    /** Direction (radians, XZ plane from +X toward +Z) ignoring local effects. */
    dirAt(t) { return field.dirAt(t); },

    get duration() { return field.duration; },
    get gusts() { return field.gusts; },
    get def() { return field.def; },
    get seed() { return field.seed; },
    core: field,
  };

  return wind;
}
