"""Google Calendar integration applet for Winston.

This module provides a comprehensive calendar management interface using the
Google Calendar API. Features include:

- Event creation, editing, deletion, and restoration
- Multi-day event handling with day-by-day navigation
- Meeting link extraction (Zoom, Google Meet, Teams)
- Calendar sharing and access control
- Response status management (accepted, declined, tentative)
- Offline support with cached events
- Participant management with autocomplete
- Hotel booking detection from Gmail integration

The DiaryDialog class provides the main UI with keyboard shortcuts for
navigation, search, and event management.
"""
import sys
import threading
import inspect
import webbrowser
import datetime
from datetime  import timedelta,timezone,datetime as dt
from dateutil import parser
from ..ordinal_date import ord_day, ord_date, ord_short_date, ord_long_date
import os
import re
from .. import debugging
from ..google_api import create_service, execute_with_retry, request_with_retry,GoogleDialog, connection_error, HTTPErrorString
from ..framework import beep,ensure_dir_exists
from ..google_contacts import edit_emails_string, ContactInputDialog
import pytz  # type: ignore[import-untyped]
import copy
from ..key_handler import KeyHandler
from ..form_elements import SingleTextElement,NumberElement,ListElement,BooleanElement,ButtonElement
from ..elements import ValuesElement
import json
from functools import partial
from typing import Any
def key_from_event_dt(tz: Any, event: dict[str, Any]) -> str:
    """Generate ISO format sort key from event start date/time.

    Args:
        tz: pytz timezone object for calendar timezone.
        event: Google Calendar event dict with 'start' field.

    Returns:
        ISO format datetime string for sorting events.
    """
    if 'dateTime' in event['start']:
        k = parser.isoparse(event['start']['dateTime'])
    else:
        k=tz.localize(dt.combine(parser.isoparse(event['start']['date']),dt.min.time()))
    return k.isoformat()
def next_state(button_name,states):
    state=button_name.split(':')[1].strip()
    if state in states:
        next_index = (states.index(state)+1)%len(states)
        state= states[next_index]
    else:
        beep()
    return ': '.join([button_name.split(':')[0],state])    
def on_add_participant(button: ButtonElement) -> None:
    """Add participant to event using contact selection dialog.

    Args:
        button: ButtonElement instance that triggered the callback.
    """
    participants_index = button.dialog.index_from_name('Participants')  # type: ignore[attr-defined]
    participants = button.dialog[participants_index].value.rstrip(';')  # type: ignore[attr-defined,index]
    participants=edit_emails_string(button.dialog,emails_string=participants)  # type: ignore[arg-type]
    button.dialog[participants_index].value = participants  # type: ignore[attr-defined,index]
    button.dialog.move(participants_index,len(button.dialog[participants_index].value),False,silent=False)  # type: ignore[attr-defined,index]


def on_status(button: ButtonElement) -> None:
    """Cycle through response status states for event.

    Args:
        button: ButtonElement instance that triggered the callback.
    """
    states = ['accepted','declined','tentative','needsAction']
    # Once have provided a response cannot return state to needsAction
    if not button.name.endswith('needsAction'):
        states.remove('needsAction')
    button.name = next_state(button.name, states)  # type: ignore[attr-defined]
    button.dialog.say_strings(button.name)  # type: ignore[attr-defined]


def on_whole_day(button: ButtonElement) -> None:
    """Toggle whole-day event status and show/hide time fields.

    Args:
        button: ButtonElement instance that triggered the callback.
    """
    dialog = button.dialog  # type: ignore[attr-defined]
    button.name = name = next_state(button.name, ['Yes','No'])  # type: ignore[attr-defined]
    if name.endswith('Yes'):
        # Starting from bottom and working up prevents removal causing iteration to miss next element
        for i in reversed(range(len(dialog))):  # type: ignore[arg-type]
            e = dialog[i]  # type: ignore[index]
            if 'time' in e.name.lower():
                dialog.remove(e)  # type: ignore[attr-defined]
    else:
        # Add time fields back
        # Access start and end from parent DiaryDialog (dialog.parent)
        this_time, end_time = [bound.strftime('%H:%M') for bound in (dialog.parent.start, dialog.parent.end)]  # type: ignore[attr-defined,union-attr]
        for i in reversed(range(len(dialog))):  # type: ignore[arg-type]
            e = dialog[i]  # type: ignore[index]
            if 'Date' in e.name:
                end = 'End' in e.name
                index = dialog.index(e) + 1  # type: ignore[attr-defined]
                dialog.insert(index, copy.copy(e))  # type: ignore[attr-defined]
                dialog[index].name = e.name.replace('Date','Time')  # type: ignore[index,union-attr]
                dialog[index].value = this_time if not end else end_time  # type: ignore[index,union-attr]
    dialog.set_from_name(name)  # type: ignore[attr-defined]


def on_focus_select(element: SingleTextElement) -> None:
    """Select all text in current element when focused.

    Args:
        element: FormElement instance that gained focus.
    """
    element.command_select_all('Control+A')


def on_title_validate(element: SingleTextElement) -> str | bool:
    """Validate title is not blank.

    Args:
        element: FormElement instance being validated.

    Returns:
        True if valid, error string if invalid.
    """
    #title = element.dialog.fields()['Title']  # type: ignore[attr-defined]
    return True if len(element.value.strip()) else 'title cannot be blank'


def on_end_validate(element: Any) -> str | bool:
    """Validate end date/time is after start date/time.

    Updates parent DiaryDialog's end and event_duration attributes.

    Args:
        element: FormElement instance being validated.

    Returns:
        True if valid, error string if invalid.
    """
    parent = element.dialog.parent  # type: ignore[attr-defined]
    fields = element.dialog.fields()  # type: ignore[attr-defined]

    start_time = fields.get('Time', '00:00')
    start = make_date_time(fields['Date'], start_time, get_calendar_timezone(parent.service))  # type: ignore[attr-defined]

    end_time = fields.get('End Time', '00:00')
    end = make_date_time(fields['End Date'], end_time, get_calendar_timezone(parent.service))  # type: ignore[attr-defined]

    if isinstance(end, str):
        # Select the text for correction and return the error message
        element.command_select_all('Control+A')
        return end
    elif isinstance(start, str):
        # Select the text for correction and return the error message
        element.command_select_all('Control+A')
        return start
    else:
        event_duration = end - start
        if event_duration.total_seconds() < 0:
            # Select the text for correction and return the error message
            element.command_select_all('Control+A')
            return 'invalid event end date and time ; cannot be less than event start date and time'
        else:
            parent.end = end  # type: ignore[attr-defined]
            parent.event_duration = event_duration  # type: ignore[attr-defined]
    return True


def on_start_validate(element: Any) -> str | bool:
    """Validate start date/time and update end time to maintain duration.

    Updates parent DiaryDialog's start attribute and calls update_end().

    Args:
        element: FormElement instance being validated.

    Returns:
        True if valid, error string if invalid.
    """
    parent = element.dialog.parent  # type: ignore[attr-defined]
    fields = element.dialog.fields()  # type: ignore[attr-defined]

    start_time = fields.get('Time', '00:00')
    start = make_date_time(fields['Date'], start_time, get_calendar_timezone(parent.service))  # type: ignore[attr-defined]

    if isinstance(start, str):
        # Select the text for correction and return the error message
        element.command_select_all('Control+A')
        return start

    parent.start = start  # type: ignore[attr-defined]
    parent.update_end()  # type: ignore[attr-defined]
    return True


def on_participants_validate(element: Any) -> str | bool:
    """Validate participant email addresses and update status.

    Ensures all participants have well-formed emails. Updates status if
    calendar owner is added to or removed from participant list.

    Args:
        element: FormElement instance being validated.

    Returns:
        True if valid, error string with invalid email if validation fails.
    """
    parent = element.dialog.parent  # type: ignore[attr-defined]
    dialog = element.dialog  # type: ignore[attr-defined]

    status = None
    status_element = [e for e in dialog if e.name.startswith('Going:')][0]  # type: ignore[attr-defined]
    old_status = status_element.name.split(':')[-1].strip()

    participants = dialog.fields()['participants'].split(';')  # type: ignore[attr-defined]
    participants = [a.strip() for a in participants if len(a)]

    if not len(participants):
        status = 'NoParticipantList'
    else:
        valid_email = r'^(\s*([A-Za-z.\s-]+)?\s*<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:\.[A-Za-z]{2,})?)>|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:\.[A-Za-z]{2,})?))(\s*;\s*(\s*([A-Za-z.\s]+)?\s*<([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:\.[A-Za-z]{2,})?)>|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:\.[A-Za-z]{2,})?)))*$'
        for email in participants:
            if not re.match(valid_email, email, re.IGNORECASE):
                # Select the text for correction and return the error message
                element.command_select_all('Control+A')
                return f"Invalid: {email}"

    if not any([parent.is_owner_email(a) for a in participants]):  # type: ignore[attr-defined]
        status = 'notAttending'
    elif old_status in ('NoParticipantList', 'notAttending'):
        status = 'needsAction'

    if status:
        status_element.name = f"Going: {status}"

    return True


def validate_event(event: dict[str, Any]) -> None:
    """DEBUG: Placeholder validation function. Not currently used."""
    # Your validation logic here
    print(f"Validating with dialog: {dialog} and event: {event}")  # type: ignore[name-defined]

def test(dialog: Any) -> None:
    """DEBUG: Test button callback. Not currently used."""
    print(f'pressed {dialog.child.current().name}  in dialog {dialog.child}')

def new_template(event_date: dt, tz: Any) -> dict[str, Any]:
    """Create template dict for new calendar event.

    Creates 30-minute event starting at 9:00 AM on specified date.

    Args:
        event_date: datetime object for event date (timezone-aware or naive).
        tz: pytz timezone object for calendar timezone.

    Returns:
        Event dict with start/end dateTime and timezone fields.
    """
    start_dt =event_date +timedelta(hours=9,minutes=0)
    end_dt =event_date +timedelta(hours=9,minutes=30)
    start = start_dt.isoformat()+'Z'
    end = end_dt.isoformat()+'Z'
    return {'start':{'dateTime':start,'timezone':tz.zone},'end':{'dateTime':end,'timezone':tz.zone}}

def is_multi_day(event: dict[str, Any]) -> bool:
    """Check if event is multi-day based on 'day' key presence.

    The 'day' key is added by expanded_events() for multi-day events.

    Args:
        event: Event dict (may be original event or pseudo-event).

    Returns:
        True if event has 'day' key (multi-day event or pseudo-event).
    """
    return 'day' in event

def set_status(dialog: Any, event: dict[str, Any], status: str) -> None:
    """Set calendar owner's response status in event participants list.

    Updates responseStatus for participant matching calendar owner email.
    Does not use 'self' key since event may be from details dialog before
    Google API adds it automatically.

    Args:
        dialog: DiaryDialog instance with is_owner_email() method.
        event: Event dict with 'participants' list.
        status: Response status ('accepted', 'declined', 'tentative', 'needsAction').
    """
    participants = event.get('participants',[])
    for i  in range(len(participants)):
        #print(f"dict for participant {i}: {participants[i]}")
        #do not use the 'self' key as this participant entry is derived from the details dialog and so does not yet contain the 'self' key which will be automatically added on the insert/update, so need to manually check against the owner of the primary calendar
        if dialog.is_owner_email(participants[i]['email']):
            event['participants'][i]['responseStatus'] = status
            #print(f"participant {i} status now set to {event['participants'][i]['responseStatus']}")
def get_participants(dialog: Any, event: dict[str, Any]) -> str:
    """Get semicolon-separated string of event participants.

    Filters out resource participants (rooms, equipment). Formats as
    "Name <email>" or just email if no display name.

    Args:
        dialog: DiaryDialog instance (not currently used).
        event: Event dict with optional 'participants' list.

    Returns:
        Semicolon-separated participant string, or empty string if none.
    """
    #contacts = [c.split(':') for c in getattr(dialog.app,'contacts',[])]
    participants=[]
    for a in event.get('participants',[]):
        if not 'resource' in a:
            a_email = a['email']
            a_name = a.get('displayName',None)
            a_text = f"{a_name} <{a_email}>" if a_name else a_email
            participants.append(a_text)
    return '; '.join(participants)

def get_status(event: dict[str, Any]) -> str:
    """Get calendar owner's response status from event participants.

    Finds participant with 'self' key (calendar owner) and returns their
    responseStatus. Defaults to 'Not Attending' if participants exist but
    owner not found, or 'NoParticipantList' if no participants.

    Args:
        event: Event dict with optional 'participants' list.

    Returns:
        Response status string: 'accepted', 'declined', 'tentative',
        'needsAction', 'Not Attending', or 'NoParticipantList'.
    """
    participants = event.get('participants',[])
    status = 'Not Attending' if len(participants) else 'NoParticipantList'
    if me_list:=[a for a in participants if 'self' in a]:
        status=me_list[0]['responseStatus']
    return status

def extract_meeting_uri(text: str) -> str | bool:
    """Extract meeting link from text (Zoom, Google Meet, Teams).

    Searches for video conferencing URLs using regex pattern.

    Args:
        text: Text to search (location or description field).

    Returns:
        Meeting URL string if found, False otherwise.

    Note:
        Returns False instead of None for backwards compatibility.
    """
    conf_pattern = r'https?://(?:[\w-]+\.)*?(zoom\.[\w-]+/j/|meet\.google\.com/|teams\.microsoft\.com/)[^\s,;?]+(?:\?[^\s,;]*)?'
    match = re.search(conf_pattern, text)  # Use re.search instead of re.match as link may not be at start of text
    return False if not match else match.group(0)  # Return the matched group

def is_hotel_booking(event: dict[str, Any], multi_day: bool) -> bool | None:
    """Check if event is hotel booking from Gmail integration.

    Gmail-created hotel bookings start with "Stay at " and have source URL
    pointing to Gmail. Google Calendar API has a bug calculating end dates
    for these events.

    Args:
        event: Event dict with optional 'summary' and 'source' fields.
        multi_day: Whether event spans multiple days.

    Returns:
        True if hotel booking, None otherwise (implicit False).
    """
    if multi_day and event.get('summary','').startswith('Stay at ') :
        if isinstance(event.get('source',None),dict):
            return'//mail.google.com/mail' in event['source'].get('url','')
    return None

def is_whole_day(event: dict[str, Any]) -> bool:
    """Check if event is whole-day (all-day) event.

    Whole-day events use 'date' field instead of 'dateTime' in start/end.

    Args:
        event: Event dict with 'start' field.

    Returns:
        True if event has 'date' field in start (whole-day event).
    """
    return 'date' in event['start']
def expanded_events(events: list[dict[str, Any]], tz: Any) -> list[dict[str, Any]]:
    """Expand multi-day events into pseudo-events for each day.

    Multi-day events (>1 day duration) are split into one entry per day.
    Original event gets ":day: 1 of N" suffix, pseudo-events created for
    remaining days. Each event gets 'sort_key' field for sorting.

    Args:
        events: List of Google Calendar event dicts.
        tz: pytz timezone object for calendar timezone.

    Returns:
        Sorted list of events with pseudo-events for multi-day events.
        Each event has added fields: 'sort_key', and for multi-day events:
        'day' (1 to N), 'length' (total days).

    Note:
        Hotel bookings and timed events ending at midnight get length-1
        to compensate for Google Calendar API bug.
    """
    key_func=partial(key_from_event_dt,tz)
    modified_events = []
    for event in events:
        # Directly append the original event to the list
        event['sort_key'] = key_func(event)
        modified_events.append(event)
        # Parse start and end times for arithmetic
        start = get_date_time(event['start'],tz)
        end = get_date_time(event['end'],tz)
        # Calculate the duration of the event to identify multi-day events
        length = (end.date() - start.date()).days+1
        if ('date' not in event['start']) and (end.time()==dt.min.time()) or is_hotel_booking(event,(length>1)):
        #decrement number of days if the event ends at midnight and not a whole day event, or if this is a hotel_booking where google calendar api has a bug in calculating end date
            length-=1
        if length > 1:
            summary = event['summary']
            #print(f"when expanding events list, found {length} day event: {summary}")
            event['summary']=summary+f':day: 1 of {length}'
            event['day'] = 1
            event['length']=length
            #range upper bound (in this case day+1) is always one beyond the max value of the iterator
            for day in range(2, length+1):
                pseudo_event = event.copy()
                pseudo_event['summary']=summary+f':day: {day} of {length}'
                pseudo_event['day'] = day
                pseudo_start_date = start.date() + timedelta(days=day-1)
                start_of_day = tz.localize(dt.combine(pseudo_start_date,dt.min.time()))
                #print(f"day {day} start of day {start_of_day}")
                pseudo_event['sort_key'] = start_of_day.isoformat()
                modified_events.append(pseudo_event)
    modified_events.sort(key=lambda x: x['sort_key'])
    return modified_events
def get_calendar(service: Any, calendar_id: str = 'primary') -> Any:
    """Build Google Calendar API request for calendar metadata.

    Args:
        service: Google Calendar API service instance.
        calendar_id: Calendar ID (default 'primary').

    Returns:
        Request object for calendarList().get() API call.
    """
    return service.calendarList().get(calendarId=calendar_id)

def get_calendar_timezone(service: Any, calendar_id: str = 'primary') -> Any:
    """Get pytz timezone object for calendar.

    Fetches calendar metadata and extracts timeZone field. Defaults to UTC
    if service is None or request fails.

    Args:
        service: Google Calendar API service instance (may be None if offline).
        calendar_id: Calendar ID (default 'primary').

    Returns:
        pytz timezone object for calendar timezone, or UTC if unavailable.
    """
    calendar={}
    if service:
        calendar = request_with_retry(get_calendar,service,calendar_id=calendar_id)
        if connection_error(calendar):
            calendar={}
    timezone_str = calendar.get('timeZone', 'UTC')  # Defaulting to UTC if calendar  key not found or timezone key not found
    return pytz.timezone(timezone_str)
def formatted_date_time(service: Any, user_date: str, user_time: str, calendar_id: str = 'primary') -> str:
    """
    Converts a user supplied date and time to an RFC3339 datetime string based on the calendar's timezone.
    The user_date can be in 'dd/mm', 'dd/mm/yy', or 'dd/mm/yyyy' format. If the year is not provided, the current year is used.
    Parameters:
    - service: Authenticated Google Calendar service instance.
    - user_time: Time string in 'HH:MM' format.
    - user_date: Date string in 'dd/mm', 'dd/mm/yy', or 'dd/mm/yyyy' format.
    - calendar_id: ID of the Google Calendar (default is 'primary').
    Returns:
    - An RFC3339 datetime string of the user supplied time and date in the calendar's timezone.
    """
    # Fetch the timezone of the calendar
    timezone = get_calendar_timezone(service, calendar_id)
    # Determine the current year
    current_year = dt.now().year
    # Parse the user_date to include the current year if not provided
    try:
        # Try parsing with year
        user_datetime = dt.strptime(user_date + '/' + str(current_year), '%d/%m/%Y')
    except ValueError:
        try:
            # Try parsing without specifying year, assumes it's included in the user_date
            user_datetime = dt.strptime(user_date, '%d/%m/%Y')
        except ValueError:
            # Fallback for 'dd/mm/yy' format
            user_datetime = dt.strptime(user_date, '%d/%m/%y')
            user_datetime = user_datetime.replace(year=user_datetime.year if user_datetime.year <= current_year else user_datetime.year - 100)
    # Ensure the datetime is set in the correct timezone
    user_datetime = timezone.localize(user_datetime)
    # Combine user date with user time
    user_time_datetime = dt.strptime(user_time, '%H:%M')
    user_datetime = user_datetime.replace(hour=user_time_datetime.hour, minute=user_time_datetime.minute)
    # Convert to RFC3339 format
    return user_datetime.isoformat()
def get_date_time(event_dt: dict[str, Any], tz: Any) -> dt:
    """Parse event start/end dict to timezone-aware datetime.

    Handles both timed events (with 'dateTime') and whole-day events (with
    'date'). Ensures result is timezone-aware using calendar timezone.

    Args:
        event_dt: Event start or end dict with 'dateTime' or 'date' field.
        tz: pytz timezone object for calendar timezone.

    Returns:
        Timezone-aware datetime object.

    Note:
        Whole-day events use date at midnight in calendar timezone.
    """
    # Check if the event_dt dict  has 'dateTime' or 'date' and parse accordingly
    if 'dateTime' in event_dt:
        event_time = parser.isoparse(event_dt['dateTime'])
    else: # it's an all-day event, which uses 'date'
        event_time = parser.isoparse(event_dt['date']).replace(tzinfo=tz)
    if event_time.tzinfo is None:
        event_time= tz.localize(event_time)
    return  event_time  
def fetch_events(service: Any, period_start: str, period_end: str, query: str | None, calendar_id: str = 'primary') -> Any:
    """Build Google Calendar API request to list events.

    Args:
        service: Google Calendar API service instance.
        period_start: RFC3339 timestamp for range start.
        period_end: RFC3339 timestamp for range end.
        query: Optional search query string (None for all events).
        calendar_id: Calendar ID (default 'primary').

    Returns:
        Request object for events().list() API call.
    """
    if not query:
        request = service.events().list(calendarId=calendar_id, timeMin=period_start, timeMax =period_end,maxResults=2500, singleEvents=True,orderBy='startTime')
    else:
        request = service.events().list(calendarId=calendar_id, timeMin=period_start, timeMax =period_end,maxResults=2500, singleEvents=True,orderBy='startTime',q=query)
    return request

def delete_event(service: Any, event_id: str | None = None, calendar_id: str = 'primary') -> Any:
    """Build Google Calendar API request to delete event.

    Args:
        service: Google Calendar API service instance.
        event_id: Event ID to delete.
        calendar_id: Calendar ID (default 'primary').

    Returns:
        Request object for events().delete() API call with sendUpdates='all'.
    """
    request =  service.events().delete(eventId=event_id,calendarId=calendar_id,sendUpdates='all')
    return request

def create_event(service: Any, event: dict[str, Any] | None = None, calendar_id: str = 'primary') -> Any:
    """Build Google Calendar API request to create event.

    Args:
        service: Google Calendar API service instance.
        event: Event dict to create.
        calendar_id: Calendar ID (default 'primary').

    Returns:
        Request object for events().insert() API call with conferenceDataVersion=1
        and sendUpdates='all'.
    """
    return service.events().insert(calendarId=calendar_id, body=event,conferenceDataVersion=1,sendUpdates='all')

def update_event(service: Any, event_id: str | None = None, event: dict[str, Any] | None = None, calendar_id: str = 'primary') -> Any:
    """Build Google Calendar API request to update event.

    Args:
        service: Google Calendar API service instance.
        event_id: Event ID to update.
        event: Updated event dict.
        calendar_id: Calendar ID (default 'primary').

    Returns:
        Request object for events().update() API call with conferenceDataVersion=1
        and sendUpdates='all'.
    """
    #print(f'updating with event id:  {event_id}, and event {event}')
    return service.events().update(calendarId=calendar_id, eventId=event_id,body=event,conferenceDataVersion=1,sendUpdates='all')     
def make_date_time(date: str, time: str | None = None, tz: Any = None) -> datetime.date | dt | str:
    """Parse user-supplied date and optional time to date or datetime object.

    Accepts date formats: dd/mm, dd/mm/yy, dd/mm/yyyy.
    If year omitted, current year is used.
    Time format: HH:MM or HH (minutes default to :00).

    Args:
        date: Date string in dd/mm[/yy[yy]] format.
        time: Optional time string in HH:MM or HH format.
        tz: pytz timezone object (required if time specified).

    Returns:
        datetime.date if time not specified,
        timezone-aware datetime if time specified,
        or error string if parsing fails.

    Note:
        Returns string (not exception) on error for display to user.
    """
    # Parse the date
    if len(date.split('/')) == 2:  # dd/mm format
        date += f"/{dt.now().year}"  # Append current year if not specified
    try:
        dt_date = dt.strptime(date, "%d/%m/%Y").date()
    except ValueError:
        try:
            #try with 2 digit rather than 4 digit year
            dt_date = dt.strptime(date, "%d/%m/%y").date()
        except ValueError:
            return f'Invalid date format: {date}'
    if not time:
        return dt_date
    # Parse the time
    if not ':' in time:
        time+=':00'
    try:
        dt_time = dt.strptime(time, "%H:%M").time()
    except ValueError:
        return f'Invalid time format: {time}'
    return tz.localize(dt.combine(dt_date, dt_time))
def get_details(dialog,populator,title):
    dialog.start = get_date_time(populator['start'],get_calendar_timezone(dialog.service))
    dialog.end = get_date_time(populator['end'],get_calendar_timezone(dialog.service))
    dialog.event_duration = dialog.end-dialog.start 
    this_date,end_date=[d_and_t.strftime('%d/%m/%y') for d_and_t in (dialog.start,dialog.end)]
    this_time,end_time=[d_and_t.strftime('%H:%M') for d_and_t in (dialog.start,dialog.end)]
    status = get_status(populator)
    participants_string = get_participants(dialog,populator) if populator.get('id') else ''
    fields = [
        [SingleTextElement,'Title',populator.get('summary',""),None,on_title_validate],
        [SingleTextElement,'Date',this_date,on_focus_select,on_start_validate],
        [SingleTextElement,'Time',this_time,on_focus_select,on_start_validate],
        [SingleTextElement,'End Time',end_time,on_focus_select,on_end_validate],
        [SingleTextElement,'End Date',end_date,on_focus_select,on_end_validate],
        [ButtonElement,f"Whole Day: {'Yes' if 'date' in populator['start'] else 'No'}",on_whole_day],
        [SingleTextElement,'participants',participants_string,None,on_participants_validate],
        [ButtonElement,'Add participant',on_add_participant],
        [ButtonElement,f"Going: {status}",on_status],
        [SingleTextElement,'Location',populator.get('location','')],
        [SingleTextElement,'Description',populator.get('description','')],
        #[ButtonElement,'Add participant',partial(test,dialog)],
        [ButtonElement,'OK'],
        ]
    return dialog.input_values(title=title,fields=fields,say_labels=True,empty_value='',validate_func=None)
def build_event(dialog,populator,title):   
    details= get_details(dialog,populator,title)
    if details is False or not details['Title']:
        return f"{title} cancelled"
    participants = details['participants'].split(';')
    event = {
        'summary': details['Title'].split(':day:')[0],
        'participants': [{'email': participant.split(':')[-1].strip()} for participant in participants if participant],
        'reminders': {'useDefault': True},
        'location':details.get('Location',''),             
        'description':details.get('Description',''),             
    }
    if not ('Whole Day: Yes' in details):
        start_datetime = make_date_time(details['Date'],details['Time'],get_calendar_timezone(dialog.service))
        end_datetime = make_date_time(details['End Date'],details['End Time'],get_calendar_timezone(dialog.service))        
        #str value returned from make_date_time is an error message so pass it on 
        if isinstance(start_datetime,str) :
            return start_datetime 
        if isinstance(end_datetime,str) :
            return end_datetime             
        tz = get_calendar_timezone(dialog.service)
        event['start']=  {
            'dateTime': start_datetime.isoformat(),
            'timeZone': tz.zone,  # Specify the appropriate timezone
        }
        event['end']= {
            'dateTime': end_datetime.isoformat(),
            'timeZone': tz.zone,  
        }
    else:
        start_date=make_date_time(details['Date'])
        end_date=make_date_time(details['End Date'])        
        #str value returned from make_date_time is an error message so pass it on 
        if isinstance(start_date,str) :
            return start_date 
        if isinstance(end_date,str) :
            return end_date             
        event['start'] = {'date': start_date.strftime('%Y-%m-%d')}
        event['end'] = {'date': end_date.strftime('%Y-%m-%d')}        
    status = [name.split(':')[-1].strip() for name in details if name.startswith('Going:')][0] 
    #print(f'status when updating event: {status}')
    if status not in ['needsAction', 'NoParticipantList']:
        set_status(dialog,event,status)
    if 'id' in populator:
        #print(f"adding id to event as this is edit not new: {populator['id']}")
        event['id'] = populator['id']
    else:
        #print('add conference link if this is a new event') 
        event['conferenceData']={'createRequest': {'requestId': f"{dt.now().isoformat()}Z", 'conferenceSolutionKey': {'type': 'hangoutsMeet'}}}    
        #event['conferenceData']=dialog.current().event['conferenceData']
    return event             
def add_or_update(dialog,populator,title):
    error_result=None
    event = build_event(dialog,populator,title)
    if isinstance(event,str):
        #this is an error response not an event so just say the error 
        dialog.say_strings(f"{event}")  
    else:
        save_changes(dialog,event)
def save_changes(dialog,event):
    id=None
    #event = event.copy()
    if 'sort_key' in event:
        #sort_key only used when sorting the events preparatory to filling the dialog so should have already removed it as it is not a field that google api recognises (this may be overkill as google api may just ignore fields it does not recognise 
        raise TypeError(f'sort_key has not been removed from {event}')
    if  'id' not in event:
        response =request_with_retry(create_event,dialog.service,event=event)
    else:
        id=event['id']
        response = request_with_retry(update_event,dialog.service,event_id=id, event=event)  
    if not connection_error(response):
        dialog.say_strings('updating calendar')
        if not id:
            id = response['id']
        mid_point = get_date_time(event['start'],tz = get_calendar_timezone(dialog.service))
        #supply None position to avoid refill overriding the centre-ing up that happens in DiaryDialog.generate_content
        #print(f"id to locate is {id}")
        dialog.refill(filler=(mid_point,60,None,id),position=None)

class EventElement(ValuesElement):
    """Calendar event element with multi-field display and browser commands.

    Displays event with date, time, title, status, meeting link, organizer,
    and participants. Customizes speech output based on cursor movement and
    multi-day event status.

    Attributes:
        event: Google Calendar event dict.
        event_dt: Timezone-aware datetime for event start (for sorting/comparison).

    Hotkeys:
        Control+H: Open event in Google Calendar web UI.
        Control+M: Open meeting link in browser (Zoom/Meet/Teams).

    Methods:
        __str__(): Returns formatted speech string for current/say_current().
        command_view_html(): Open event HTML link in browser thread.
        command_open_meeting(): Open meeting URL in browser thread.
    """
    def __init__(self, *args: Any, event: dict[str, Any] | None = None, event_dt: dt | None = None, **kwargs: Any):
        self.event = event
        self.event_dt = event_dt
        super().__init__(*args,**kwargs)

    def __str__(self) -> str:
        """Format event for speech output.

        Omits date if moving one position and date unchanged from previous.
        For multi-day events, shortens date to just day portion if omitting.

        Returns:
            Comma-separated string of date (conditional), time, and title.
        """
        values = []
        d = self.dialog
        if self.event_dt and (not ((abs(d.cursor-d.previous_cursor)==1) and (self.event_dt.date()==d[d.previous_cursor].event_dt.date()))):
            values=[self.values[0]]
        else:
            if self.event and is_multi_day(self.event):
                values = [self.values[0].split(': ')[-1]]
        values.extend(self.values[1:3])
        return ', '.join(values)

    @KeyHandler.hotkey("Control+H")
    def command_view_html(self, modified_name: str) -> None:
        """Open event in Google Calendar web UI (background thread)."""
        if self.event:
            event_url = self.event['htmlLink']
            threading.Thread(target=webbrowser.open, args=(event_url,)).start()

    @KeyHandler.hotkey("Control+M")
    def command_open_meeting(self, modified_name: str) -> None:
        """Open meeting link in browser (background thread).

        Extracts URL from 'Meeting Link' field (Zoom, Google Meet, Teams).
        """
        index = self.dialog.get_headers().index('Meeting Link')  # type: ignore[attr-defined]
        meeting_url = self.values[index]
        threading.Thread(target=webbrowser.open, args=(meeting_url,)).start()
class DiaryDialog(GoogleDialog):
    """Google Calendar integration dialog with event management.

    Provides comprehensive calendar interface with:
    - Event creation, editing, deletion, and restoration
    - Multi-day event display with day-by-day entries
    - Meeting link extraction and opening
    - Calendar sharing and ACL management
    - Response status management
    - Search and goto date navigation
    - Offline support with cached events

    Hotkeys:
        Control+N: Create new event
        Control+E: Edit current event
        Delete: Delete event (Shift+Delete to restore)
        Control+R: Update response status
        Control+T: Go to today
        Control+G: Go to specific date
        Control+F: Find in current view (Shift+Control+F for 2-year search)
        Control+F2: Share calendar (Shift+Control+F2 to list/revoke shares)
        Control+H: Open event in web browser (on EventElement)
        Control+M: Open meeting link (on EventElement)
        Prior/Next: Navigate by month

    Attributes:
        owner_email: Email address of primary calendar owner.
        filler: Tuple of (mid_datetime, window_days, query_string, event_id).

    Methods:
        See individual method docstrings for details.
    """
    def refill(self, *args: Any, **kwargs: Any) -> None:
        """Refill dialog content and speak current element."""
        super().refill(*args,**kwargs)
        self.say_current()

    def get_api_name_and_version(self) -> tuple[str, str]:
        """Return Google API name and version for service creation.

        Returns:
            Tuple of ('calendar', 'v3').
        """
        return ('calendar','v3')

    def preliminaries(self) -> None:
        """Fetch calendar owner email from primary calendar metadata."""
        self.owner_email: str | None = None
        self.start: dt  # Event start datetime (set by get_details)
        self.end: dt  # Event end datetime (set by get_details)
        self.event_duration: timedelta  # Event duration (set by get_details)
        if self.service:
            request = self.service.calendarList().get(calendarId='primary')
            primary_calendar = execute_with_retry(request)
            if not connection_error(primary_calendar):
                self.owner_email = primary_calendar.get('id')

    def element_attributes(self) -> set[str]:
        """Declare required attributes for DiaryDialog elements.

        Returns:
            Set of attribute names including 'event' and 'event_dt' from parent classes.
        """
        attrs = super().element_attributes()
        attrs.update({'event', 'event_dt'})  # EventElement-specific attributes
        return attrs

    def generate_content(self,filler=[],element_type=ValuesElement):
        """
        filler = tuple of (datetime object, window size, query string) or optionally also has event_id as fourth item
        """
        from ..secrets_paths import get_diary_cache_path_with_fallback
        diary_cache_path = get_diary_cache_path_with_fallback()
        ensure_dir_exists(os.path.dirname(diary_cache_path))
        tz = get_calendar_timezone(self.service)        
        initial_load = (filler is None) 
        if not filler:
            filler = (tz.localize(dt.now()),60,None)
        self.filler=filler
        event_id = None if len(filler)!=4 else filler[3]
        mid_date_time = self.filler[0]
        half_window= int((filler[1]+1)/2)
        start_date = dt.combine((mid_date_time-datetime.timedelta(days=half_window)).date(),dt.min.time())  
        end_date = dt.combine((mid_date_time+datetime.timedelta(days=half_window)).date(),dt.max.time())          
        # Convert dates to RFC3339 timestamp format, as required by Google Calendar API
        time_min = start_date.isoformat().split('+')[0] + 'Z'  # 'Z' indicates UTC time
        time_max = end_date.isoformat().split('+')[0] + 'Z'
        selection_event = None 
        if self.service:
            events = request_with_retry(fetch_events,self.service, period_start=time_min, period_end=time_max,query=filler[2])
        else:
            events = None
        if (events is None) or connection_error(events):
            with open(diary_cache_path,'r') as file:
                events = json.load(file)
        else:
            events_list=events.get('items',[])
            events = expanded_events(events_list,tz)
            if initial_load:
                with open(diary_cache_path,'w',encoding='utf-8') as f:
                    json.dump(events,f)
        for event in events:
            start = get_date_time(event['start'],tz)
            end = get_date_time(event['end'],tz)            
            this_date = start 
            if is_multi_day(event):
                end = start+datetime.timedelta(days=(event['length']-1))
                this_date =(start+datetime.timedelta(days=(event['day']-1)))
            #else:
                #print(f"not multi_day: {event.get('summary')}")
            event_date = ord_date(this_date) if this_date.year==dt.now().year else ord_long_date(this_date)  
            if is_multi_day(event):
                event_date = f"{event_date}: {ord_day(start)} to {ord_day(end)}"
            #print(f"end before datetime test is {end}")
            if 'dateTime' in event['start']:
                start_time,end_time= [bound.strftime('%H:%M') for bound in (start,end)] 
                end_time = 'midnight' if end_time=='00:00' else end_time  
                if not is_multi_day(event):
                    event_time = ' to '.join([start_time,end_time]) 
                else:
                    #print(f"contstructing event_time when day is {event['day']}")
                    if event['day']==1: 
                        event_time = f"starting at {start_time}"
                    elif event['day']==event['length']: 
                        event_time = f"ending at {end_time}"                    
                    else:
                        event_time = ''
            else:
                event_time='whole day'
            summary=event.get('summary','no title')
            meeting_uri = ''
            if event.get('conferenceData',None) and event['conferenceData'].get('entryPoints',None) and len(event['conferenceData']['entryPoints']):
                for ep in event['conferenceData']['entryPoints']: 
                    if ep.get('entryPointType',None)=='video':
                        meeting_uri = ep.get('uri','')
                        break
            #override the meeting uri if  a zoom link or similar is in the location or description as this hack sometimes used to provide Zoom links should probably take precedence over the 'proper' meeting uri
            from_text_meeting_uri = extract_meeting_uri(event.get('location',''))
            if not from_text_meeting_uri:
                from_text_meeting_uri = extract_meeting_uri(event.get('description',''))
            if from_text_meeting_uri and isinstance(from_text_meeting_uri, str):
                meeting_uri=from_text_meeting_uri  
            status=get_status(event)
            participants = get_participants(self,event)
            event_dt = dt.fromisoformat(event['sort_key'])
            del event['sort_key']
            event_element = EventElement(self,name=summary,value=event_time,values=[event_date,event_time,summary,status,meeting_uri,event['organizer']['email'],participants],event=event,event_dt=event_dt)
            if (selection_event is None): 
                if event_id and (event['id']==event_id):
                    selection_event=event_element   
                elif (start>=mid_date_time):
                    selection_event=event_element   
            yield event_element
        if selection_event:
            self.move(self.index(selection_event),0,False,silent=True) 
        else:
            self.say_strings('cannot go to specified date, may be offline')
    def get_headers(self):
        return ['Date','Time','Title','Status','Meeting Link','Organizer','participants']
    def validate_date(self,date_str):
        # Regular expression to match date formats ##/## or ##/##/##
        date_regex = r"^(0[1-9]|1[0-2])/(0[1-9]|[12][0-9]|3[01])(/(\d{2}|\d{4}))?$"
        match = re.match(date_regex, date_str)
        if not match:
            return False
        month, day, year = match.groups()[0], match.groups()[1], match.groups()[3]
        month, day = int(month), int(day)
        # Handling February and leap years
        if month == 2:
            if year:
                year = int(year)
            else:
                year = dt.now().year 
            is_leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
            end_day = 29 if is_leap else 28
        elif month in [4, 6, 9, 11]:
            end_day = 30
        else:
            end_day = 31
        return 1 <= day <= end_day
    def validate_duration(self,duration_str):
        # Regular expression to match duration formats hh:mm or mm
        duration_regex = r"^((\d{1,2}):)?([0-5]?\d)$"
        match = re.match(duration_regex, duration_str)
        if not match:
            return False
        hours, minutes = match.groups()[1], match.groups()[2]
        hours = int(hours) if hours else 0
        minutes = int(minutes)
        return 0 <= hours <= 23 and 0 <= minutes <= 59
    @KeyHandler.hotkey("Prior")
    def command_prior_period(self,modified_name):
        mid_date_time=self.filler-datetime.timedelta(days=31)
        self.refill((mid_date_time,60,None),position=None)
    @KeyHandler.hotkey("Next")
    def command_next_period(self,modified_name):
        mid_date_time=self.filler[0]+datetime.timedelta(days=31)
        self.refill((mid_date_time,60,None),position=None)
    @KeyHandler.hotkey("Control+F")
    def command_find(self,modified_name):
        if not 'Shift' in modified_name:
            super().command_find(modified_name)
        else:
            query = self.input_text(prompt='string',title='search for') 
            if not query:
                self.say_strings('cancelled search or no search string supplied')
            else:
                period = 731 #2 years including an extra day for possible leap year 
                tz = get_calendar_timezone(self.service)
                #no position supplied so defaults to start of events list 
                self.refill((tz.localize(dt.now()),period,query))
    @KeyHandler.hotkey("Control+T")
    def command_today(self,modified_name):
        tz = get_calendar_timezone(self.service)
        self.refill((tz.localize(dt.now()),60,None),position=None)
    @KeyHandler.hotkey("Control+G")
    def command_goto(self,modified_name):
        goto_date: str | bool ='unspecified date'
        try:
            goto_date =self.input_text(prompt='Date: dd/mm/yy', title='go to')
        except Exception as e:
            self.say_strings(f'{goto_date} invalid format. Must be dd/mm/yy or dd/mm')
            return
        if goto_date is False:
            self.say_current()
            return
        assert isinstance(goto_date, str)  # Not False after the check above
        dt_date=make_date_time(goto_date,'00:00',get_calendar_timezone(self.service))
        if isinstance(dt_date,str): #indicates this is an invalid date  
            self.say_strings(dt_date)
            return
        self.refill((dt_date,60,None),position=None)
    @KeyHandler.hotkey("Control+R")
    def command_respond(self,modified_name):
        event=self.current().event  # type: ignore[attr-defined]
        response = self.message(title='update response to:',buttons=["accepted","declined","tentative"])
        if response is False:
            self.say_strings('response cancelled')
        else:
            assert isinstance(response, str)  # response is one of the button strings
            set_status(self,event,response)
            save_changes(self,event)
    @KeyHandler.hotkey("Control+E")
    def command_edit(self,modified_name):
        event=self.current().event  # type: ignore[attr-defined]
        add_or_update(self,populator=event,title=f"Edit {event.get('summary','No Subject')} event")      
    @KeyHandler.hotkey("Control+F2")
    def command_share_calendar(self, modified_name: str) -> None:
        if not 'Shift' in modified_name:
            recipient  =  ContactInputDialog(self, 'share calendar with: ',filler=[]).run()
            if recipient  is False or not len(recipient):
                self.say_strings('cancelled share or no valid contact provided')
            else:
                self.say_strings(f'shared calendar with  {recipient}')
                #actually execute the share 
                rule = {'scope': {'type': 'user','value': recipient[-1]},'role': 'reader'}  # or 'writer' or 'owner' if you want to give more permissions
                share_request = self.service.acl().insert(calendarId='primary', body=rule)
                response= execute_with_retry(share_request)
                if isinstance(response,HTTPErrorString):
                    print(response)
        else:
            acl_request = self.service.acl().list(calendarId='primary')
            acl_list = execute_with_retry(acl_request)
            if isinstance(acl_list,HTTPErrorString):
                print(acl_list)
            else:
                acls = acl_list.get('items', [])
                shared_with_list = [acl['scope']['value'] for acl in acls if (acl['scope']['type']=='user') and (acl['role']!='owner')]                   
                #print('shared with:', shared_with_list)
            fields = [
                [ListElement,'selection',None,shared_with_list],
                [ButtonElement,'OK']
                ]          
            result =self.input_values(title='revoke calendar access for:',fields=fields,say_labels=False)
            if not result:
                self.say_strings('cancelled unshare')
            else:
                assert isinstance(result, dict)  # result is field dict when user presses OK
                target =[acl  for acl  in acls if acl['scope']['value']==result['selection']][0] 
                unshare_request= self.service.acl().delete(calendarId='primary', ruleId=target['id']) 
                response= execute_with_retry(unshare_request)
                if isinstance(response,HTTPErrorString):
                    print(response)
                else:
                    self.say_strings(f"unshared calendar with {result['selection']}")
    @KeyHandler.hotkey("Control+N")
    def command_new_event(self,modified_name):
        if not self.service:
            self.say_strings('cannot create event - may be offline')
            return
        if hasattr(self.current(),'event_dt'):
            e_d=self.current().event_dt.date()  # type: ignore[attr-defined]
        else:
            e_d = dt.today()
        event_date=dt(e_d.year,e_d.month,e_d.day)
        tz=get_calendar_timezone(self.service)
    def ok(self, modified_name: str) -> None:
        self.say_strings("expand the Control+E  dialog to show all fields and switch from Control+E to Return key")
    @KeyHandler.hotkey("Delete")
    def command_delete(self, modified_name: str) -> None:
        if 'Shift' in modified_name:
            self.restore_event()
            return
        if not hasattr(self.current(),'event'):
            self.say_strings('no event to delete')
            return
        event_id=self.current().event['id']  # type: ignore[attr-defined]
        response = request_with_retry(delete_event,self.service,calendar_id='primary', event_id=event_id)
        if connection_error(response,):
            #some sort of http error occurred - e.g. permission denied (403) or event not found(404)
            return
        #print(f"successfully deleted: response {response} of class {type(response)}")
        #if successful then now remove the element from self, along with any pseudo-events which have the same id (the case of a multi-day event which always has N-1 pseudo-events created during content-generation)
        initial_length = len(self)
        i = 0
        while i < len(self):
            if self[i].event['id'] == event_id:
                del self[i]
                if i < self.cursor or (self.cursor >= initial_length - 1 and initial_length > 1):  # type: ignore[has-type]
                    self.cursor -= 1  # type: ignore[has-type]
            else:
                i += 1
        if self.cursor >= len(self) and len(self) > 0:  # type: ignore[has-type]
            self.cursor = len(self) - 1  # type: ignore[has-type]
        elif len(self) == 0:
            self.cursor=0  # type: ignore[has-type]
        self.say_current()        
    def restore_event(self):
        tz = get_calendar_timezone(self.service)        
        now = dt.utcnow().isoformat() + 'Z'  # 'Z' indicates UTC time
        request = self.service.events().list(calendarId='primary', showDeleted=True, timeMin=now)
        response = execute_with_retry(request)
        if connection_error(response):
            return
        events =response.get('items',[])
        deleted_events = [event for event in events if event.get('status') == 'cancelled']
        buttons=[]
        recurring_events=[]
        single_events=[]
        for event in events:
            if (event.get('status')=='cancelled') and ('recurrence' in event):
                recurring_events.append(event)
            elif event.get('status')=='cancelled':
                single_events.append(event)
        key_func=partial(key_from_event_dt,tz)
        single_events.sort(key=key_func)
        deleted_events=single_events+recurring_events
        for event in deleted_events:
            event_time=''
            if 'recurrence' in event:
                event_date = 'recurring event'
            else:
                event_dt = event.get('start',event.get('originalStartDate',None))
                if event_dt:
                    this_date = get_date_time(event_dt,tz) 
                    event_date = ord_date(this_date) if this_date.year==dt.now().year else ord_long_date(this_date)  
                    if 'dateTime' in event_dt:
                        event_time= this_date.strftime('%H:%M') 
                else:
                    event_date='no date available'
            buttons .append(f"{event_date}, {event_time} {event.get('summary','no title')}")
        selection = self.message(title='select event to restore',buttons=buttons)
        if not selection:
            self.say_strings('no event selected')
            return
        self.say_strings('restore event and go to that date')
        assert isinstance(selection, str)  # selection is button string not False after check above
        event_index=buttons.index(selection)
        event=deleted_events[event_index]
        # Restore the event by clearing the 'status' field
        event['status'] = 'confirmed'
        request = self.service.events().update(calendarId='primary', eventId=event['id'], body=event)
        response=execute_with_retry(request)
        if not connection_error(response):
            self.say_strings(f"restored {event.get('summary','untitled event')}")
    def is_owner_email(self, email: str) -> bool:
        """Check if email matches calendar owner address.

        Compares supplied email or "name: email" string with owner email.
        Normalizes Gmail addresses by removing dots and + suffixes.

        Args:
            email: Email address or "name: email" string to check.

        Returns:
            True if email matches calendar owner (after normalization).
        """
        email = email.split(':')[-1].strip()
        # Normalize email addresses by converting to lowercase
        normalized_email = email.lower()
        if not self.owner_email:
            return False
        normalized_owner = self.owner_email.lower()
        # for gmail addresses, remove periods before the '@' symbol and ignore everything after '+'
        if normalized_owner.split('@')[-1] in ('gmail.com','googlemail.com'): 
            normalized_owner = normalized_owner.split('@')[0].replace('.', '').split('+')[0] + '@gmail.com'
        if normalized_email.split('@')[-1] in ('gmail.com','googlemail.com'): 
            normalized_email = normalized_email.split('@')[0].replace('.', '').split('+')[0] + '@gmail.com'            
        # Compare the processed email addresses
        return normalized_owner == normalized_email

    def update_end(self):
        """
        adjusts end date and time to keep same duration as before start date/time were modified
        if end date or time is modified then event duration is updated in on_validate_end so that this update does nothing if start end and time have not been modified since end date and time were modified
        """
        self.end = self.start+self.event_duration
        end_date=self.end.strftime('%d/%m/%y')
        end_time = self.end.strftime('%H:%M')
        for e in self.child:  # type: ignore[attr-defined]
            if e.name=='End Date':
                e.value=end_date
            elif e.name=='End Time':
                e.value=end_time