#include <algorithm>
#include <cmath>
#include <memory>
#include <wx/dcclient.h>
#include <wx/dcmemory.h>
#include <wx/graphics.h>
#include <wx/pen.h>
#include <wx/brush.h>
#include <wx/font.h>
#include <wx/datetime.h>
#include <wx/filename.h>
#include <wx/filefn.h>
#include <wx/file.h>
#include <wx/image.h>
#include <AceLinux.h>
#include <RumPi/AlertManager.h>
#include "../../Headers/GUI/HomeScenePanel.h"
#include "../../Headers/Resources/SceneData.h"
#include "../../Headers/ClientUtilities.h"
#include "../../Headers/ClientConfig.h"

/**
* The reading pills along the bottom strip, in draw order. Naming the slots lets the per-pill logic (values,
* font-fit, status dots) read by role instead of a bare index; E_READING_PILL_COUNT is the number of pills.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*/
enum EReadingPill
{
	E_READING_PILL_TEMPERATURE = 0,	///< Temperature pill.
	E_READING_PILL_HUMIDITY,		///< Humidity pill.
	E_READING_PILL_AIR_QUALITY,		///< Air-quality pill (carries a status dot).
	E_READING_PILL_CO2,				///< CO2 pill (carries a status dot).
	E_READING_PILL_WIND,			///< Wind-speed pill.
	E_READING_PILL_COUNT			///< Number of reading pills (loop bound / array size).
};

/**
* Builds the scene panel: loads the tunable settings from ClientConfig, seeds and loads the day/night SVG art
* (falling back to embedded defaults), binds the paint/size/timer events, and starts the animation timer.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	HomeScenePanel* panel = new HomeScenePanel(notebook);
* @endcode
*
* @param [in] parent The parent window.
*/
HomeScenePanel::HomeScenePanel(wxWindow* parent) : wxPanel(parent, wxID_ANY)
{
	int animationFps;
	int animationIntervalMs;
	const ClientConfig& clientConfigData = ClientConfig::GetInstance();
	const wxString imagesFolder = clientConfigData.GetImagesFolder();

	myBaseCacheWidth = 0;
	myBaseCacheHeight = 0;
	myBaseCacheBlendBucket = -1;
	myWindPhase = 0.0;
	myCloudPhase = 0.0;
	mySceneAspect = DESIGN_WIDTH / 1080.0;

	//wxBG_STYLE_PAINT + our own buffered compositing lets us own every pixel without flicker.
	SetBackgroundStyle(wxBG_STYLE_PAINT);

	//Pull the user-tunable settings from the config file once.
	myLocationName = clientConfigData.GetLocation();
	myCo2FreshMax = clientConfigData.GetCo2FreshMax();
	myCo2ElevatedMax = clientConfigData.GetCo2ElevatedMax();
	myTempWarmAbove = clientConfigData.GetTempWarmAbove();
	myTempCoolBelow = clientConfigData.GetTempCoolBelow();
	myDuskGlowOpacity = clientConfigData.GetDuskGlowOpacity();
	myTemperatureTintOpacity = clientConfigData.GetTemperatureTintOpacity();
	myCloudOpacity = clientConfigData.GetCloudOpacity();
	myHazeOpacity = clientConfigData.GetHazeOpacity();
	myMistOpacity = clientConfigData.GetMistOpacity();
	myWindOpacity = clientConfigData.GetWindOpacity();
	myCo2FreshColour = clientConfigData.GetCo2FreshColour();
	myCo2ElevatedColour = clientConfigData.GetCo2ElevatedColour();
	myCo2HighColour = clientConfigData.GetCo2HighColour();

	//Load the scenes from the user's images folder, falling back to the embedded defaults. The folder is seeded with editable copies of the defaults on first run.
	CreateDefaultScenesIfMissing(imagesFolder);
	myDayScene = LoadSceneBundle(imagesFolder, "scene_day", SCENE_DAY_SVG);
	myNightScene = LoadSceneBundle(imagesFolder, "scene_night", SCENE_NIGHT_SVG);

	Bind(wxEVT_PAINT, &HomeScenePanel::OnPaint, this);
	Bind(wxEVT_SIZE, &HomeScenePanel::OnSize, this);
	Bind(wxEVT_TIMER, &HomeScenePanel::OnTimer, this);

	//Frame rate from config; default a zero/negative value, then clamp the interval to at least 1 ms so an
	//over-large FPS can't integer-divide to a 0 ms timer that fires continuously and pegs a core.
	myAnimationTimer.SetOwner(this);
	if (clientConfigData.GetAnimationFps() > 0)
	{
		animationFps = clientConfigData.GetAnimationFps();
	}
	else
	{
		animationFps = 20;
	}

	animationIntervalMs = 1000 / animationFps;
	if (animationIntervalMs < 1)
	{
		animationIntervalMs = 1;
	}

	myAnimationTimer.Start(animationIntervalMs);
}

/**
* Stores the latest sensor snapshot (on the UI thread) and schedules a repaint so the overlays reflect it.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	panel->Update(readings);
* @endcode
*
* @param [in] readings The latest curated sensor readings.
*/
void HomeScenePanel::Update(const RumPi::SelectedSensorReadings& readings)
{
	//Runs on the UI thread (called from OnRumPiUpdate). Store the immutable snapshot and repaint.
	myReadings = readings;
	Refresh(false);
}

/**
* Gets the current local time as a fractional number of hours (e.g. 15.5 for 15:30).
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	double hours = LocalTimeInHours();
* @endcode
*
* @return The local time, in hours.
*/
double HomeScenePanel::LocalTimeInHours() const
{
	wxDateTime now = wxDateTime::Now();
	return now.GetHour() + (now.GetMinute() / 60.0);
}

/**
* Computes how "night" it is now, from 0.0 (full day) to 1.0 (full night), using a sun-elevation proxy that peaks
* at noon.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	double night = NightAmount();
* @endcode
*
* @return The night amount, from 0.0 (day) to 1.0 (night).
*/
double HomeScenePanel::NightAmount() const
{
	//Sun-elevation proxy: peaks at noon, negative at night. Night amount is how far below full day we are.
	const double elevation = Ace::Sin(((LocalTimeInHours() - 6.0) / 12.0) * PI);
	const double daylight = Ace::Clamp(elevation, 0.0, 1.0);

	return 1.0 - daylight;
}

/**
* Picks the status-dot colour for a CO2 reading, using the configured fresh/elevated thresholds and (accessibility-
* overridable) colours.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	wxColour colour = Co2DotColour(650.0f);
* @endcode
*
* @param [in] co2 The CO2 reading, in ppm.
*
* @return The indicator colour for the reading's band.
*/
wxColour HomeScenePanel::Co2DotColour(float co2) const
{
	//Both the thresholds and the colours come from config (colours are overridable for accessibility).

	//Fresh
	if (co2 < myCo2FreshMax)
	{
		return myCo2FreshColour;
	}

	//Elevated
	if (co2 <= myCo2ElevatedMax)
	{
		return myCo2ElevatedColour;
	}

	//High
	return myCo2HighColour;
}

/**
* Picks the status-dot colour for an air-quality level, collapsing the six levels onto the same good/warning/bad
* palette as the CO2 dot so there is a single palette to retune for accessibility.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	wxColour colour = AirQualityDotColour(RumPi::E_AirQualityLevel::AIR_QUALITY_POOR);
* @endcode
*
* @param [in] airQualityLevel The air-quality level.
*
* @return The indicator colour for the level.
*/
wxColour HomeScenePanel::AirQualityDotColour(RumPi::E_AirQualityLevel airQualityLevel) const
{
	//Reuses the same three status colours as the CO2 dot, so there is a single palette to retune for
	//accessibility. The six air-quality levels collapse onto good / warning / bad.
	if (airQualityLevel <= RumPi::E_AirQualityLevel::AIR_QUALITY_SATISFACTORY)
	{
		return myCo2FreshColour;
	}

	if (airQualityLevel == RumPi::E_AirQualityLevel::AIR_QUALITY_MODERATELY_POLLUTED)
	{
		return myCo2ElevatedColour;
	}

	return myCo2HighColour;
}

/**
* Loads a scene image bundle, preferring a user-supplied file (svg, then raster) in the images folder and falling
* back to the embedded SVG art.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	wxBitmapBundle day = LoadSceneBundle(imagesFolder, "scene_day", SCENE_DAY_SVG);
* @endcode
*
* @param [in] folder      The images folder to look in.
* @param [in] baseName    The base file name (without extension) to look for.
* @param [in] embeddedSvg The embedded SVG to fall back to.
*
* @return The loaded bundle, or an empty bundle if the inputs were invalid.
*/
wxBitmapBundle HomeScenePanel::LoadSceneBundle(const wxString& folder, const wxString& baseName, const char* embeddedSvg) const
{
	//Prefer a user-supplied file (svg first, then raster) in the images folder; fall back to the embedded art.
	const wxSize defaultSize(1920, 1080);
	const char* extensions[] = { "svg", "png", "jpg", "jpeg" };

	if (folder.IsEmpty() == false && baseName.IsEmpty() == false && embeddedSvg != nullptr)
	{
		for (size_t extensionIndex = 0; extensionIndex < WXSIZEOF(extensions); extensionIndex++)
		{
			const wxString path = wxFileName(folder, baseName + "." + extensions[extensionIndex]).GetFullPath();
			if (wxFileExists(path) == true)
			{
				if (wxString(extensions[extensionIndex]) == "svg")
				{
					const wxBitmapBundle bundle = wxBitmapBundle::FromSVGFile(path, defaultSize);
					if (bundle.IsOk())
					{
						return bundle;
					}
				}
				else
				{
					const wxImage image(path);
					if (image.IsOk())
					{
						return wxBitmapBundle::FromBitmap(wxBitmap(image));
					}
				}
			}
		}

		return wxBitmapBundle::FromSVG(embeddedSvg, defaultSize);
	}
	else
	{
		RumPi::AlertManager::GetInstance().Report(RumPi::EAlertLevel::Alert, "Client HomeScenePanel::LoadSceneBundle() Error: folder, baseName, or embeddedSvg are empty!");
	}

	return wxBitmapBundle();
}

/**
* Seeds the images folder with editable copies of the default day/night SVG art, but only when the folder does not
* exist yet, so a user's own art is never clobbered.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	CreateDefaultScenesIfMissing(imagesFolder);
* @endcode
*
* @param [in] folder The images folder to seed.
*/
void HomeScenePanel::CreateDefaultScenesIfMissing(const wxString& folder) const
{
	wxFile dayFile;
	wxFile nightFile;

	//Ensure provided folder is not empty/blank.
	if (folder.IsEmpty() == false)
	{
		//Seed only when the folder does not exist yet, so the user gets editable copies of the defaults once and we never clobber art they have added later.
		if (wxDirExists(folder) == false && wxFileName::Mkdir(folder, wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL) == true)
		{
			if (dayFile.Create(wxFileName(folder, "scene_day.svg").GetFullPath(), false, wxS_DEFAULT) == true)
			{
				dayFile.Write(SCENE_DAY_SVG);
				dayFile.Close();
			}

			if (nightFile.Create(wxFileName(folder, "scene_night.svg").GetFullPath(), false, wxS_DEFAULT) == true)
			{
				nightFile.Write(SCENE_NIGHT_SVG);
				nightFile.Close();
			}
		}
	}
	else
	{
		RumPi::AlertManager::GetInstance().Report(RumPi::EAlertLevel::Alert, "Client HomeScenePanel::CreateDefaultScenesIfMissing() Error: provided folder path is empty!");
	}
}

/**
* Rebuilds the cached day/night-crossfaded base image, but only when the draw size or the bucketed blend has
* changed, so the SVG art is not re-rasterized every frame.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	EnsureBaseCache(coverWidth, coverHeight, blendBucket);
* @endcode
*
* @param [in] width       The draw width to build the cache at.
* @param [in] height      The draw height to build the cache at.
* @param [in] blendBucket The bucketed night amount (0 - 100) to crossfade at.
*/
void HomeScenePanel::EnsureBaseCache(int width, int height, int blendBucket)
{
	wxBitmap dayBitmap;

	//Check if anything has changed.
	if (myBaseCache.IsOk() == false || myBaseCacheWidth != width || myBaseCacheHeight != height || myBaseCacheBlendBucket != blendBucket)
	{
		//Check if dimensions are invalid before using width and height.
		if (width > 0 && height > 0)
		{
			//Cache needs to be built
			const double nightAmount = blendBucket / 100.0;

			wxBitmap composite(width, height, 32);
			wxMemoryDC memoryDC(composite);
			bool baseWasDrawn = false;
			std::unique_ptr<wxGraphicsContext> graphicsContext(wxGraphicsContext::Create(memoryDC));
			if (graphicsContext != nullptr)
			{
				//Draw the day base, then fade the night base over it by the night amount - a true crossfade.
				dayBitmap = myDayScene.GetBitmap(wxSize(width, height));
				graphicsContext->DrawBitmap(dayBitmap, 0, 0, width, height);

				//Only composite the night layer when there is some night to show; bucket 0 is full daylight.
				if (blendBucket > 0)
				{
					wxBitmap nightBitmap = myNightScene.GetBitmap(wxSize(width, height));
					graphicsContext->BeginLayer(nightAmount);
					graphicsContext->DrawBitmap(nightBitmap, 0, 0, width, height);
					graphicsContext->EndLayer();
				}

				baseWasDrawn = true;
			}

			//Release the bitmap from the DC before we use/cache it.
			memoryDC.SelectObject(wxNullBitmap);

			//Only publish the cache when the draw actually happened. If wxGraphicsContext::Create returned null,
			//`composite` is undrawn - leave the previous good cache (and its keys) so the next paint retries rather
			//than caching, and then blitting, a blank bitmap.
			if (baseWasDrawn == true)
			{
				myBaseCache = composite;
				myBaseCacheWidth = width;
				myBaseCacheHeight = height;
				myBaseCacheBlendBucket = blendBucket;
			}
		}
		else
		{
			RumPi::AlertManager::GetInstance().Report(RumPi::EAlertLevel::Alert, "Client HomeScenePanel::EnsureBaseCache() Error: width and/or height are negative!");
		}
	}
}

/**
* Size handler: schedules a repaint so the scene re-scales to the new panel size.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	//Invoked by wxWidgets when the panel is resized.
* @endcode
*
* @param [in] event The size event.
*/
void HomeScenePanel::OnSize(wxSizeEvent& event)
{
	Refresh(false);
	event.Skip();
}

/**
* Timer handler: advances the wind and cloud animation phases (a baseline drift plus an amount proportional to wind
* speed) and schedules a repaint.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	//Invoked on each animation-timer tick.
* @endcode
*
* @param [in] event The timer event.
*/
void HomeScenePanel::OnTimer(wxTimerEvent& event)
{
	//Advance the motion phases. Each gains a constant baseline drift (so there is gentle motion even in dead calm)
	//plus an amount proportional to wind speed (stronger wind scrolls faster). Design-space units/tick.
	const double windBaselineDrift = 2.0;
	const double windDriftPerMetrePerSecond = 1.6;
	const double cloudBaselineDrift = 0.25;
	const double cloudDriftPerMetrePerSecond = 0.12;

	//No wind sensor yields the NaN "no reading" sentinel; treat that (and any spurious negative) as dead calm.
	float wind = myReadings.GetWindSpeed();
	if (std::isnan(wind) == true || wind < 0.0f)
	{
		wind = 0.0f;
	}

	myWindPhase += windBaselineDrift + wind * windDriftPerMetrePerSecond;
	myCloudPhase += cloudBaselineDrift + wind * cloudDriftPerMetrePerSecond;

	Refresh(false);
}

/**
* Paint handler: cover-scales and blits the cached base, then composites the celestial body, atmosphere,
* streetlights, and info pills into a reused off-screen buffer and blits it once (flicker-free).
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	//Invoked by wxWidgets when the panel needs repainting.
* @endcode
*
* @param [in] event The paint event.
*/
void HomeScenePanel::OnPaint(wxPaintEvent& event)
{
	wxPaintDC drawContext(this);

	//Guard the whole composite: it allocates a full-screen buffer, creates a graphics context, and rasterizes the
	//scene every frame. A bad_alloc (realistic on a low-RAM Pi) must not escape this wx paint handler through the
	//GTK C callback - that would std::terminate. On failure, skip this frame's drawing rather than crashing.
	try
	{
		RenderScene(drawContext);
	}
	catch (...)
	{
	}
}

void HomeScenePanel::RenderScene(wxPaintDC& drawContext)
{
	int coverWidth;
	int coverHeight;
	const wxSize panelSize = GetClientSize();
	const int width = panelSize.GetWidth();
	const int height = panelSize.GetHeight();
	double panelAspect;
	int offsetX;
	int offsetY;
	int blendBucket;

	if (width > 0 && height > 0)
	{
		//Cover-scale the 16:9 base so it fills the panel with no bars (crop the overflow, centered).
		panelAspect = static_cast<double>(width) / static_cast<double>(height);
		if (panelAspect > mySceneAspect)
		{
			coverWidth = width;
			coverHeight = static_cast<int>(coverWidth / mySceneAspect + 0.5);
		}
		else
		{
			coverHeight = height;
			coverWidth = static_cast<int>(coverHeight * mySceneAspect + 0.5);
		}

		offsetX = (width - coverWidth) / 2;
		offsetY = (height - coverHeight) / 2;
		blendBucket = static_cast<int>(NightAmount() * 100.0 + 0.5);
		EnsureBaseCache(coverWidth, coverHeight, blendBucket);

		//Composite into a reused off-screen buffer, then blit once - no flicker.
		if (myFrameBuffer.IsOk() == false || myFrameBuffer.GetWidth() != width || myFrameBuffer.GetHeight() != height)
		{
			myFrameBuffer = wxBitmap(width, height, 32);

			//Clear the fresh buffer to a solid colour so that if wxGraphicsContext::Create fails on this same frame
			//(rare), the unconditional blit below shows that colour instead of uninitialized pixels - the same
			//"never present an undrawn buffer" guarantee as EnsureBaseCache.
			wxMemoryDC clearDC(myFrameBuffer);
			clearDC.SetBackground(*wxBLACK_BRUSH);
			clearDC.Clear();
			clearDC.SelectObject(wxNullBitmap);
		}

		//Do the actual panel drawing.
		wxMemoryDC memoryDC(myFrameBuffer);
		//unique_ptr so the context is freed on every exit, including if a Draw* throws (bad_alloc): OnPaint's
		//try/catch now catches that, so a raw delete here would be skipped and leak the context every frame.
		std::unique_ptr<wxGraphicsContext> graphicsContext(wxGraphicsContext::Create(memoryDC));
		if (graphicsContext != nullptr)
		{
			if (myBaseCache.IsOk() == true)
			{
				graphicsContext->DrawBitmap(myBaseCache, offsetX, offsetY, coverWidth, coverHeight);
			}

			DrawCelestialBody(graphicsContext.get(), width, height);
			DrawAtmosphere(graphicsContext.get(), width, height);
			DrawStreetlights(graphicsContext.get(), width, height);
			DrawPills(graphicsContext.get(), width, height);
		}
		memoryDC.SelectObject(wxNullBitmap);

		drawContext.DrawBitmap(myFrameBuffer, 0, 0);
	}
}

/**
* Draws the procedural sun (day) or moon (night) arcing across the sky by real local time, clipped to the sky so
* it slips behind the horizon as it rises and sets.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	DrawCelestialBody(graphicsContext, width, height);
* @endcode
*
* @param [in] graphicsContext The graphics context to draw into.
* @param [in] width           The draw width, in pixels.
* @param [in] height          The draw height, in pixels.
*/
void HomeScenePanel::DrawCelestialBody(wxGraphicsContext* graphicsContext, int width, int height)
{
	//Procedural sun (day) / moon (night) that arcs across the sky with real local time - rising in the east,
	//highest overhead at noon/midnight, setting in the west. The base art no longer bakes in a sun or moon, so
	//this owns the sky; over a user's own photo it would float, like the streetlights.
	double bodyX;
	double bodyY;
	double arcProgress;
	double glowRadius;
	double bodyRadius;
	const double designScale = width / DESIGN_WIDTH;
	const double localTimeInHours = LocalTimeInHours();
	const double horizonY = 700.0;
	const double arcHeight = 560.0;
	const bool isDaytime = (localTimeInHours >= 6.0 && localTimeInHours < 18.0);
	wxColour glowInner;
	wxColour glowOuter;
	wxGraphicsBrush glowBrush;

	//Progress runs 0 at rise (east horizon) to 1 at set (west horizon); the night arc bridges midnight.
	if (isDaytime)
	{
		arcProgress = (localTimeInHours - 6.0) / 12.0;
	}
	else if (localTimeInHours >= 18.0)
	{
		arcProgress = (localTimeInHours - 18.0) / 12.0;
	}
	else
	{
		arcProgress = (localTimeInHours + 6.0) / 12.0;
	}

	bodyX = arcProgress * DESIGN_WIDTH;
	bodyY = horizonY - Ace::Sin(arcProgress * PI) * arcHeight;

	//Clip to the sky so the body slips behind the horizon as it rises and sets, rather than sitting on the ground.
	graphicsContext->Clip(0, 0, width, static_cast<wxCoord>(horizonY * designScale));
	graphicsContext->SetPen(*wxTRANSPARENT_PEN);

	if (isDaytime)
	{
		glowRadius = 220.0;
		bodyRadius = 90.0;
		glowInner = wxColour(255, 233, 168, ClientUtilities::AlphaByte(0.5));
		glowOuter = wxColour(255, 233, 168, 0);
		glowBrush = graphicsContext->CreateRadialGradientBrush(bodyX * designScale, bodyY * designScale, bodyX * designScale, bodyY * designScale, glowRadius * designScale, glowInner, glowOuter);
		graphicsContext->SetBrush(glowBrush);
		graphicsContext->DrawEllipse((bodyX - glowRadius) * designScale, (bodyY - glowRadius) * designScale, (glowRadius * 2.0) * designScale, (glowRadius * 2.0) * designScale);
		graphicsContext->SetBrush(wxBrush(wxColour(255, 210, 77)));
		graphicsContext->DrawEllipse((bodyX - bodyRadius) * designScale, (bodyY - bodyRadius) * designScale, (bodyRadius * 2.0) * designScale, (bodyRadius * 2.0) * designScale);
	}
	else
	{
		glowRadius = 175.0;
		bodyRadius = 78.0;
		glowInner = wxColour(220, 231, 255, ClientUtilities::AlphaByte(0.32));
		glowOuter = wxColour(220, 231, 255, 0);
		glowBrush = graphicsContext->CreateRadialGradientBrush(bodyX * designScale, bodyY * designScale, bodyX * designScale, bodyY * designScale, glowRadius * designScale, glowInner, glowOuter);
		graphicsContext->SetBrush(glowBrush);
		graphicsContext->DrawEllipse((bodyX - glowRadius) * designScale, (bodyY - glowRadius) * designScale, (glowRadius * 2.0) * designScale, (glowRadius * 2.0) * designScale);
		graphicsContext->SetBrush(wxBrush(wxColour(234, 240, 255)));
		graphicsContext->DrawEllipse((bodyX - bodyRadius) * designScale, (bodyY - bodyRadius) * designScale, (bodyRadius * 2.0) * designScale, (bodyRadius * 2.0) * designScale);

		//A couple of soft craters for character.
		graphicsContext->SetBrush(wxBrush(wxColour(208, 218, 240, ClientUtilities::AlphaByte(0.6))));
		graphicsContext->DrawEllipse((bodyX - 26.0) * designScale, (bodyY - 20.0) * designScale, 30.0 * designScale, 30.0 * designScale);
		graphicsContext->DrawEllipse((bodyX + 10.0) * designScale, (bodyY + 14.0) * designScale, 22.0 * designScale, 22.0 * designScale);
	}

	graphicsContext->ResetClip();
}

/**
* Draws the reading-driven atmospheric overlays: dawn/dusk glow, temperature tint, humidity clouds, air-quality
* haze, humidity mist, and wind streaks, each mapped from its reading through a response curve.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	DrawAtmosphere(graphicsContext, width, height);
* @endcode
*
* @param [in] graphicsContext The graphics context to draw into.
* @param [in] width           The draw width, in pixels.
* @param [in] height          The draw height, in pixels.
*/
void HomeScenePanel::DrawAtmosphere(wxGraphicsContext* graphicsContext, int width, int height)
{
	double cloudAlpha;
	double currCenterX;
	double currCenterY;
	double hazeAlpha;
	double brownHazeAmount;
	unsigned char redBrownHazeAmount;
	unsigned char greenBrownHazeAmount;
	unsigned char blueBrownHazeAmount;
	double mistAlpha;
	double windAlpha;
	double windDrift;
	double windStreakY;
	double duskGlow;
	const double designScale = width / DESIGN_WIDTH;
	const double localTimeInHours = LocalTimeInHours();
	const float temperatureInC = myReadings.GetTemperatureCelsius();
	const float humidityPercent = myReadings.GetHumidityPercent();
	const float pmTwoPointFive = myReadings.GetPmTwoPointFive();
	const float windSpeed = myReadings.GetWindSpeed();
	const double centresX[3] = { 300.0, 950.0, 1500.0 };
	const double centresY[3] = { 235.0, 300.0, 185.0 };

	//Below this alpha an overlay is too faint to be worth drawing.
	const double minVisibleAlpha = 0.01;

	//Response curves: how each reading maps onto overlay intensity.
	const double dawnPeakHour = 6.6;         //Hour the dawn glow is strongest.
	const double dawnFalloffHours = 1.6;     //Hours either side of the peak over which it fades to nothing.
	const double duskPeakHour = 18.0;        //Hour the dusk glow is strongest.
	const double duskFalloffHours = 1.8;
	const double warmRampDegrees = 16.0;     //Degrees above the warm threshold to reach full warm tint.
	const double coolRampDegrees = 22.0;     //Degrees below the cool threshold to reach full cool tint.
	const double cloudHumidityOnset = 18.0;  //Humidity % at which clouds begin to appear.
	const double cloudHumidityRange = 82.0;  //Further humidity % to reach full cloud cover.
	const double hazePmRange = 150.0;        //PM2.5 (ug/m3) that maps to full haze opacity.
	const double hazeBrownPmRange = 120.0;   //PM2.5 that maps to the fully-brown haze tint.
	const double mistHumidityOnset = 55.0;   //Humidity % at which the mist band starts.
	const double mistHumidityRange = 45.0;   //Further humidity % to reach full mist.
	const double windSpeedRange = 32.0;      //Wind speed (m/s) that maps to full-strength streaks.

	//Clouds drift right and wrap; the wrap width is a bit wider than the design width so a cloud fully leaves
	//the right edge before reappearing on the left (no visible pop).
	double streakScreenX;
	const double cloudWrapWidth = DESIGN_WIDTH + 500.0;
	const double windStreakSpacing = 240.0;

	//Dawn/dusk warmth - a soft orange wash peaking around sunrise and sunset.
	const double dawn = Ace::Clamp(1.0 - Ace::AbsoluteValue(localTimeInHours - dawnPeakHour) / dawnFalloffHours, 0.0, 1.0);
	const double dusk = Ace::Clamp(1.0 - Ace::AbsoluteValue(localTimeInHours - duskPeakHour) / duskFalloffHours, 0.0, 1.0);

	//Determine which glow to use.
	if (dawn > dusk)
	{
		duskGlow = dawn;
	}
	else
	{
		duskGlow = dusk;
	}

	//Overlays are fills with no outline, except the wind streaks which set their own stroke.
	graphicsContext->SetPen(*wxTRANSPARENT_PEN);

	if (duskGlow > minVisibleAlpha)
	{
		graphicsContext->SetBrush(wxBrush(wxColour(242, 160, 90, ClientUtilities::AlphaByte(duskGlow * myDuskGlowOpacity))));
		graphicsContext->DrawRectangle(0, 0, width, height);
	}

	//Temperature tint - warm above the warm threshold, cool below the cool threshold.
	if (std::isnan(temperatureInC) == false)  //NaN is the "no reading" sentinel; a real sub-zero temperature displays.
	{
		if (temperatureInC >= myTempWarmAbove)
		{
			graphicsContext->SetBrush(wxBrush(wxColour(255, 122, 46, ClientUtilities::AlphaByte(Ace::Clamp((temperatureInC - myTempWarmAbove) / warmRampDegrees, 0.0, 1.0) * myTemperatureTintOpacity))));
			graphicsContext->DrawRectangle(0, 0, width, height);
		}
		else if (temperatureInC <= myTempCoolBelow)
		{
			graphicsContext->SetBrush(wxBrush(wxColour(63, 160, 230, ClientUtilities::AlphaByte(Ace::Clamp((myTempCoolBelow - temperatureInC) / coolRampDegrees, 0.0, 1.0) * myTemperatureTintOpacity))));
			graphicsContext->DrawRectangle(0, 0, width, height);
		}
	}

	//Humidity clouds - drifting clusters that thicken with humidity.
	if (humidityPercent >= 0.0f)
	{
		cloudAlpha = Ace::Clamp((humidityPercent - cloudHumidityOnset) / cloudHumidityRange, 0.0, 1.0) * myCloudOpacity;
		if (cloudAlpha > minVisibleAlpha)
		{
			graphicsContext->SetBrush(wxBrush(wxColour(255, 255, 255, ClientUtilities::AlphaByte(cloudAlpha))));

			for (int cloudIndex = 0; cloudIndex < 3; cloudIndex++)
			{
				currCenterX = std::fmod(centresX[cloudIndex] + myCloudPhase, cloudWrapWidth);
				currCenterY = centresY[cloudIndex];
				graphicsContext->DrawEllipse((currCenterX - 150.0) * designScale, (currCenterY - 58.0) * designScale, 300.0 * designScale, 116.0 * designScale);
				graphicsContext->DrawEllipse((currCenterX - 40.0) * designScale, (currCenterY - 78.0) * designScale, 240.0 * designScale, 150.0 * designScale);
				graphicsContext->DrawEllipse((currCenterX + 90.0) * designScale, (currCenterY - 52.0) * designScale, 220.0 * designScale, 108.0 * designScale);
			}
		}
	}

	//Air-quality haze - a veil that thickens and browns as PM2.5 rises.
	if (pmTwoPointFive >= 0.0f)
	{
		hazeAlpha = Ace::Clamp(pmTwoPointFive / hazePmRange, 0.0, 1.0) * myHazeOpacity;
		if (hazeAlpha > minVisibleAlpha)
		{
			brownHazeAmount = Ace::Clamp(pmTwoPointFive / hazeBrownPmRange, 0.0, 1.0);
			redBrownHazeAmount = static_cast<unsigned char>(207 + (180 - 207) * brownHazeAmount);
			greenBrownHazeAmount = static_cast<unsigned char>(202 + (154 - 202) * brownHazeAmount);
			blueBrownHazeAmount = static_cast<unsigned char>(189 + (120 - 189) * brownHazeAmount);
			graphicsContext->SetBrush(wxBrush(wxColour(redBrownHazeAmount, greenBrownHazeAmount, blueBrownHazeAmount, ClientUtilities::AlphaByte(hazeAlpha))));
			graphicsContext->DrawRectangle(0, 0, width, height);
		}
	}

	//Humidity mist - a low band that creeps up when the air is very damp.
	if (humidityPercent >= 0.0f)
	{
		mistAlpha = Ace::Clamp((humidityPercent - mistHumidityOnset) / mistHumidityRange, 0.0, 1.0) * myMistOpacity;
		if (mistAlpha > minVisibleAlpha)
		{
			graphicsContext->SetBrush(wxBrush(wxColour(234, 241, 242, ClientUtilities::AlphaByte(mistAlpha))));
			graphicsContext->DrawRectangle(0, height * 0.62, width, height * 0.38);
		}
	}

	//Wind streaks - faint lines that scroll faster and brighter with wind speed.
	if (windSpeed >= 0.0f)
	{
		windAlpha = Ace::Clamp(windSpeed / windSpeedRange, 0.0, 1.0) * myWindOpacity;
		if (windAlpha > minVisibleAlpha)
		{
			graphicsContext->SetPen(wxPen(wxColour(255, 255, 255, ClientUtilities::AlphaByte(windAlpha)), std::max(1, static_cast<int>(4.0 * designScale))));

			windDrift = std::fmod(myWindPhase, windStreakSpacing);
			for (int row = 0; row < 7; row++)
			{
				windStreakY = (150.0 + row * 78.0) * designScale;
				for (double windStreakX = -windStreakSpacing; windStreakX < DESIGN_WIDTH + windStreakSpacing; windStreakX += windStreakSpacing)
				{
					streakScreenX = (windStreakX + windDrift) * designScale;
					graphicsContext->StrokeLine(streakScreenX, windStreakY, streakScreenX + 150.0 * designScale, windStreakY + 16.0 * designScale);
				}
			}
		}
	}
}

/**
* Draws the default scene's lamp posts, lit by the TSL2591 light level (brighter the darker it gets) and guarded on
* a present light sensor so they only appear when there is a real reading.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	DrawStreetlights(graphicsContext, width, height);
* @endcode
*
* @param [in] graphicsContext The graphics context to draw into.
* @param [in] width           The draw width, in pixels.
* @param [in] height          The draw height, in pixels.
*/
void HomeScenePanel::DrawStreetlights(wxGraphicsContext* graphicsContext, int width, int height)
{
	//Procedural default-scene flourish: lamp posts along the town that light up when it gets dark, driven by
	//the TSL2591 light level. Guarded on lux >= 0 so it only appears when a light sensor is actually present
	//(the level enum has no "no reading" value and would otherwise default to LIGHT_DARK - lamps blazing).
	//The post positions line up with the built-in art; over a user's own photo they would just float.
	double glowAmount = -1.0;
	double currLampX;
	const double designScale = width / DESIGN_WIDTH;
	const double lampCentresX[3] = { 480.0, 980.0, 1480.0 };
	const double postTopY = 720.0;
	const double postBottomY = 905.0;
	const double postHalfWidth = 5.0;
	const double bulbRadius = 13.0;
	const double glowRadius = 70.0;

	const wxColour glowOuter(255, 220, 140, 0);
	wxColour glowInner;
	wxGraphicsBrush glowBrush;

	if (myReadings.GetLightLux() >= 0.0f)
	{
		//Lamps come on at or below "very dim", brighter the darker it gets; off from "dim" upward.
		if (myReadings.GetLightLevel() == RumPi::E_LightLevel::LIGHT_DARK)
		{
			glowAmount = 1.0;
		}
		else if (myReadings.GetLightLevel() == RumPi::E_LightLevel::LIGHT_VERY_DIM)
		{
			glowAmount = 0.65;
		}

		if (glowAmount != -1.0)
		{
			//Glow brightness scales with darkness (glowAmount), so build the inner colour now that it is known.
			glowInner = wxColour(255, 220, 140, ClientUtilities::AlphaByte(glowAmount * 0.6));

			graphicsContext->SetPen(*wxTRANSPARENT_PEN);

			for (int lampIndex = 0; lampIndex < 3; lampIndex++)
			{
				currLampX = lampCentresX[lampIndex];

				//Lamp Post.
				graphicsContext->SetBrush(wxBrush(wxColour(38, 43, 54)));
				graphicsContext->DrawRectangle((currLampX - postHalfWidth) * designScale, postTopY * designScale, (postHalfWidth * 2.0) * designScale, (postBottomY - postTopY) * designScale);

				//Warm glow (radial: bright at the bulb, fading to transparent), scaled by how dark it is.
				glowBrush = graphicsContext->CreateRadialGradientBrush(currLampX * designScale, postTopY * designScale, currLampX * designScale, postTopY * designScale, glowRadius * designScale, glowInner, glowOuter);
				graphicsContext->SetBrush(glowBrush);
				graphicsContext->DrawEllipse((currLampX - glowRadius) * designScale, (postTopY - glowRadius) * designScale, (glowRadius * 2.0) * designScale, (glowRadius * 2.0) * designScale);

				//Light Bulb.
				graphicsContext->SetBrush(wxBrush(wxColour(255, 238, 178)));
				graphicsContext->DrawEllipse((currLampX - bulbRadius) * designScale, (postTopY - bulbRadius) * designScale, (bulbRadius * 2.0) * designScale, (bulbRadius * 2.0) * designScale);
			}
		}
	}
}

/**
* Draws the glanceable info pills: the location and clock along the top, and a bottom strip of reading pills
* (temperature, humidity, air quality, CO2, wind) with colour-coded status dots on the air-quality and CO2 pills.
*
* @author Eddie O'Hagan
* @date 8/13/2026
*
* @code
*	DrawPills(graphicsContext, width, height);
* @endcode
*
* @param [in] graphicsContext The graphics context to draw into.
* @param [in] width           The draw width, in pixels.
* @param [in] height          The draw height, in pixels.
*/
void HomeScenePanel::DrawPills(wxGraphicsContext* graphicsContext, int width, int height)
{
	double pillWidth;
	double pillHeight;
	double pillX;
	double pillY;
	const double designScale = width / DESIGN_WIDTH;
	const double stripSideMargin = 46.0 * designScale;
	const double pillSpacing = 22.0 * designScale;
	const wxColour pillBackground(16, 21, 30, 150);
	const wxColour textColour(244, 247, 251);
	const wxColour labelColour(214, 221, 230);
	const wxFont labelFont(wxSize(0, std::max(9, static_cast<int>(28.0 * designScale))), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_NORMAL);
	const wxFont valueFont(wxSize(0, std::max(11, static_cast<int>(44.0 * designScale))), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD);
	const wxFont nameFont(wxSize(0, std::max(12, static_cast<int>(50.0 * designScale))), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD);
	const wxString clock = wxDateTime::Now().Format("%H:%M");
	const double statusDotDiameter = 22.0 * designScale;
	wxString airQuality = wxString::FromUTF8(myReadings.GetAirQuality().c_str());
	wxString pillValuesAsStrings[E_READING_PILL_COUNT];
	double valueTextWidth;
	double valueTextHeight;
	double maxValueWidth;
	int fittedPixelSize;
	wxFont fittedValueFont;

	//Reading pills (bottom strip), one per EReadingPill slot.
	const int pillCount = E_READING_PILL_COUNT;
	const wxString labels[E_READING_PILL_COUNT] = { "Temperature", "Humidity", "Air quality", "CO2", "Wind" };

	graphicsContext->SetPen(*wxTRANSPARENT_PEN);

	//Location pill (top-left).
	pillWidth = 360.0 * designScale;
	pillHeight = 100.0 * designScale;
	pillX = 60.0 * designScale;
	pillY = 58.0 * designScale;
	graphicsContext->SetBrush(wxBrush(pillBackground));
	graphicsContext->DrawRoundedRectangle(pillX, pillY, pillWidth, pillHeight, pillHeight * 0.5);
	graphicsContext->SetFont(nameFont, textColour);
	graphicsContext->DrawText(myLocationName, pillX + 32.0 * designScale, pillY + 22.0 * designScale);

	//Clock pill (top-right).
	pillWidth = 260.0 * designScale;
	pillHeight = 100.0 * designScale;
	pillX = width - pillWidth - 60.0 * designScale;
	pillY = 58.0 * designScale;
	graphicsContext->SetBrush(wxBrush(pillBackground));
	graphicsContext->DrawRoundedRectangle(pillX, pillY, pillWidth, pillHeight, pillHeight * 0.5);
	graphicsContext->SetFont(nameFont, textColour);
	graphicsContext->DrawText(clock, pillX + 44.0 * designScale, pillY + 22.0 * designScale);

	if (airQuality.IsEmpty())
	{
		airQuality = "N/A";
	}

	pillValuesAsStrings[E_READING_PILL_TEMPERATURE] = ClientUtilities::FormatReading(myReadings.GetTemperatureCelsius(), "°C", 0);
	pillValuesAsStrings[E_READING_PILL_HUMIDITY]    = ClientUtilities::FormatReading(myReadings.GetHumidityPercent(), "%", 0);
	pillValuesAsStrings[E_READING_PILL_AIR_QUALITY] = airQuality;
	pillValuesAsStrings[E_READING_PILL_CO2]         = ClientUtilities::FormatReading(myReadings.GetCO2(), "ppm", 0);
	pillValuesAsStrings[E_READING_PILL_WIND]        = ClientUtilities::FormatReading(myReadings.GetWindSpeed(), "m/s", 1);

	pillHeight = 118.0 * designScale;
	pillY = height - pillHeight - 34.0 * designScale;
	pillWidth = (width - 2.0 * stripSideMargin - (pillCount - 1) * pillSpacing) / pillCount;
	for (int pillIndex = 0; pillIndex < pillCount; pillIndex++)
	{
		pillX = stripSideMargin + pillIndex * (pillWidth + pillSpacing);

		graphicsContext->SetBrush(wxBrush(pillBackground));
		graphicsContext->DrawRoundedRectangle(pillX, pillY, pillWidth, pillHeight, pillHeight * 0.42);

		graphicsContext->SetFont(labelFont, labelColour);
		graphicsContext->DrawText(labels[pillIndex], pillX + 30.0 * designScale, pillY + 20.0 * designScale);

		//Shrink the value font if the text would overrun the pill (e.g. a long "Moderately Polluted"), leaving
		//room for the status dot on the pills that have one. Measure at the base font, then scale down to fit.
		if (pillIndex == E_READING_PILL_AIR_QUALITY || pillIndex == E_READING_PILL_CO2)
		{
			maxValueWidth = pillWidth - statusDotDiameter - 66.0 * designScale;
		}
		else
		{
			maxValueWidth = pillWidth - 60.0 * designScale;
		}

		fittedValueFont = valueFont;
		graphicsContext->SetFont(fittedValueFont, textColour);
		graphicsContext->GetTextExtent(pillValuesAsStrings[pillIndex], &valueTextWidth, &valueTextHeight);
		if (valueTextWidth > maxValueWidth && valueTextWidth > 0.0)
		{
			fittedPixelSize = std::max(11, static_cast<int>(44.0 * designScale * (maxValueWidth / valueTextWidth)));
			fittedValueFont = wxFont(wxSize(0, fittedPixelSize), wxFONTFAMILY_DEFAULT, wxFONTSTYLE_NORMAL, wxFONTWEIGHT_BOLD);
			graphicsContext->SetFont(fittedValueFont, textColour);
		}

		graphicsContext->DrawText(pillValuesAsStrings[pillIndex], pillX + 30.0 * designScale, pillY + 58.0 * designScale);

		//The air quality and CO2 pills each carry a colour-coded status dot. Guard on the paired reading
		//(non-empty category / non-negative ppm) since the level enums have no "no reading" value.
		if (pillIndex == E_READING_PILL_AIR_QUALITY && airQuality != "N/A")
		{
			graphicsContext->SetBrush(wxBrush(AirQualityDotColour(myReadings.GetAirQualityLevel())));
			graphicsContext->DrawEllipse(pillX + pillWidth - statusDotDiameter - 28.0 * designScale, pillY + 26.0 * designScale, statusDotDiameter, statusDotDiameter);
		}

		if (pillIndex == E_READING_PILL_CO2 && myReadings.GetCO2() >= 0.0f)
		{
			graphicsContext->SetBrush(wxBrush(Co2DotColour(myReadings.GetCO2())));
			graphicsContext->DrawEllipse(pillX + pillWidth - statusDotDiameter - 28.0 * designScale, pillY + 26.0 * designScale, statusDotDiameter, statusDotDiameter);
		}
	}
}
