"""
sun_azimuth_time_finder.py
======================
Find the local clock time(s) when the sun reaches a specified azimuth,
for a given latitude, longitude, and date.

No third-party libraries required — only Python's standard `math` and
`datetime` modules.

Accuracy: typically within 1–5 minutes of true solar time, limited by:
  - Solar declination formula    (Spencer 1971, accurate to ~0.3 deg)
  - Equation of Time formula     (Spencer 1971, accurate to ~0.5 min)
  - Hour-angle scan step         (0.01 deg ~ 2-3 seconds of time)

Method:
  The sun's azimuth (clockwise from North) and altitude are computed from
  the observer's latitude, the solar declination, and the hour angle H
  (degrees west of the meridian; positive = afternoon, negative = morning).
  The code scans H across the daylight window and interpolates to find
  the exact crossing of the target azimuth.

Usage:
    python sun_azimuth_noephem.py
"""

import math
from datetime import date, datetime, timedelta


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

DEG = math.pi / 180   # degrees -> radians
RAD = 180 / math.pi   # radians -> degrees


# ---------------------------------------------------------------------------
# Time-zone helper (US Eastern; swap for your own zone as needed)
# ---------------------------------------------------------------------------

def us_eastern_offset(d):
    """UTC offset in hours for US Eastern time on date d.
    Returns -4 (EDT) during DST, -5 (EST) otherwise.
    DST: second Sunday in March to first Sunday in November.
    """
    year = d.year
    march1 = date(year, 3, 1)
    dst_start = march1 + timedelta(days=(6 - march1.weekday()) % 7 + 7)
    nov1 = date(year, 11, 1)
    dst_end = nov1 + timedelta(days=(6 - nov1.weekday()) % 7)
    return -4 if dst_start <= d <= dst_end else -5


# ---------------------------------------------------------------------------
# Solar geometry
# ---------------------------------------------------------------------------

def day_of_year(d):
    """Day-of-year (1 = Jan 1)."""
    return d.timetuple().tm_yday


def solar_declination(doy):
    """Solar declination in degrees for day-of-year doy.
    Spencer (1971) Fourier series, accurate to ~0.3 deg.
    """
    B = 2 * math.pi * (doy - 1) / 365
    dec_rad = (0.006918
               - 0.399912 * math.cos(B)
               + 0.070257 * math.sin(B)
               - 0.006758 * math.cos(2 * B)
               + 0.000907 * math.sin(2 * B)
               - 0.002697 * math.cos(3 * B)
               + 0.001480 * math.sin(3 * B))
    return dec_rad * RAD


def equation_of_time(doy):
    """Equation of Time in minutes for day-of-year doy.
    Positive = true sun ahead of mean sun.
    Spencer (1971), accurate to ~0.5 min.
    """
    B = 2 * math.pi * (doy - 1) / 365
    eot_rad = (0.000075
               + 0.001868 * math.cos(B)
               - 0.032077 * math.sin(B)
               - 0.014615 * math.cos(2 * B)
               - 0.040890 * math.sin(2 * B))
    return eot_rad * 229.18   # convert to minutes


def solar_altitude(lat_deg, dec_deg, H_deg):
    """Solar altitude (elevation above horizon) in degrees.

    lat_deg : observer latitude  (degrees, + = North)
    dec_deg : solar declination  (degrees)
    H_deg   : hour angle (degrees, + = afternoon/west, - = morning/east)
    """
    lat, dec, H = lat_deg * DEG, dec_deg * DEG, H_deg * DEG
    sin_alt = (math.sin(lat) * math.sin(dec)
               + math.cos(lat) * math.cos(dec) * math.cos(H))
    return math.asin(max(-1.0, min(1.0, sin_alt))) * RAD


def solar_azimuth(lat_deg, dec_deg, H_deg):
    """Solar azimuth in degrees, clockwise from North (0-360).

    Uses the formula (Meeus, "Astronomical Algorithms", Ch. 13):
        tan(A) = sin(H) / (cos(H)*sin(phi) - tan(delta)*cos(phi))
    with a +180 deg shift so 0=North, 90=East, 180=South, 270=West.

    H_deg > 0  ->  afternoon (sun west of meridian)  ->  azimuth > 180 deg
    H_deg < 0  ->  morning   (sun east of meridian)  ->  azimuth < 180 deg
    """
    lat, dec, H = lat_deg * DEG, dec_deg * DEG, H_deg * DEG
    num = math.sin(H)
    den = math.cos(H) * math.sin(lat) - math.tan(dec) * math.cos(lat)
    return (math.atan2(num, den) * RAD + 180.0) % 360.0


# ---------------------------------------------------------------------------
# Hour-angle scan: find crossings of target azimuth
# ---------------------------------------------------------------------------

def find_hour_angles(lat_deg, dec_deg, target_az, step_deg=0.01):
    """Return hour angles (degrees) at which the sun's azimuth equals target_az.

    Scans H from -90 deg (6 h before noon) to +90 deg (6 h after noon) in
    steps of step_deg, and interpolates linearly at each upward crossing of
    target_az. Points where the sun is more than 2 deg below the horizon are
    skipped, but crossings that occur right at the horizon are retained as
    long as the interpolated altitude is >= 0.

    Returns a list sorted ascending (negative = AM, positive = PM).
    """
    results = []
    prev_az = prev_H = prev_alt = None

    n_steps = int(90.0 / step_deg)
    for i in range(-n_steps, n_steps + 1):
        H = i * step_deg
        alt = solar_altitude(lat_deg, dec_deg, H)

        # Skip when clearly below horizon; 2 deg buffer catches edge cases
        if alt < -2.0:
            prev_az = prev_H = prev_alt = None
            continue

        az = solar_azimuth(lat_deg, dec_deg, H)

        if prev_az is not None:
            # Ignore discontinuities from atan2 wrap-around near sunrise/sunset
            if abs(az - prev_az) > 180:
                prev_az = prev_H = prev_alt = None
                continue

            # Upward crossing of target_az
            if prev_az < target_az <= az:
                frac = (target_az - prev_az) / (az - prev_az)
                H_exact = prev_H + frac * step_deg
                alt_exact = prev_alt + frac * (alt - prev_alt)
                if alt_exact >= 0.0:
                    results.append(round(H_exact, 4))

        prev_az, prev_H, prev_alt = az, H, alt

    return results   # sorted ascending because we scan left-to-right


# ---------------------------------------------------------------------------
# Hour angle -> local clock time
# ---------------------------------------------------------------------------

def hour_angle_to_local_time(H_deg, lon_deg, doy, utc_offset):
    """Convert a solar hour angle to a local clock time.

    Derivation:
        Local Solar Time  LST = 12h + H/15          [hours]
        UTC               = LST - EoT/60 - lon/15   [lon in degrees East]
        Local clock       = UTC + utc_offset

    Combined:
        clock = 12 + H/15 - EoT/60 - lon/15 + utc_offset

    lon_deg uses the East-positive convention (negative for West).
    The date in the returned datetime is nominal (1900-01-01);
    only the time component is meaningful.
    """
    eot_hours = equation_of_time(doy) / 60.0
    clock_hours = (12.0
                   + H_deg / 15.0
                   - eot_hours
                   - lon_deg / 15.0
                   + utc_offset)
    return datetime(1900, 1, 1) + timedelta(minutes=round(clock_hours * 60))


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

def find_azimuth_times(lat, lon, target_az, d, utc_offset_fn=None):
    """Find local clock times when the sun reaches target_az on date d.

    Parameters
    ----------
    lat        : latitude in decimal degrees  (+ = North, - = South)
    lon        : longitude in decimal degrees (+ = East,  - = West)
    target_az  : azimuth in degrees clockwise from North (0-360)
    d          : datetime.date
    utc_offset_fn : callable(date) -> int giving UTC offset in hours.
                    Defaults to us_eastern_offset (US Eastern, auto DST).

    Returns
    -------
    List of dicts sorted AM before PM, each containing:
        'local_time'   : datetime (1900-01-01 base; only time is meaningful)
        'hour_angle'   : float, degrees (negative = AM, positive = PM)
        'altitude_deg' : solar altitude at the crossing (degrees)
        'period'       : 'AM' or 'PM'
        'utc_offset'   : int hours
        'tz_label'     : e.g. 'EST', 'EDT', or 'UTC-5'
    """
    if utc_offset_fn is None:
        utc_offset_fn = us_eastern_offset

    utc_offset = utc_offset_fn(d)
    doy = day_of_year(d)
    dec = solar_declination(doy)

    results = []
    for H in find_hour_angles(lat, dec, target_az):
        alt = solar_altitude(lat, dec, H)
        t = hour_angle_to_local_time(H, lon, doy, utc_offset)
        tz_label = {-4: 'EDT', -5: 'EST'}.get(utc_offset, f'UTC{utc_offset:+d}')
        results.append({
            'local_time':   t,
            'hour_angle':   H,
            'altitude_deg': round(alt, 2),
            'period':       'AM' if H < 0 else 'PM',
            'utc_offset':   utc_offset,
            'tz_label':     tz_label,
        })
    return results


def azimuth_time_orig(lat, lon, target_az, d, utc_offset_fn=None):
    """Return the local time when the sun reaches target_az on date d.
       Returns None if the sun never reaches that azimuth above the horizon.
    """
    crossings = find_azimuth_times(lat, lon, target_az, d, utc_offset_fn)
    return crossings[0]['local_time'] if crossings else None
    
    
    
def azimuth_time(target_azimuth, latitude=None, longitude=None, reference_date=None, utc_offset_fn=None):
    
    """Returns the local time as datetime value when the sun reaches target_az on date d.
       If values not supplied for latitude or longitude, uses those from zone.home.
       If date not supplied, uses current date.
       Returns None if the sun never reaches that azimuth above the horizon.
    """
    
    from datetime import date
    
    if latitude == None:
        latitude = state.get("zone.home.latitude")
        
    if longitude == None:
        longitude = state.get("zone.home.longitude")
    
    if reference_date == None:
        reference_date = date.today()
        
    log.info(f"\nComputing azimuth time for date {reference_date}, latitude {latitude}, longitude {longitude}, azimuth {target_azimuth}")
    
    crossings = find_azimuth_times(latitude, longitude, target_azimuth, reference_date, utc_offset_fn)
    
    if crossings:
        crossing_time = crossings[0]['local_time']
        time_string = crossing_time.strftime('%H:%M:%S')
        log.info(f"\nAzimuth crossing time {time_string}")
        return crossing_time
    else:
        log.info(f"\nNo azimuth crossing for this date")
        return None
        
    #return crossings[0]['local_time'] if crossings else None


# ---------------------------------------------------------------------------
# Home Assistant Services
# ---------------------------------------------------------------------------

@service
def set_entity_to_azimuth_time(entity_id, azimuth, latitude=None, longitude=None, date=None):
    
    """Sets the supplied entity to the local time when the sun reaches azimuth on date reference_date.
       If values not supplied for latitude or longitude, uses those from zone.home.
       If date not supplied, uses current date.
       If the sun never reaches that azimuth above the horizon, leaves entity unchanged
    """
    if date:
        date = datetime.strptime(date, "%Y-%m-%d").date()
    
    crossing_time = azimuth_time(azimuth, latitude, longitude, date)
    
    if crossing_time:
        time_string = crossing_time.strftime('%H:%M:%S')
    
        # Use set_datetime to update the entity
        input_datetime.set_datetime(entity_id=entity_id, time=time_string)
        log.info(f"\nUpdated {entity_id} to {time_string}")
        
    else:
        log.info(f"\nNo azimuth crossing for azimuth {azimuth} for this date; entity {entity_id} not changed")



@service
def set_entity_to_azimuth_time_or_sunset(entity_id, azimuth):
    
    """Sets the supplied entity to the local time when the sun reaches target_az on the current date
       for the latitude and longitude configured in zone.home.
       If the sun never reaches that azimuth above the horizon, sets the entity to the sunset time.
    """

    crossing_time = azimuth_time(azimuth)
    
    if crossing_time:
        time_string = crossing_time.strftime('%H:%M:%S')
        log.info(f"\nUpdated {entity_id} to {time_string} (sun at specified azimuth)")
    else:
        sunset_time_utc = sun.sun.next_setting
        # Convert to a readable local datetime object
        sunset_time = datetime.fromisoformat(sunset_time_utc).astimezone()
        time_string = sunset_time.strftime('%H:%M:%S')
        log.info(f"\nUpdated {entity_id} to {time_string} (sunset time)")
    
    # Use set_datetime to update the entity
    input_datetime.set_datetime(entity_id=entity_id, time=time_string)
    
    
@service
def set_entity_to_azimuth_time_or_sunrise(entity_id, azimuth):

    """Sets the supplied entity to the local time when the sun reaches target_az on the current date
       for the latitude and longitude configured in zone.home.
       If the sun never reaches that azimuth above the horizon, sets the entity to the sunrise time.
    """
    
    crossing_time = azimuth_time(azimuth)
    
    if crossing_time:
        time_string = crossing_time.strftime('%H:%M:%S')
        log.info(f"\nUpdated {entity_id} to {time_string} (sun at specified azimuth)")
    else:
        sunrise_time_utc = sun.sun.next_rising
        # Convert to a readable local datetime object
        sunrise_time = datetime.fromisoformat(sunrise_time_utc).astimezone()
        time_string = sunrise_time.strftime('%H:%M:%S')
        log.info(f"\nUpdated {entity_id} to {time_string} (sunrise time)")
    
    # Use set_datetime to update the entity
    input_datetime.set_datetime(entity_id=entity_id, time=time_string)
    
    
