"""Contact management and email validation utilities for Google Contacts API.

This utility module provides email address parsing, validation, and autocomplete
functionality for Winston's Google Contacts integration. Used by diary, mail, and
people applets.

Key functionality:
- Email address parsing and RFC formatting
- Contact autocomplete from Google Contacts API
- Recent contacts caching
- Interactive contact input dialogs

Note:
    The PeopleDialog applet is in applets/people.py and uses these utilities.
"""
from .google_api import create_service, request_with_retry,execute_with_retry,HTTPErrorString
from .key_handler import KeyHandler
from .dialog import Dialog
from .elements import Element
from .subdialogs import InputPromptDialog
from .form_elements import InputPromptElement, SingleTextElement, SurrogateTextElement
from .framework import beep,ensure_exists
from .secrets_paths import get_contacts_cache_path_with_fallback, get_recent_contacts_path_with_fallback, get_contacts_cache_path, get_recent_contacts_path
import pyperclip
import re
import json
from email_validator import validate_email, EmailSyntaxError, EmailNotValidError,EmailUndeliverableError
import unicodedata
from typing import Any

def sorting_key(s: str) -> str:
    """Generate case-insensitive Unicode-normalized sorting key.

    Strips leading whitespace/quotes, normalizes Unicode to decomposed form (NFKD),
    converts to lowercase, and removes combining marks for consistent sorting.

    Args:
        s: String to generate sorting key from.

    Returns:
        Normalized lowercase string suitable for sorting contacts alphabetically.

    Note:
        Used for sorting contact lists case-insensitively across different scripts.
    """
    # strip and Normalize the string to decompose characters
    nfkd_form = unicodedata.normalize('NFKD', s.lstrip(" \"'<"))
    # convert to  lower case and Filter out combining marks (category "Mn" in Unicode)
    return ''.join(c.lower() for c in nfkd_form if not unicodedata.category(c).startswith('M'))
#sorting_key = lambda s:s.lstrip(" \"'<")
def split_email(input_str: str) -> tuple[str | None, str]:
    """Split email string into display name and email address.

    Handles multiple formats:
    - "Display Name" <email@example.com>
    - Display Name <email@example.com>
    - Display Name email@example.com
    - email@example.com

    Args:
        input_str: Email string in any supported format.

    Returns:
        Tuple of (display_name, email). display_name is None if not present,
        otherwise string with quotes and whitespace stripped.

    Note:
        Normalizes single quotes to double quotes in display names.
    """
    # Strip leading and trailing spaces
    input_str = input_str.strip()
    # Normalize single quotes in display names to double quotes
    input_str = re.sub(r"^'(.+)'", r'"\1"', input_str)
    # Match display name and email in angle brackets
    match = re.match(r'^(.*?)(<[^>]+>)$', input_str)
    if match:
        display_name = match.group(1).strip()
        email = match.group(2).strip("<>")  # Extract email from angle brackets
    else:
        # Split into potential display name and email
        parts = input_str.rsplit(" ", 1)
        if len(parts) == 2 and "@" in parts[1]:
            display_name = parts[0]
            email = parts[1].strip()
        else:
            display_name = None
            email = input_str.strip()
    display_name=display_name.strip('" ') if display_name else display_name
    return display_name,email

def validate_and_normalize(input_str: str) -> str | Exception:
    """Validate and normalize email address to RFC format.

    Parses input, validates email address syntax, quotes display name if needed,
    and returns RFC-formatted string.

    Args:
        input_str: Email string (with or without display name).

    Returns:
        RFC-formatted email string on success (e.g., "Name" <email@example.com>),
        or Exception instance on validation failure.

    Note:
        Display names are quoted if they contain special characters (,;:@<>()"\\/[])
        or non-ASCII characters. Skips deliverability checks for performance.
    """
    SPECIAL_CHARS = r',;:@<>()"\\/[]'
    display_name,email=split_email(input_str)
    # Collapse contiguous spaces in the display name
    if display_name:
        display_name = re.sub(r"\s+", " ", display_name)
        # Quote the display name if it contains special characters or non-ASCII
        if any(char in display_name for char in SPECIAL_CHARS) or not display_name.isascii():
            display_name = f'"{display_name}"'
    #strip spurious ' around emails
    email= email.strip("'").strip('"')
    # Validate only the email part (skip deliverability checks entirely)
    try:
        result = validate_email(email, allow_display_name=False, check_deliverability=False)
        normalized_email = result.normalized
    except (EmailSyntaxError, EmailNotValidError,EmailUndeliverableError)  as e:
        #pass the exception to the caller for logging and handling
        return e
    # Reconstruct the full normalized string
    if display_name:
        return f"{display_name} <{normalized_email}>"
    else:
        return normalized_email
def split_email_list(email_list: str, owner: str | None = None) -> list[str]:
    """Split semicolon/comma-separated email list into validated individual addresses.

    Parses email string by identifying ';' or ',' separators outside quoted display names.
    Replaces standalone 'me' entries with owner address if specified. Validates and
    normalizes each entry, printing and discarding invalid ones.

    Args:
        email_list: Input string containing email addresses and display names.
        owner: Optional email address to replace 'me' with.

    Returns:
        List of validated, RFC-formatted email addresses.

    Note:
        Invalid addresses are printed to stdout and removed from result.
    """
    # Split by ';' or ',' outside of quotes
    split_emails = re.split(r';|,(?=(?:[^"]*"[^"]*")*[^"]*$)', email_list)
    #replace 'me' by the mailbox owner if supplied
    if owner:
        split_emails= [e if e.lower() != 'me' else owner for e in split_emails]
    # validate each email and normalize to RFC format, printing and discarding any invalid entries
    return clean_emails(split_emails)
def clean_emails(email_list: list[str]) -> list[str]:
    """Validate and normalize list of email addresses, filtering out invalid ones.

    Applies validate_and_normalize() to each non-empty entry. Prints any invalid
    addresses (returned as Exception instances) to stdout and removes them from result.

    Args:
        email_list: List of email strings to validate.

    Returns:
        List of validated, RFC-formatted email addresses with invalid entries removed.

    Note:
        Invalid addresses are printed but not raised as exceptions.
    """
    cleaned_emails_mixed: list[str | Exception] = [validate_and_normalize(e) for e in email_list if len(e.strip())]
    #print any invalid emails (returned as exceptions by validate_and_normalize) and discard them from the list
    _=[print(e) for e in cleaned_emails_mixed if isinstance(e,Exception)]
    cleaned_emails: list[str] = [e for e in cleaned_emails_mixed if not isinstance(e,Exception)]  # type: ignore[misc]
    return cleaned_emails
def replace_separators(email_list: str, owner: str) -> str:
    """Replace semicolon separators with commas and 'me' with owner address.

    Replaces ';' with ',' except within quoted display names. Also replaces
    standalone 'me' (not part of email address) with owner string.

    Args:
        email_list: Input string containing email addresses.
        owner: Email address to replace 'me' with.

    Returns:
        Modified email string with replaced separators and owner substitution.

    Note:
        Uses regex to avoid replacing separators/text inside quoted display names.
    """
    # Replace ';' with ',' outside of quoted text
    modified_emails = re.sub(r';(?=(?:[^"]*"[^"]*")*[^"]*$)', ',', email_list)
    # Replace 'me' surrounded by whitespace with 'owner' only if it's not within quotes
    #modified_emails = re.sub(r'\bme\b(?=(?:[^"]*"[^"]*")*[^"]*$)', owner, modified_emails)
    modified_emails = re.sub(r'(?<![\w.@])\bme\b(?!@)(?=(?:[^"]*"[^"]*")*[^"]*$)', owner, modified_emails)
    return modified_emails
def get_contacts_from_cache(cached_file_path: str) -> list[str]:
    """Load contacts from JSON cache file.

    Creates file if it doesn't exist. Returns empty list if file is empty or contains None.

    Args:
        cached_file_path: Path to JSON cache file.

    Returns:
        List of contact strings from cache, or empty list if none exist.

    Note:
        This is the first definition - there's a duplicate at line 226 that does cleaning.
    """
    contacts=[]
    ensure_exists(cached_file_path)
    with open(cached_file_path, encoding = 'utf-8') as f:
        contacts= json.load(f)
    #if json.load returns None then return an empty list
    return contacts if contacts else []
def update_recent_contacts(app: Any, addresses: list[str]) -> None:
    """Add new addresses to recent contacts cache and save if changed.

    Merges new addresses into app.recent_contacts set, sorts by sorting_key(),
    and saves to cache/recent_contacts.json (via secrets_paths helper) if any new addresses added.

    Args:
        app: Application instance with recent_contacts attribute (Anchor).
        addresses: List of email addresses to add to recent contacts.

    Note:
        Only writes to disk if new addresses were actually added.
    """
    recent_set=set(app.recent_contacts if app.recent_contacts else [])
    previous_count = len(recent_set)
    for address in addresses:
        recent_set.add(address)
    if len(recent_set)>previous_count:
        app.recent_contacts=sorted(list(recent_set),key=sorting_key)
        with open(get_recent_contacts_path(), 'w', encoding='utf-8', errors='ignore') as f:
            json.dump(app.recent_contacts,f)
def contacts_from_api(app: Any) -> None:
    """Fetch contacts from Google People API and update app.contacts.

    Runs get_other_contacts() and assigns result to app.contacts if successful.
    Thread-safe: app.contacts setter acquires app.google_api_lock.

    Args:
        app: Application instance with google_creds and contacts attributes (Anchor).

    Note:
        Only updates app.contacts if API call succeeds (returns non-None).
        Errors are appended to app.errors_in_contacts list.
    """
    if app.google_creds:
        contacts = get_other_contacts(app.google_creds,errors=app.errors_in_contacts)
        if contacts is not None:
            app.contacts = contacts
def edit_emails_string(dialog: Any, emails_string: str) -> str:
    """Launch contact input dialog to add contact to email string.

    Opens ContactInputDialog for user to autocomplete and select a contact.
    Appends selected contact to emails_string with semicolon separator.

    Args:
        dialog: Parent dialog to launch ContactInputDialog from.
        emails_string: Existing semicolon-separated email list.

    Returns:
        Updated email string with new contact appended (if selected), or original
        string if user cancelled.

    Todo:
        Update to use extract_email() for editing email in middle of string.
    """
    new_contact=  ContactInputDialog(dialog, ' ',filler=[]).run()
    if new_contact:
        #add this contact to the email list. Needs updating to use extract email for handling editing of an email in the middle of emails_string
        #twiddle at end of string construction below is to ensure exactly one ; as otherwise sometimes end up with ; ;
        emails_string = ';'.join([emails_string,new_contact]).rstrip('; ')+';'
    return emails_string
def extract_email(email_string: str, offset: int) -> tuple[str, str, str]:
    """Extract email segment at cursor position from semicolon-separated list.

    Splits email string by ';' or ',' outside quoted sections. Returns three parts:
    text before the segment containing offset, the segment itself, and text after.

    Args:
        email_string: Input string containing email addresses and display names.
        offset: Character offset (cursor position) within the input string.

    Returns:
        Tuple of (before, containing, after):
        - before: String portion before the segment containing offset
        - containing: Email segment at offset position (stripped)
        - after: String portion after the segment containing offset

    Note:
        If offset is on separator or end of line, returns text from previous
        separator to offset. Returns ("", "", "") if unexpected input.
    """
    # Define separator positions
    separators = [m.start() for m in re.finditer(r'(;|,)(?=(?:[^"]*"[^"]*")*[^"]*$)', email_string)] + [len(email_string)]
    previous_sep = 0
    # Find the containing segment
    for sep in separators:
        if sep >= offset:
            # If offset is on a separator or end of line, use text from previous separator to offset
            containing = email_string[previous_sep:sep].strip()
            before = email_string[:previous_sep].strip()
            after = email_string[sep + 1:].strip() if sep < len(email_string) else ""
            return before, containing, after
        previous_sep = sep + 1
    # Default to empty triplet if something unexpected occurs
    return "", "", ""
def build_page_request(service: Any, page_token: str) -> Any:
    """Build Google People API request for paginated otherContacts list.

    Args:
        service: Google People API service instance.
        page_token: Token for next page of results (empty string for first page).

    Returns:
        Request object for otherContacts().list() API call.

    Note:
        Requests names and emailAddresses fields only. Page size: 1000 contacts.
    """
    request=service.otherContacts().list(readMask='names,emailAddresses',pageSize=1000,pageToken=page_token)
    return request
def get_contacts_from_cache(cached_file_path: str) -> list[str]:  # type: ignore[no-redef]
    """Load and validate contacts from JSON cache file.

    Creates file if it doesn't exist. Cleans and validates all contacts before returning.

    Args:
        cached_file_path: Path to JSON cache file.

    Returns:
        List of validated, RFC-formatted contact strings from cache.

    Note:
        This is the second definition (line 226) - overrides simpler version at line 162.
        This version applies clean_emails() validation. Consider removing duplicate.
    """
    contacts=[]
    ensure_exists(cached_file_path)
    with open(cached_file_path, encoding = 'utf-8') as f:
        contacts= json.load(f)
    #if json.load returns None then return an empty list
    if not contacts:
        return []
    else:
        contacts=clean_emails(contacts)
        return contacts
def get_other_contacts(creds: Any, errors: list[tuple[Any, str, str]] | None = None) -> list[str] | None:
    """Fetch all contacts from Google People API otherContacts endpoint.

    Paginates through all contacts, validates and normalizes each email address,
    filters out invalid contacts and those starting with digits. Saves to cache file.

    Args:
        creds: Google OAuth credentials object.
        errors: Optional list to append (contact, error_msg, contact_string) tuples
            for invalid contacts.

    Returns:
        Sorted list of validated contact strings, or None if API call fails.

    Note:
        - Filters out display names containing '@' (likely email aliases)
        - Ignores contacts starting with digits after stripping quotes
        - Saves validated contacts to cache/contacts.json (via secrets_paths helper)
        - Uses request_with_retry() for automatic retry on transient failures
    """
    if errors is None:
        errors = []
    service = create_service('people','v1', creds)
    next_page_token = ''
    other_contacts=[]
    response=None
    contacts_file_path = get_contacts_cache_path()
    while service and (next_page_token is not None):
        response = request_with_retry(build_page_request,service,page_token=next_page_token)
        if isinstance(response,HTTPErrorString):
            #except for invisibly re-tried transient network exceptions all other request failures are returned as a string. ,
            print(f"failed to get contacts: {response}")
            response=None
            break
        else:
            other_contacts.extend(response.get('otherContacts', []))
            next_page_token = response.get('nextPageToken')
    if not response:
        return None
    else:
        contacts=[]
        for contact in other_contacts:
            all_names = contact.get('names', [])
            names=[n for n in all_names if not '@' in n.get('displayName','')] #filter out any names that are actually email addresses which seems to happen sometimes for email aliases such as Mike.Smith@... aliasing mike.smith@...
            emails = contact.get('emailAddresses', [])
            for email in emails:
                email_string=email.get('value', 'no email available')
                if not names:
                    name=None
                    contact_string= email_string
                else:
                    name=names[0].get('displayName','') #.strip("""'"<>!""")
                    contact_string=' '.join([name,f"<{email_string}>"])
                valid_contact=validate_and_normalize(contact_string)
                if isinstance(valid_contact,Exception):
                    errors.append((contact,str(valid_contact), contact_string))
                    continue
                #ignore contacts that start with digits
                if not valid_contact.lstrip('"')<'A':
                    contacts.append(valid_contact)
        contacts.sort(key=sorting_key)
        with open(contacts_file_path,'w',encoding='utf-8',errors='ignore') as f:
            json.dump(contacts,f)
        return contacts

class AutoCompleteElement(SingleTextElement):
    """Text element that triggers dialog content filtering on edit.

    Used in deprecated ZZZ_ContactsDialog. Filters dialog content as user types
    or deletes characters.

    Methods:
        __str__(): Returns value if non-empty, otherwise name.
        char_sink(): Processes character input and triggers filter_content().
        delete(): Processes Delete key and triggers filter_content().
    """
    def __str__(self) -> str:
        #return ': '.join([self.name,self.value])
        return self.value if len(self.value) else self.name
    def char_sink(self, modified_name: str) -> Any:
        result = super().char_sink(modified_name)
        self.dialog.filter_content(self.value,self.caret)  # type: ignore[attr-defined]
    @KeyHandler.hotkey("Delete")
    def delete(self, modified_name: str) -> Any:
        super().delete(modified_name)
        self.dialog.filter_content(self.value,self.caret)  # type: ignore[attr-defined]
class CompletionInputElement(SurrogateTextElement):
    """Surrogate element that mirrors parent dialog element and refreshes completions.

    Used by CompletionInputDialog. Changes to this element's value are propagated
    to the parent dialog's current element, and the dialog content is refreshed
    with updated completions.

    Attributes:
        value: Property setter that updates surrogate, fetches new completions,
            and refills dialog content.
    """
    @SurrogateTextElement.value.setter  # type: ignore[attr-defined]
    def value(self, val: str) -> None:  # type: ignore[override]
        current_completion=self.value
        surrogate=self.dialog.parent.current()  # type: ignore[union-attr]
        #for maintainability wholesale replacement of the setter logic is replaced with a call to the superclass function in case that superclass functionality is upgraded in future
        #surrogate.value=val
        SurrogateTextElement.value.fset(self, val)  # type: ignore[attr-defined]
        #If cannot do direct update of the attribute and if super() does not work for fset  try below statement instead
        #super(CompletionInputElement, CompletionInputElement).value.__set__(self,val)
        completions=surrogate.get_completions()  # type: ignore[union-attr]
        if not completions:
            cursor=0
        else:
            cursor = completions.index(current_completion) if current_completion in completions else 1
        self.dialog.refill(completions,cursor)  # type: ignore[arg-type]
class ContactInputElement(InputPromptElement):
    """Input element with autocomplete from recent contacts and Google Contacts API.

    Matches input against display names and email addresses. Speaks first completion
    as user types. Down arrow opens completion picker dialog. Return accepts completion
    or current value.

    Attributes:
        empty_value: Placeholder text when empty ("add").
        last_completion: Last spoken completion to avoid repetition.

    Methods:
        char_sink(): Processes character input and updates completion.
        delete(): Processes Delete key and updates completion.
        update_completion(): Fetches completions and speaks first if changed.
        get_completions(): Returns validated contacts matching current input.
        is_completion(): Checks if contact matches input (name or email prefix).
        dispatch_input(): Accepts completion and adds to dialog list.
        down(): Opens CompletionInputDialog picker for selecting from completions.
    """
    def __init__(self, *args: Any, **kwargs: Any):
        super().__init__(*args,**kwargs)
        self.empty_value='add'
        self.last_completion: str | None = None
    def char_sink(self, char: str) -> Any:
        ret = super().char_sink(char)
        self.update_completion()
        return ret
    @KeyHandler.hotkey("Delete")
    def delete(self, modified_name: str) -> Any:
        ret=super().delete(modified_name)
        self.update_completion()
        return ret
    def update_completion(self) -> None:
        """Fetch completions and speak first one if different from last."""
        completions=[]
        if self.value and len(self.value):
            completions=self.get_completions()
        if len(completions):
            if not completions[0]==self.last_completion:
                self.dialog.say_strings(completions[0])
                self.last_completion=completions[0]
        else:
            self.last_completion=None
    def get_completions(self) -> list[str]:
        """Get validated contacts matching current input from recent and API contacts.

        Returns:
            List of validated, RFC-formatted contacts where display name or email
            starts with current input value (case-insensitive).
        """
        if not self.value:
            return []
        seed=self.value.lower()
        completions: list[str] = []
        if not len(seed):
            return []
        for c in self.dialog.app.recent_contacts+self.dialog.app.contacts:
            if self.is_completion(c):
                #for performance only clean the completion contact  once have found a likely match
                clean = validate_and_normalize(c)
                if isinstance(clean, str) and self.is_completion(clean):
                    completions.append(clean)
        return completions
    def is_completion(self, c: Any) -> bool:
        """Check if contact matches current input (display name or email prefix).

        Args:
            c: Contact string to check.

        Returns:
            True if contact's display name or email starts with current input value.
        """
        if not self.value:
            return False
        seed= self.value.lower()
        if not isinstance(c,str):
            return False
        display_name,email=split_email(c.lower())
        stripped_display_name='' if display_name is None else display_name.strip('"<>\'')
        matched_display_name = stripped_display_name.startswith(seed)
        stripped_email=email.strip('"\'<>')
        matched_email= stripped_email.startswith(seed)
        return matched_display_name or matched_email
    @KeyHandler.hotkey("Return")
    def dispatch_input(self, modified_name: str) -> Any:
        """Accept completion and add to dialog. Speaks added contact."""
        if self.last_completion and self.is_completion(self.last_completion):
            self.value=self.last_completion
        added_contact = self.value if self.value else ""
        super().dispatch_input(modified_name)
        self.dialog.say_strings(added_contact)
    @KeyHandler.hotkey("Down")
    def down(self, modified_name: str) -> Any:
        """Open completion picker dialog to select from matching contacts.

        Overrides dialog's Down handler when focus is on this element.
        Launches CompletionInputDialog with current completions. If user selects
        one, dispatches it as input.

        Note:
            Method name is down() to match hotkey, could be select_completion().
        """
        completions=self.get_completions()
        if not completions:
            beep()
        else:
            completion = CompletionInputDialog(self.dialog,'completions',filler=completions).run(speak='%current')
            if completion:
                return self.dispatch_input(completion)
            else:
                self.dialog.say_current()

            if completion:
                self.last_completion=completion
                self.dispatch_input(modified_name)
            else:
                self.dialog.say_current()
class ImmediateContactInputElement(ContactInputElement):
    """Contact input element that returns immediately on selection.

    Used by ContactInputDialog for single-contact selection (vs ContactsInputDialog
    which builds a list). Suppresses Return handler so ContactInputDialog.ok_wrapper()
    can return the contact directly to launcher.

    Methods:
        get_hotkey_handler(): Returns None for "Return" hotkey to allow dialog
            to handle it and return contact immediately.
    """
    def get_hotkey_handler(self, hotkey: str) -> Any:
        """Suppress Return handler so dialog can return contact immediately."""
        return super().get_hotkey_handler(hotkey) if hotkey!="Return" else None

class zzz_ContactsElement(SingleTextElement):
    """
    supports auto completion editing of any email within a ; separated email string
    """
    def __init__(self,*args,**kwargs):
        super().__init__(*args,**kwargs)
        self.last_completion=None
    def char_sink(self,char):
        ret = super().char_sink(char)
        self.update_completion()
        return ret
    @KeyHandler.hotkey("Delete")
    def delete(self,modified_name):
        ret=super().delete(modified_name)
        self.update_completion()
        return ret
    def update_completion(self):
        before,seed,after=extract_email(self.value,self.caret)
        completions = [c for c in self.dialog.app.recent_contacts+self.dialog.app.contacts if len(seed) and seed.lower() in c.lower()]
        if len(completions):
            if not completions[0]==self.last_completion:
                self.dialog.say_strings(completions[0])
                self.last_completion=completions[0]
        else:
            self.last_completion=None
    @KeyHandler.hotkey("Return")
    def accept_completion(self,modified_name):
        before,containing,after = extract_email(self.value,self.caret)
        if (self.last_completion is not None) and (containing.lower() in self.last_completion.lower()):
            self.rebuild_value(before,self.last_completion,after)
            self.dialog.say_strings(self.last_completion)
        else:
            beep()
    def rebuild_value(self,before,email,after):
            joiner = ';' if len(before) and not before.endswith(';') else ''
            first_part = joiner.join([before,email])+';'
            caret = len(first_part)
            #joiner = ';' if len(after) and not after.startswith(';') else ''
            self.value = joiner.join([first_part,after.lstrip(';')])
            self.dialog.move(self.dialog.cursor,caret,shift=False)
    @KeyHandler.hotkey("Control+Down")
    def auto_complete(self,modified_name):
        before,seed,after=extract_email(self.value,self.caret)
        result = AutoCompleteDialog(self.dialog,name_seed='autocompleter',filler = [c for c in self.dialog.app.recent_contacts+self.dialog.app.contacts if seed.lower() in c.lower()]).run(speak='%current')
        if isinstance(result,tuple):
            if result[0] == 'char':
                self.char_sink(result[1])
            elif result[0] == 'hotkey':
                self.dialog.handle_hotkey(result[1])
            else:
                raise ValueError(f"Unexpected tuple returned by auto complete: {result}")
        else: #not a keystroke that needs passing up to parent
            email = result if result else seed
            self.rebuild_value(before,email,after)
            self.dialog.say_strings('escape' if not result else result)
class AutoCompleteDialog(Dialog):
    """Dialog that returns keystrokes to parent for pass-through handling.

    Used by deprecated zzz_ContactsElement. Returns character input and Delete
    hotkey as tuples for parent to process.

    Methods:
        char_sink(): Returns ('char', char) tuple for parent to handle.
        delete(): Returns ('hotkey', modified_name) tuple for parent to handle.
    """

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

        Returns:
            Set of attribute names including parent Dialog requirements.
        """
        attrs = super().element_attributes()
        # No additional attributes required beyond base Dialog
        return attrs

    def char_sink(self, char: str) -> tuple[str, str]:
        """Return character input to parent for handling."""
        return ('char',char)
    @KeyHandler.hotkey("Delete")
    def delete(self, modified_name: str) -> tuple[str, str]:
        """Return Delete hotkey to parent for handling."""
        return('hotkey',modified_name)
class ContactsInputDialog(InputPromptDialog):
    """Dialog for building semicolon-separated list of validated contacts.

    Provides ContactInputElement for adding contacts with autocomplete. Delete key
    removes selected contact from list. Returns semicolon-separated string of
    validated contacts on OK.

    Methods:
        input_element(): Returns ContactInputElement for adding contacts.
        get_result(): Returns semicolon-separated validated contacts string.
        command_remove_recipient(): Delete key removes current contact from list.

    Note:
        Only includes valid contacts in result (invalid input element value excluded).
    """
    def input_element(self) -> tuple[bool, ContactInputElement]:
        """Return ContactInputElement for adding contacts with autocomplete."""
        return(True,ContactInputElement(self,'add contact',''))
    def get_result(self) -> str:
        """Return semicolon-separated string of validated contacts.

        Includes all list elements plus input element value if valid (i.e.
        validate_and_normalize does not return Exception). Returns empty string
        if no valid contacts.

        Returns:
            Semicolon-separated email string, or empty string if no valid entries.
        """
        return ';'.join([c.value for c in self if c.value and (not isinstance(c,ContactInputElement) or not isinstance(validate_and_normalize(c.value),Exception))])
    @KeyHandler.hotkey("Delete")
    def command_remove_recipient(self, modified_name: str) -> None:
        """Remove currently selected contact from list (not input element)."""
        if not isinstance(self.current(),ContactInputElement):
            self.pop(self.cursor)
            self.say_current()
        else:
            beep()
class ContactInputDialog(ContactsInputDialog):
    """Dialog for selecting single contact with autocomplete.

    Returns immediately on selection instead of building list. Uses
    ImmediateContactInputElement which suppresses Return handler so dialog
    can return contact directly.

    Methods:
        input_element(): Returns ImmediateContactInputElement for contact selection.
        ok_wrapper(): Return handler calls Dialog.ok() to bypass InputPromptDialog.ok().
        cancel(): Escape handler returns False to cancel dialog.

    Note:
        Return handler bypasses InputPromptDialog.ok() because
        ImmediateContactInputElement suppresses its own Return handler.
    """
    def input_element(self) -> tuple[bool, ImmediateContactInputElement]:
        """Return ImmediateContactInputElement for single contact selection."""
        return(True,ImmediateContactInputElement(self,'Select contact',''))
    #below is necessary as suppressing the "return" handling on the ImmediateContactInputElement results in it being suppressed by InputPromptDialog.ok which is bypassed by the below trick as the default behaviour for successfully terminating the dialog
    @KeyHandler.hotkey("Return")
    def ok_wrapper(self, modified_name: str) -> Any:
        """Return contact immediately, bypassing InputPromptDialog.ok()."""
        return Dialog.ok(self,modified_name)
    @KeyHandler.hotkey("Escape")
    def cancel(self, modified_name: str) -> bool:
        """Cancel dialog and return False."""
        return False
class CompletionInputDialog(InputPromptDialog):
    """Dialog for selecting from autocomplete suggestions.

    Input prompt at top mirrors parent element's value. As user edits input,
    dialog content refreshes with matching completions. Cursor moves to first
    completion if current selection no longer matches. Return/Escape close dialog.

    Architecture:
        - CompletionInputElement mirrors parent dialog's current element
        - Edits propagate to parent and trigger completion refresh
        - Dialog displays filtered completions list

    Methods:
        input_element(): Returns CompletionInputElement positioned at top.

    Note:
        Input prompt is at top (False) not bottom like typical InputPromptDialog.
    """
    def input_element(self) -> tuple[bool, CompletionInputElement]:  # type: ignore[override]
        """Return CompletionInputElement at top that mirrors parent element."""
        return (False,CompletionInputElement(self,'surrogate value',"mirrors value of the element that launched this element's owning dialog"))
class ZZZ_ContactsDialog(Dialog):
    """DEPRECATED: Contact autocomplete dialog. Use ContactsInputDialog instead."""

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

        Returns:
            Set of attribute names including parent Dialog requirements.
        """
        attrs = super().element_attributes()
        # No additional attributes required beyond base Dialog
        return attrs

    def generate_content(self,filler,element_type=None):
        filler = filler[0] if (isinstance(filler,list) and len(filler))  else filler
        filler = '' if not filler  else filler.lower()
        if not len(self.app.contacts) and not len(self.app.recent_contacts):
            return
        yield AutoCompleteElement(self,'find contact',filler)
        if not hasattr(self,'contacts'):
            self.contacts = self.app.recent_contacts+self.app.contacts
        for contact in self.contacts:
            try:
                #name,email = contact.split(':')
                #fille with all contacts if the filter is '*', otherwise if filler is not blank, fille with contacts whose name.lower() or email starts with filler.lower
                #if (filler=='*') or (len(filler) and (name.lower().startswith(filler) or email.lower().startswith(filler))):
                if (filler=='*') or (filler.lower() in contact.lower()):
                    yield Element(self,'contact',contact)
            except:
                pass
    def filter_content(self,filter,caret):
        if len(self)>1:
            self.last_match=self[1].value
        else:
            self.last_match=None
        del self[1:]
        for c in self.generate_valid_content(filler=filter):
            if not isinstance(c,AutoCompleteElement):
                self.append(c)
        if (len(self)>1) and (self.last_match!=self[1].value):
            self.say_strings(str(self[1]))
    def empty_message(self):
        return 'no contacts available'
    def zzz_get_headers(self):
        return ['name','email']
    def back_catcher(self,modified_name):
        #handle the Back the same as Delete - i.e. move to auto completer and consume the key there
        self.delete_catcher(modified_name)
    #@KeyHandler.hotkey("Delete")
    def delete_catcher(self,modified_name):
        #self.breakpoint()
        self.move(0,self[0].caret,False)
        self.current().delete(modified_name)  # type: ignore[attr-defined]
    def char_sink(self,char):
        self.top_line()
        self.current().char_sink(char)  # type: ignore[attr-defined]
    def top_line(self):
        if self.cursor!=0:
            top_caret=self[0].caret
            self.move(0,top_caret,False)
    def get_contact(self):
        """
        Returns the selected contact
        if currently on the input element and there is at least one contact matching the auto complete input then return the first matching contact
        """
        if isinstance(self.current(),AutoCompleteElement):
            if (len(self)>1):
                return self[1].value
            else:
                beep()
        else:
            return self.current().value
    def cancel(self,modified_name):
        if self.cursor==0:
            return False
        else:
            self.move(0,self[0].caret,False)
    def ok(self,modified_name):
        return self.get_contact()
    @KeyHandler.hotkey("Delete")
    def delete(self,modified_name):
        self.top_line()
        self.current().delete(modified_name)  # type: ignore[attr-defined]
    @KeyHandler.hotkey("Alt+Left")
    def close_dialog(self,modified_name):
        return False #get out without providing a contact tuple - same behaviour as Escape
