"""photo_query and QueryOptions class for PhotosDB.query"""

from __future__ import annotations

import dataclasses
import datetime
import io
import pathlib
import re
import sys
from collections import OrderedDict
from collections.abc import Iterable
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, List, Optional, Tuple

import bitmath

from ._constants import _PHOTOS_5_VERSION, UUID_PATTERN
from .datetime_utils import datetime_has_tz, datetime_naive_to_local
from .photoinfo import PhotoInfo
from .phototemplate import RenderOptions
from .platform import is_macos
from .unicode import normalize_unicode

if TYPE_CHECKING:
    from .iphoto import iPhotoDB
    from .photosdb import PhotosDB

if is_macos:
    import photoscript

# default date for sorting
DEFAULT_DATE = datetime.datetime(1970, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc)

__all__ = [
    "IncompatibleQueryOptions",
    "QueryOptions",
    "photo_query",
    "query_options_from_kwargs",
]


class IncompatibleQueryOptions(Exception):
    """Incompatible query options"""

    pass


@dataclass
class QueryOptions:
    """QueryOptions class for PhotosDB.query

    Attributes:
        added_after: search for photos added on or after a given date
        added_before: search for photos added before a given date
        added_in_last: search for photos added in last X datetime.timedelta
        album: list of album names to search for
        burst_photos: include all associated burst photos for photos in query results
        burst: search for burst photos
        cloudasset: search for photos that are managed by iCloud
        deleted_only: search only for deleted photos
        deleted: also include deleted photos
        description: list of descriptions to search for
        duplicate: search for duplicate photos
        edited: search for edited photos
        exif: search for photos with EXIF tags that matches the given data
        external_edit: search for photos edited in external apps
        favorite: search for favorite photos
        folder: list of folder names to search for
        from_date: search for photos taken on or after this date
        from_time: search for photos taken on or after this time of day
        function: list of query functions to evaluate
        has_comment: search for photos with comments
        has_likes: search for shared photos with likes
        has_raw: search for photos with associated raw files
        hdr: search for HDR photos
        hidden: search for hidden photos
        ignore_case: ignore case when searching
        in_album: search for photos in an album
        incloud: search for cloud assets that are synched to iCloud
        is_reference: search for photos stored by reference (that is, they are not managed by Photos)
        keyword: list of keywords to search for
        label: list of labels to search for
        live: search for live photos
        location: search for photos with a location
        max_size: maximum size of photos to search for
        min_size: minimum size of photos to search for
        missing_bursts: for burst photos, also include burst photos that are missing
        missing: search for missing photos
        movies: search for movies
        name: list of names to search for
        no_comment: search for photos with no comments
        no_description: search for photos with no description
        no_likes: search for shared photos with no likes
        no_location: search for photos with no location
        no_keyword: search for photos with no keywords
        no_place: search for photos with no place
        no_title: search for photos with no title
        not_burst: search for non-burst photos
        not_cloudasset: search for photos that are not managed by iCloud
        not_edited: search for photos that have not been edited
        not_favorite: search for non-favorite photos
        not_hdr: search for non-HDR photos
        not_hidden: search for non-hidden photos
        not_in_album: search for photos not in an album
        not_incloud: search for cloud asset photos that are not yet synched to iCloud
        not_live: search for non-live photos
        not_missing: search for non-missing photos
        not_panorama: search for non-panorama photos
        not_portrait: search for non-portrait photos
        not_reference: search for photos not stored by reference (that is, they are managed by Photos)
        not_screenshot: search for non-screenshot photos
        not_screen_recording: search for non-screen recordings videos
        not_selfie: search for non-selfie photos
        not_shared: search for non-shared photos
        not_slow_mo: search for non-slow-mo photos
        not_time_lapse: search for non-time-lapse photos
        panorama: search for panorama photos
        person: list of person names to search for
        photos: search for photos
        place: list of place names to search for
        portrait: search for portrait photos
        query_eval: list of query expressions to evaluate
        regex: list of regular expressions to search for
        screenshot: search for screenshot photos
        screen_recording: search for screen recording videos
        selected: search for selected photos
        selfie: search for selfie photos
        shared: search for shared photos
        slow_mo: search for slow-mo photos
        time_lapse: search for time-lapse photos
        title: list of titles to search for
        to_date: search for photos taken before this date
        to_time: search for photos taken before this time of day
        uti: list of UTIs to search for
        uuid: list of uuids to search for
        year: search for photos taken in a given year
        syndicated: search for photos that have been shared via syndication ("Shared with You" album via Messages, etc.)
        not_syndicated: search for photos that have not been shared via syndication ("Shared with You" album via Messages, etc.)
        saved_to_library: search for syndicated photos that have been saved to the Photos library
        not_saved_to_library: search for syndicated photos that have not been saved to the Photos library
        shared_moment: search for photos that have been shared via a shared moment
        not_shared_moment: search for photos that have not been shared via a shared moment
        shared_library: search for photos that are part of a shared iCloud library
        not_shared_library: search for photos that are not part of a shared iCloud library
        newest_first: return results in newest first order instead of default oldest first
    """

    added_after: Optional[datetime.datetime] = None
    added_before: Optional[datetime.datetime] = None
    added_in_last: Optional[datetime.timedelta] = None
    album: Optional[Iterable[str]] = None
    burst_photos: Optional[bool] = None
    burst: Optional[bool] = None
    cloudasset: Optional[bool] = None
    deleted_only: Optional[bool] = None
    deleted: Optional[bool] = None
    description: Optional[Iterable[str]] = None
    duplicate: Optional[bool] = None
    edited: Optional[bool] = None
    exif: Optional[Iterable[Tuple[str, str]]] = None
    external_edit: Optional[bool] = None
    favorite: Optional[bool] = None
    folder: Optional[Iterable[str]] = None
    from_date: Optional[datetime.datetime] = None
    from_time: Optional[datetime.time] = None
    function: Optional[List[Tuple[callable, str]]] = None
    has_comment: Optional[bool] = None
    has_likes: Optional[bool] = None
    has_raw: Optional[bool] = None
    hdr: Optional[bool] = None
    hidden: Optional[bool] = None
    ignore_case: Optional[bool] = None
    in_album: Optional[bool] = None
    incloud: Optional[bool] = None
    is_reference: Optional[bool] = None
    keyword: Optional[Iterable[str]] = None
    label: Optional[Iterable[str]] = None
    live: Optional[bool] = None
    location: Optional[bool] = None
    max_size: Optional[bitmath.Byte] = None
    min_size: Optional[bitmath.Byte] = None
    missing_bursts: Optional[bool] = None
    missing: Optional[bool] = None
    movies: Optional[bool] = True
    name: Optional[Iterable[str]] = None
    no_comment: Optional[bool] = None
    no_description: Optional[bool] = None
    no_likes: Optional[bool] = None
    no_location: Optional[bool] = None
    no_keyword: Optional[bool] = None
    no_place: Optional[bool] = None
    no_title: Optional[bool] = None
    not_burst: Optional[bool] = None
    not_cloudasset: Optional[bool] = None
    not_edited: Optional[bool] = None
    not_favorite: Optional[bool] = None
    not_hdr: Optional[bool] = None
    not_hidden: Optional[bool] = None
    not_in_album: Optional[bool] = None
    not_incloud: Optional[bool] = None
    not_live: Optional[bool] = None
    not_missing: Optional[bool] = None
    not_panorama: Optional[bool] = None
    not_portrait: Optional[bool] = None
    not_reference: Optional[bool] = None
    not_screenshot: Optional[bool] = None
    not_screen_recording: Optional[bool] = None
    not_selfie: Optional[bool] = None
    not_shared: Optional[bool] = None
    not_slow_mo: Optional[bool] = None
    not_time_lapse: Optional[bool] = None
    panorama: Optional[bool] = None
    person: Optional[Iterable[str]] = None
    photos: Optional[bool] = True
    place: Optional[Iterable[str]] = None
    portrait: Optional[bool] = None
    query_eval: Optional[Iterable[str]] = None
    regex: Optional[Iterable[Tuple[str, str]]] = None
    screenshot: Optional[bool] = None
    screen_recording: Optional[bool] = None
    selected: Optional[bool] = None
    selfie: Optional[bool] = None
    shared: Optional[bool] = None
    slow_mo: Optional[bool] = None
    time_lapse: Optional[bool] = None
    title: Optional[Iterable[str]] = None
    to_date: Optional[datetime.datetime] = None
    to_time: Optional[datetime.time] = None
    uti: Optional[Iterable[str]] = None
    uuid: Optional[Iterable[str]] = None
    year: Optional[Iterable[int]] = None
    syndicated: Optional[bool] = None
    not_syndicated: Optional[bool] = None
    saved_to_library: Optional[bool] = None
    not_saved_to_library: Optional[bool] = None
    shared_moment: Optional[bool] = None
    not_shared_moment: Optional[bool] = None
    shared_library: Optional[bool] = None
    not_shared_library: Optional[bool] = None
    newest_first: Optional[bool] = None

    def asdict(self):
        return asdict(self)


def query_options_from_kwargs(**kwargs) -> QueryOptions:
    """Validate query options and create a QueryOptions instance.
    Note: this will block on stdin if uuid_from_file is set to "-"
    so it is best to call function before creating the PhotosDB instance
    so that the validation of query options can happen before the database
    is loaded.
    """
    # sanity check input args
    nonexclusive = [
        "added_after",
        "added_before",
        "added_in_last",
        "album",
        "duplicate",
        "exif",
        "external_edit",
        "folder",
        "from_date",
        "from_time",
        "has_raw",
        "keyword",
        "label",
        "max_size",
        "min_size",
        "name",
        "person",
        "query_eval",
        "query_function",
        "regex",
        "selected",
        "to_date",
        "to_time",
        "uti",
        "uuid",
        "uuid_from_file",
        "year",
        "newest_first",
    ]
    exclusive = [
        ("burst", "not_burst"),
        ("cloudasset", "not_cloudasset"),
        ("edited", "not_edited"),
        ("favorite", "not_favorite"),
        ("has_comment", "no_comment"),
        ("has_likes", "no_likes"),
        ("hdr", "not_hdr"),
        ("hidden", "not_hidden"),
        ("in_album", "not_in_album"),
        ("incloud", "not_incloud"),
        ("is_reference", "not_reference"),
        ("keyword", "no_keyword"),
        ("live", "not_live"),
        ("location", "no_location"),
        ("missing", "not_missing"),
        ("only_photos", "only_movies"),
        ("panorama", "not_panorama"),
        ("portrait", "not_portrait"),
        ("screenshot", "not_screenshot"),
        ("screen_recording", "not_screen_recording"),
        ("selfie", "not_selfie"),
        ("shared", "not_shared"),
        ("slow_mo", "not_slow_mo"),
        ("time_lapse", "not_time_lapse"),
        ("deleted", "not_deleted"),
        ("deleted", "deleted_only"),
        ("deleted_only", "not_deleted"),
        ("syndicated", "not_syndicated"),
        ("saved_to_library", "not_saved_to_library"),
        ("shared_moment", "not_shared_moment"),
        ("shared_library", "not_shared_library"),
    ]

    # TODO: add option to validate requiring at least one query arg
    for arg, not_arg in exclusive:
        if kwargs.get(arg) and kwargs.get(not_arg):
            arg = arg.replace("_", "-")
            not_arg = not_arg.replace("_", "-")
            raise IncompatibleQueryOptions(
                f"Incompatible query options: --{arg} and --{not_arg} are mutually exclusive"
            )

    # some options like title can be specified multiple times
    # check if any of them are specified along with their no_ counterpart
    exclusive_multi_options = ["title", "description", "place", "keyword"]
    for option in exclusive_multi_options:
        if kwargs.get(option) and kwargs.get("no_{option}"):
            raise IncompatibleQueryOptions(
                f"--{option} and --no-{option} are mutually exclusive"
            )

    include_photos = True
    include_movies = True  # default searches for everything
    if kwargs.get("only_movies"):
        include_photos = False
    if kwargs.get("only_photos"):
        include_movies = False

    # load UUIDs if necessary and append to any uuids passed with --uuid
    uuids = list(kwargs.get("uuid", []))  # Click option is a tuple
    if uuid_from_file := kwargs.get("uuid_from_file"):
        uuids.extend(load_uuid_from_file(uuid_from_file))
        uuids = tuple(uuids)

    query_fields = [field.name for field in dataclasses.fields(QueryOptions)]
    query_dict = {field: kwargs.get(field) for field in query_fields}
    query_dict["photos"] = include_photos
    query_dict["movies"] = include_movies
    query_dict["uuid"] = uuids
    query_dict["function"] = kwargs.get("query_function")

    return QueryOptions(**query_dict)


def load_uuid_from_file(filename: str) -> list[str]:
    """
    Load UUIDs from file.
    Does not validate UUIDs but does validate that the UUIDs are in the correct format.
    Format is 1 UUID per line, any line beginning with # is ignored.
    Whitespace is stripped.

    Arguments:
        filename: file name of the file containing UUIDs

    Returns:
        list of UUIDs or empty list of no UUIDs in file

    Raises:
        FileNotFoundError if file does not exist
        ValueError if UUID is not in correct format
    """

    if filename == "-":
        return _load_uuid_from_stream(sys.stdin)

    if not pathlib.Path(filename).is_file():
        raise FileNotFoundError(f"Could not find file {filename}")

    with open(filename, "r") as f:
        return _load_uuid_from_stream(f)


def _load_uuid_from_stream(stream: io.IOBase) -> list[str]:
    """
    Load UUIDs from stream.
    Does not validate UUIDs but does validate that the UUIDs are in the correct format.
    Format is 1 UUID per line, any line beginning with # is ignored.
    Whitespace is stripped.

    Arguments:
        filename: file name of the file containing UUIDs

    Returns:
        list of UUIDs or empty list of no UUIDs in file

    Raises:
        ValueError if UUID is not in correct format
    """

    uuid = []
    for line in stream:
        line = line.strip()
        if len(line) and line[0] != "#":
            if not re.match(f"^{UUID_PATTERN}$", line):
                raise ValueError(f"Invalid UUID: {line}")
            line = line.upper()
            uuid.append(line)
    return uuid


def photo_query(
    photosdb: PhotosDB | iPhotoDB, options: QueryOptions
) -> list[PhotoInfo]:
    """Run a query against PhotosDB to extract the photos based on user supplied options

    Args:
        options: a QueryOptions instance
    """

    if options.deleted or options.deleted_only:
        photos = photosdb.photos(
            uuid=options.uuid,
            images=options.photos,
            movies=options.movies,
            from_date=options.from_date,
            to_date=options.to_date,
            intrash=True,
        )
    else:
        photos = []

    if not options.deleted_only:
        photos += photosdb.photos(
            uuid=options.uuid,
            images=options.photos,
            movies=options.movies,
            from_date=options.from_date,
            to_date=options.to_date,
        )

    person = normalize_unicode(options.person)
    keyword = normalize_unicode(options.keyword)
    album = normalize_unicode(options.album)
    folder = normalize_unicode(options.folder)
    title = normalize_unicode(options.title)
    description = normalize_unicode(options.description)
    place = normalize_unicode(options.place)
    label = normalize_unicode(options.label)
    name = normalize_unicode(options.name)

    if album:
        photos = filter_photos_by_folder_album_path(photos, album, options.ignore_case)

    if keyword:
        photos = _get_photos_by_attribute(
            photos, "keywords", keyword, options.ignore_case
        )
    elif options.no_keyword:
        photos = [p for p in photos if not p.keywords]

    if person:
        photos = _get_photos_by_attribute(
            photos, "persons", person, options.ignore_case
        )

    if label:
        photos = _get_photos_by_attribute(photos, "labels", label, options.ignore_case)

    if folder:
        photos = filter_photos_by_folder_album_path(
            photos, folder, options.ignore_case, True
        )

    if title:
        # search title field for text
        # if more than one, find photos with all title values in title
        photo_list = []
        if options.ignore_case:
            # case-insensitive
            for t in title:
                t = t.lower()
                photo_list.extend(
                    [p for p in photos if p.title and t in p.title.lower()]
                )
        else:
            for t in title:
                photo_list.extend([p for p in photos if p.title and t in p.title])
        photos = photo_list
    elif options.no_title:
        photos = [p for p in photos if not p.title]

    if description:
        # search description field for text
        # if more than one, find photos with all description values in description
        photo_list = []
        if options.ignore_case:
            # case-insensitive
            for d in description:
                d = d.lower()
                photo_list.extend(
                    [p for p in photos if p.description and d in p.description.lower()]
                )
        else:
            for d in description:
                photo_list.extend(
                    [p for p in photos if p.description and d in p.description]
                )
        photos = photo_list
    elif options.no_description:
        photos = [p for p in photos if not p.description]

    if place:
        # search place.names for text matching place
        # if more than one place, find photos with all place values in description
        if options.ignore_case:
            # case-insensitive
            for place_name in place:
                place_name = place_name.lower()
                photos = [
                    p
                    for p in photos
                    if p.place
                    and any(
                        pname
                        for pname in p.place.names
                        if any(
                            pvalue for pvalue in pname if place_name in pvalue.lower()
                        )
                    )
                ]
        else:
            for place_name in place:
                photos = [
                    p
                    for p in photos
                    if p.place
                    and any(
                        pname
                        for pname in p.place.names
                        if any(pvalue for pvalue in pname if place_name in pvalue)
                    )
                ]
    elif options.no_place:
        photos = [p for p in photos if not p.place]

    if options.edited:
        photos = [p for p in photos if p.hasadjustments]
    elif options.not_edited:
        photos = [p for p in photos if not p.hasadjustments]

    if options.external_edit:
        photos = [p for p in photos if p.external_edit]

    if options.favorite:
        photos = [p for p in photos if p.favorite]
    elif options.not_favorite:
        photos = [p for p in photos if not p.favorite]

    if options.hidden:
        photos = [p for p in photos if p.hidden]
    elif options.not_hidden:
        photos = [p for p in photos if not p.hidden]

    if options.missing:
        photos = [p for p in photos if not p.path]
    elif options.not_missing:
        photos = [p for p in photos if p.path]

    if options.shared:
        photos = [p for p in photos if p.shared]
    elif options.not_shared:
        photos = [p for p in photos if not p.shared]

    if options.shared:
        photos = [p for p in photos if p.shared]
    elif options.not_shared:
        photos = [p for p in photos if not p.shared]

    if options.uti:
        photos = [p for p in photos if options.uti in p.uti_original]

    if options.burst:
        photos = [p for p in photos if p.burst]
    elif options.not_burst:
        photos = [p for p in photos if not p.burst]

    if options.live:
        photos = [p for p in photos if p.live_photo]
    elif options.not_live:
        photos = [p for p in photos if not p.live_photo]

    if options.portrait:
        photos = [p for p in photos if p.portrait]
    elif options.not_portrait:
        photos = [p for p in photos if not p.portrait]

    if options.screenshot:
        photos = [p for p in photos if p.screenshot]
    elif options.not_screenshot:
        photos = [p for p in photos if not p.screenshot]

    if options.screen_recording:
        photos = [p for p in photos if p.screen_recording]
    elif options.not_screen_recording:
        photos = [p for p in photos if not p.screen_recording]

    if options.slow_mo:
        photos = [p for p in photos if p.slow_mo]
    elif options.not_slow_mo:
        photos = [p for p in photos if not p.slow_mo]

    if options.time_lapse:
        photos = [p for p in photos if p.time_lapse]
    elif options.not_time_lapse:
        photos = [p for p in photos if not p.time_lapse]

    if options.hdr:
        photos = [p for p in photos if p.hdr]
    elif options.not_hdr:
        photos = [p for p in photos if not p.hdr]

    if options.selfie:
        photos = [p for p in photos if p.selfie]
    elif options.not_selfie:
        photos = [p for p in photos if not p.selfie]

    if options.panorama:
        photos = [p for p in photos if p.panorama]
    elif options.not_panorama:
        photos = [p for p in photos if not p.panorama]

    if options.cloudasset:
        photos = [p for p in photos if p.iscloudasset]
    elif options.not_cloudasset:
        photos = [p for p in photos if not p.iscloudasset]

    if options.incloud:
        photos = [p for p in photos if p.incloud and not p.shared]
    elif options.not_incloud:
        photos = [p for p in photos if not p.incloud and not p.shared]

    if options.has_raw:
        photos = [p for p in photos if p.has_raw]

    if options.has_comment:
        photos = [p for p in photos if p.comments]
    elif options.no_comment:
        photos = [p for p in photos if not p.comments]

    if options.has_likes:
        photos = [p for p in photos if p.likes]
    elif options.no_likes:
        photos = [p for p in photos if not p.likes]

    if options.is_reference:
        photos = [p for p in photos if p.isreference]
    elif options.not_reference:
        photos = [p for p in photos if not p.isreference]

    if options.in_album:
        photos = [p for p in photos if p.albums]
    elif options.not_in_album:
        photos = [p for p in photos if not p.albums]

    if options.from_time:
        photos = [p for p in photos if p.date.time() >= options.from_time]

    if options.to_time:
        photos = [p for p in photos if p.date.time() < options.to_time]

    if options.year:
        photos = [p for p in photos if p.date.year in options.year]

    if name:
        # search filename fields for text
        # if more than one, find photos with all title values in filename
        photo_list = []
        if options.ignore_case:
            # case-insensitive
            for n in name:
                n = n.lower()
                if photosdb._db_version >= _PHOTOS_5_VERSION:
                    # search only original_filename (#594)
                    photo_list.extend(
                        [p for p in photos if n in p.original_filename.lower()]
                    )
                else:
                    photo_list.extend(
                        [
                            p
                            for p in photos
                            if n in p.filename.lower()
                            or n in p.original_filename.lower()
                        ]
                    )
        else:
            for n in name:
                if photosdb._db_version >= _PHOTOS_5_VERSION:
                    # search only original_filename (#594)
                    photo_list.extend([p for p in photos if n in p.original_filename])
                else:
                    photo_list.extend(
                        [
                            p
                            for p in photos
                            if n in p.filename or n in p.original_filename
                        ]
                    )
        photos = list(set(photo_list))

    if options.min_size:
        photos = [
            p for p in photos if bitmath.Byte(p.original_filesize) >= options.min_size
        ]

    if options.max_size:
        photos = [
            p for p in photos if bitmath.Byte(p.original_filesize) <= options.max_size
        ]

    if options.regex:
        flags = re.IGNORECASE if options.ignore_case else 0
        render_options = RenderOptions(none_str="")
        photo_list = []
        for regex, template in options.regex:
            regex = re.compile(regex, flags)
            for p in photos:
                rendered, _ = p.render_template(template, render_options)
                for value in rendered:
                    if regex.search(value):
                        photo_list.append(p)
                        break
        photos = photo_list

    if options.query_eval:
        for q in options.query_eval:
            query_string = f"[photo for photo in photos if {q}]"
            try:
                photos = eval(query_string)
            except Exception as e:
                raise ValueError(f"Invalid query_eval CRITERIA: {e}")

    if options.duplicate:
        sort_options = QueryOptions()
        photos = sort_photos([p for p in photos if p.duplicates], sort_options)
        # gather all duplicates but ensure each uuid is only represented once
        photodict = OrderedDict()
        for p in photos:
            if p.uuid not in photodict:
                photodict[p.uuid] = p
                for d in sort_photos(p.duplicates, sort_options):
                    if d.uuid not in photodict:
                        photodict[d.uuid] = d
        photos = list(photodict.values())

        # filter for deleted as photo.duplicates will include photos in the trash
        if not (options.deleted or options.deleted_only):
            photos = [p for p in photos if not p.intrash]
        if options.deleted_only:
            photos = [p for p in photos if p.intrash]

    if options.location:
        photos = [p for p in photos if p.location != (None, None)]
    elif options.no_location:
        photos = [p for p in photos if p.location == (None, None)]

    if options.selected:
        # photos selected in Photos app
        if not is_macos:
            raise NotImplementedError(
                "Query option --selected is only available on macOS"
            )
        try:
            # catch AppleScript errors as the scripting interfce to Photos is flaky
            selected = photoscript.PhotosLibrary().selection
            selected_uuid = [p.uuid for p in selected]
            photos = [p for p in photos if p.uuid in selected_uuid]
        except Exception:
            # no photos selected or a selected photo was "open"
            # selection only works if photos selected in main media browser
            photos = []

    if options.exif:
        matching_photos = []
        for p in photos:
            if not p.exiftool:
                continue
            exifdata = p.exiftool.asdict(normalized=True)
            exifdata.update(p.exiftool.asdict(tag_groups=False, normalized=True))
            for exiftag, exifvalue in options.exif:
                if options.ignore_case:
                    exifvalue = exifvalue.lower()
                    exifdata_value = exifdata.get(exiftag.lower(), "")
                    if isinstance(exifdata_value, str):
                        exifdata_value = exifdata_value.lower()
                    elif isinstance(exifdata_value, Iterable):
                        exifdata_value = [v.lower() for v in exifdata_value]
                    else:
                        exifdata_value = str(exifdata_value)

                    if exifvalue in exifdata_value:
                        matching_photos.append(p)
                else:
                    exifdata_value = exifdata.get(exiftag.lower(), "")
                    if not isinstance(exifdata_value, (str, Iterable)):
                        exifdata_value = str(exifdata_value)
                    if exifvalue in exifdata_value:
                        matching_photos.append(p)
        photos = list(set(matching_photos))

    if options.added_after:
        added_after = options.added_after
        if not datetime_has_tz(added_after):
            added_after = datetime_naive_to_local(added_after)
        photos = [p for p in photos if p.date_added and p.date_added > added_after]

    if options.added_before:
        added_before = options.added_before
        if not datetime_has_tz(added_before):
            added_before = datetime_naive_to_local(added_before)
        photos = [p for p in photos if p.date_added and p.date_added < added_before]

    if options.added_in_last:
        added_after = datetime.datetime.now() - options.added_in_last
        added_after = datetime_naive_to_local(added_after)
        photos = [p for p in photos if p.date_added and p.date_added > added_after]

    if options.syndicated:
        photos = [p for p in photos if p.syndicated]
    elif options.not_syndicated:
        photos = [p for p in photos if not p.syndicated]

    if options.saved_to_library:
        photos = [p for p in photos if p.syndicated and p.saved_to_library]
    elif options.not_saved_to_library:
        photos = [p for p in photos if p.syndicated and not p.saved_to_library]

    if options.shared_moment:
        photos = [p for p in photos if p.shared_moment]
    elif options.not_shared_moment:
        photos = [p for p in photos if not p.shared_moment]

    if options.shared_library:
        photos = [p for p in photos if p.shared_library]
    elif options.not_shared_library:
        photos = [p for p in photos if not p.shared_library]

    if options.function:
        for function in options.function:
            photos = function[0](photos)

    # burst should be checked last, ref #640
    if options.burst_photos:
        # add the burst_photos to the export set
        photos_burst = [p for p in photos if p.burst]
        for burst in photos_burst:
            if options.missing_bursts:
                # include burst photos that are missing
                photos.extend(burst.burst_photos)
            else:
                # don't include missing burst images (these can't be downloaded with AppleScript)
                photos.extend([p for p in burst.burst_photos if not p.ismissing])

        # remove duplicates as each burst photo in the set that's selected would
        # result in the entire set being added above
        # can't use set() because PhotoInfo not hashable
        seen_uuids = {}
        for p in photos:
            if p.uuid in seen_uuids:
                continue
            seen_uuids[p.uuid] = p
        photos = list(seen_uuids.values())

    return sort_photos(photos, options)


def sort_photos(photos: list[PhotoInfo], options: QueryOptions) -> list[PhotoInfo]:
    """Sort photo list results from photo_query"""
    return sorted(
        photos,
        key=lambda x: (
            x.date or DEFAULT_DATE,
            x.date_added or DEFAULT_DATE,
            x.uuid or "",
        ),
        reverse=True if options.newest_first else False,
    )


def filter_photos_by_folder_album_path(
    photos: list[PhotoInfo],
    paths: list[str],
    ignore_case: bool = False,
    folders_only: bool = False,
) -> list[PhotoInfo]:
    """Filter photos based on full album/folder paths

    Args:
        photos: a list of PhotoInfo objects
        paths: list of paths to search for (e.g. ["Folder/Subfolder/Album1", "Folder2/Album2"])
        ignore_case: ignore case when searching
        folders_only: if True, treat paths as folder paths and find photos in any albums within those folders

    Returns:
        list of PhotoInfo objects matching search criteria

    Notes:
        - Paths should be in format "Folder/Subfolder/.../Album" (or just "Folder/Subfolder" if folders_only=True)
        - Forward slashes in folder/album names should be escaped as "//"
        - Multiple paths are combined with OR logic
        - If folders_only=True, finds photos in any albums that are children of the specified folder path
    """
    if not paths:
        return photos

    # Normalize paths for comparison
    normalized_paths = []
    for path in paths:
        # Handle escaped forward slashes: replace "//" with a placeholder
        # then split on "/" and restore the escaped slashes
        placeholder = "\x00ESCAPED_SLASH\x00"
        path_normalized = path.replace("//", placeholder)
        path_parts = [
            part.replace(placeholder, "/") for part in path_normalized.split("/")
        ]

        if ignore_case:
            path_parts = [part.lower() for part in path_parts]

        normalized_paths.append(path_parts)

    matching_photos = []

    for photo in photos:
        photo_matched = False
        # Check each album the photo is in
        for album_info in photo.album_info:
            # Build the full path for this album
            # folder_names gives us the hierarchy: ["Top folder", "sub folder", ...]
            folder_names = album_info.folder_names or []
            album_title = album_info.title or ""

            if folders_only:
                # For folders_only mode, we only care about the folder hierarchy
                # Check if the album's folder path starts with any of our target folder paths
                if ignore_case:
                    folder_names_compare = [part.lower() for part in folder_names]
                else:
                    folder_names_compare = folder_names

                # Check if this album's folder hierarchy matches any target folder path
                for target_path_parts in normalized_paths:
                    # Check if the album's folder hierarchy starts with the target folder path
                    if (
                        len(folder_names_compare) >= len(target_path_parts)
                        and folder_names_compare[: len(target_path_parts)]
                        == target_path_parts
                    ):
                        matching_photos.append(photo)
                        photo_matched = True
                        break  # Found a match for this photo
            else:
                # Original exact path matching logic
                # Construct full path: folder_names + album_title
                full_path_parts = folder_names + [album_title]

                if ignore_case:
                    full_path_parts_compare = [part.lower() for part in full_path_parts]
                else:
                    full_path_parts_compare = full_path_parts

                # Check if this path matches any of our target paths
                for target_path_parts in normalized_paths:
                    if full_path_parts_compare == target_path_parts:
                        matching_photos.append(photo)
                        photo_matched = True
                        break  # Found a match for this photo

            if photo_matched:
                break  # No need to check other albums for this photo

    return list(set(matching_photos))  # Remove duplicates


def _get_photos_by_attribute(photos, attribute, values, ignore_case):
    """Search for photos based on values being in PhotoInfo.attribute

    Args:
        photos: a list of PhotoInfo objects
        attribute: str, name of PhotoInfo attribute to search (e.g. keywords, persons, etc)
        values: list of values to search in property
        ignore_case: ignore case when searching

    Returns:
        list of PhotoInfo objects matching search criteria
    """
    photos_search = []
    if ignore_case:
        # case-insensitive
        for x in values:
            x = x.lower()
            photos_search.extend(
                p
                for p in photos
                if x in [attr.lower() for attr in getattr(p, attribute)]
            )
    else:
        for x in values:
            photos_search.extend(p for p in photos if x in getattr(p, attribute))
    return list(set(photos_search))
