"""Write metadata to files using exiftool"""

from __future__ import annotations

import contextlib
import dataclasses
import datetime
import json
import logging
import os
import pathlib
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from ._constants import _MAX_IPTC_KEYWORD_LEN, _OSXPHOTOS_NONE_SENTINEL, _UNKNOWN_PERSON
from .datetime_utils import datetime_has_tz, datetime_tz_to_utc
from .exiftool import ExifTool, ExifToolCaching
from .exportoptions import ExportOptions
from .image_file_utils import is_video_file
from .phototemplate import RenderOptions

if TYPE_CHECKING:
    from .photoinfo import PhotoInfo

logger = logging.getLogger("osxphotos")

__all__ = [
    "ExifWriter",
    "ExifOptions",
    "exif_options_from_locals",
    "exif_options_from_options",
]


@dataclass
class ExifOptions:
    """Options class for writing metadata to files using exiftool

    all keywords location faces date title description favorite

    Attributes:
        datetime (bool): if True, include date/time in exported metadata
        description (bool): if True, include description in exported metadata
        description_template (str): Optional template string that will be rendered for use as photo description
        exiftool_flags (list of str): Optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
        face_regions: (bool, default=True): if True, will export face regions
        favorite_rating (bool): if True, set XMP:Rating=5 for favorite images and XMP:Rating=0 for non-favorites
        ignore_date_modified (bool): for use with sidecar and exiftool; if True, sets EXIF:ModifyDate to EXIF:DateTimeOriginal even if date_modified is set
        keyword_template (list of str): list of template strings that will be rendered as used as keywords
        keywords (bool): if True, include keywords in exported metadata
        location (bool): if True, include location in exported metadata
        merge_exif_keywords (bool): if True, merged keywords found in file's exif data (requires exiftool)
        merge_exif_persons (bool): if True, merged persons found in file's exif data (requires exiftool)
        persons (bool): if True, include persons in exported metadata
        render_options (RenderOptions): Optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates
        replace_keywords (bool): if True, keyword_template replaces any keywords, otherwise it's additive
        strip (bool): if True, strip whitespace from rendered templates
        title (bool): if True, include title in exported metadata
        use_albums_as_keywords (bool, default = False): if True, will include album names in keywords when exporting metadata with exiftool or sidecar
        use_persons_as_keywords (bool, default = False): if True, will include person names in keywords when exporting metadata with exiftool or sidecar
    """

    datetime: bool = True
    description: bool = True
    description_template: str | None = None
    exiftool_flags: list[str] | None = None
    face_regions: bool = True
    favorite_rating: bool = False
    ignore_date_modified: bool = False
    keyword_template: list[str] | None = None
    keywords: bool = True
    location: bool = True
    merge_exif_keywords: bool = False
    merge_exif_persons: bool = False
    persons: bool = True
    render_options: RenderOptions | None = None
    replace_keywords: bool = False
    strip: bool = False
    title: bool = True
    use_albums_as_keywords: bool = False
    use_persons_as_keywords: bool = False

    def asdict(self):
        return dataclasses.asdict(self)


def exif_options_from_options(export_options: ExportOptions) -> ExifOptions:
    """Given an ExportOptions, which is a superset of ExifOptions, return an ExifOptions object"""
    fields = dataclasses.fields(ExifOptions)
    exif_options = ExifOptions()
    for field in fields:
        if field.name in export_options.__dict__:
            setattr(exif_options, field.name, export_options.__dict__[field.name])
    return exif_options


def exif_options_from_locals(locals_dict: dict[str, Any]) -> ExifOptions:
    """Return ExifOptions object from locals() dict"""
    fields = dataclasses.fields(ExifOptions)
    exif_options = ExifOptions()
    for field in fields:
        if field.name in locals_dict:
            setattr(exif_options, field.name, locals_dict[field.name])
    return exif_options


class _ExifMixin:
    """Mixin class to add methods for getting EXIF data about a photo.
    Must be used in a class that defines self.photo
    Call _ExifMixin.__init__(self) at end of __init__ of class that uses this mixin
    """

    def __init__(self, photo: PhotoInfo):
        self.photo = photo
        self.exiftool_path = self.photo._exiftool_path
        super().__init__()

    def _get_exif_keywords(self) -> list[str]:
        """returns list of keywords found in the file's exif metadata or [] if filepath is None"""
        if not self.photo.path:
            logger.warning(
                f"photo.path is None for photo {self.photo.uuid}, cannot read exif keywords"
            )
            return []
        return self._get_exif_fields_as_list(
            self.photo.path, ["IPTC:Keywords", "XMP:TagsList", "XMP:Subject"]
        )

    def _get_exif_persons(self) -> list[str]:
        """returns list of persons found in the file's exif metadata or [] if filepath is None"""
        if not self.photo.path:
            logger.warning(
                f"photo.path is None for photo {self.photo.uuid}, cannot read exif persons"
            )
            return []
        return self._get_exif_fields_as_list(self.photo.path, ["XMP:PersonInImage"])

    def _get_exif_fields_as_list(self, filepath: str, fields: list[str]) -> list[str]:
        """Return list of values matching fields for exifdict

        Args:
            filepath (str): filepath to read EXIF data from
            fields (list[str]): list of EXIF field names

        Returns:
            list of values for fields (combined as a single list)
        """
        exif = ExifToolCaching(filepath, self.exiftool_path)
        exifdict = exif.asdict()
        values = []
        for field in fields:
            with contextlib.suppress(KeyError):
                kw = exifdict[field]
                if kw and type(kw) != list:
                    kw = [kw]
                kw = [str(k) for k in kw]
                values.extend(kw)
        return values


class ExifWriter(_ExifMixin):
    """Write EXIF & other metadata to files using exiftool for a Photo asset

    Args:
        photo: PhotoInfo, the photo object to write metadata for
    """

    def __init__(self, photo: PhotoInfo):
        """Create instance of ExifWriter

        Args:
            photo: PhotoInfo instance
        """
        super().__init__(photo)
        self._render_options = RenderOptions()

    def write_exif_data(self, filepath: str | pathlib.Path, options: ExifOptions):
        """Write exif data to image file at filepath

        Args:
            options (ExifOptions): options controlling what data is written

        Returns:
            (warning, error) of warning and error strings if exiftool produces warnings or errors
        """
        if not os.path.exists(filepath):
            raise FileNotFoundError(f"Could not find file {filepath}")
        exif_info = self.exiftool_dict(options=options, video=is_video_file(filepath))
        with ExifTool(
            filepath,
            flags=options.exiftool_flags,
            exiftool=self.photo._exiftool_path,
        ) as exiftool:
            for exiftag, val in exif_info.items():
                if isinstance(val, (list, tuple)):
                    for v in val:
                        exiftool.setvalue(exiftag, v)
                else:
                    exiftool.setvalue(exiftag, val)
        return exiftool.warning, exiftool.error

    def exiftool_dict(
        self,
        options: ExifOptions | None = None,
        filename: str | None = None,
        video: bool = False,
    ):
        """Return dict of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
            Does not include all the EXIF fields as those are likely already in the image.

        Args:
            options (ExifOptions): options for export
            filename (str): name of source image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included
            video (bool): if True, returns the video metadata even if asset is a Photo (this is needed for the Quicktime component of a Live Video pair)

        Returns: dict with exiftool tags / values

        Exports the following:
            EXIF:ImageDescription (may include template)
            XMP:Description (may include template)
            XMP:Title
            IPTC:ObjectName
            XMP:TagsList (may include album name, person name, or template)
            IPTC:Keywords (may include album name, person name, or template)
            IPTC:Caption-Abstract
            XMP:Subject (set to keywords + persons)
            XMP:PersonInImage
            EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
            EXIF:GPSLatitude, EXIF:GPSLongitude
            EXIF:GPSPosition
            EXIF:DateTimeOriginal
            EXIF:SubSecTimeOriginal
            EXIF:OffsetTimeOriginal (UTC offset for DateTimeOriginal)
            EXIF:ModifyDate
            EXIF:SubSecTime
            EXIF:OffsetTime (UTC offset for ModifyDate)
            IPTC:DateCreated
            IPTC:TimeCreated
            QuickTime:CreationDate
            QuickTime:ContentCreateDate
            QuickTime:CreateDate (UTC)
            QuickTime:ModifyDate (UTC)
            QuickTime:GPSCoordinates
            UserData:GPSCoordinates
            XMP:Rating
            XMP:RegionAppliedToDimensionsW
            XMP:RegionAppliedToDimensionsH
            XMP:RegionAppliedToDimensionsUnit
            XMP:RegionName
            XMP:RegionType
            XMP:RegionAreaX
            XMP:RegionAreaY
            XMP:RegionAreaW
            XMP:RegionAreaH
            XMP:RegionAreaUnit
            XMP:RegionPersonDisplayName

        Reference:
            https://iptc.org/std/photometadata/specification/IPTC-PhotoMetadata-201610_1.pdf
        """

        options = options or ExifOptions()
        self._render_options = options.render_options or self._render_options

        exif = (
            {
                "SourceFile": filename,
                "ExifTool:ExifToolVersion": "12.00",
                "File:FileName": filename,
            }
            if filename is not None
            else {}
        )

        if options.description:
            description = ""
            if options.description_template is not None:
                # render description template and use that
                render_options = dataclasses.replace(
                    self._render_options, expand_inplace=True, inplace_sep=", "
                )
                rendered = self.photo.render_template(
                    options.description_template, render_options
                )[0]
                description = " ".join(rendered) if rendered else ""
                if options.strip:
                    description = description.strip()
            else:
                # use photos' description
                description = self.photo.description
            if description:
                exif["EXIF:ImageDescription"] = description
                exif["XMP:Description"] = description
                exif["IPTC:Caption-Abstract"] = description

        if options.title and self.photo.title:
            exif["XMP:Title"] = self.photo.title
            exif["IPTC:ObjectName"] = self.photo.title

        # do persons and keywords
        # keywords can contain persons and albums
        person_list = []
        keyword_list = []
        if options.persons:
            if options.merge_exif_persons:
                person_list.extend(self._get_exif_persons())

            if self.photo.persons:
                # filter out _UNKNOWN_PERSON
                person_list.extend(
                    [p for p in self.photo.persons if p != _UNKNOWN_PERSON]
                )

            if options.use_persons_as_keywords and person_list:
                keyword_list.extend(person_list)

        if options.keywords:
            if options.merge_exif_keywords:
                keyword_list.extend(self._get_exif_keywords())

            if self.photo.keywords and not options.replace_keywords:
                keyword_list.extend(self.photo.keywords)

            if options.use_albums_as_keywords and self.photo.albums:
                keyword_list.extend(self.photo.albums)

            if options.keyword_template:
                rendered_keywords = []
                render_options = dataclasses.replace(
                    self._render_options,
                    none_str=_OSXPHOTOS_NONE_SENTINEL,
                    path_sep="/",
                )
                for template_str in options.keyword_template:
                    rendered, unmatched = self.photo.render_template(
                        template_str, render_options
                    )
                    if unmatched:
                        logger.warning(
                            f"Unmatched template substitution for template: {template_str} {unmatched}"
                        )
                    rendered_keywords.extend(rendered)

                if options.strip:
                    rendered_keywords = [
                        keyword.strip() for keyword in rendered_keywords
                    ]

                # filter out any template values that didn't match by looking for sentinel
                rendered_keywords = [
                    keyword
                    for keyword in sorted(rendered_keywords)
                    if _OSXPHOTOS_NONE_SENTINEL not in keyword
                ]

                # check to see if any keywords too long
                long_keywords = [
                    long_str
                    for long_str in rendered_keywords
                    if len(long_str) > _MAX_IPTC_KEYWORD_LEN
                ]
                if long_keywords:
                    logger.warning(
                        f"Warning: some keywords exceed max IPTC Keyword length of {_MAX_IPTC_KEYWORD_LEN} (exiftool will truncate these): {long_keywords}"
                    )

                keyword_list.extend(rendered_keywords)

            if keyword_list:
                # remove duplicates
                keyword_list = sorted(
                    list(set(str(keyword) for keyword in keyword_list))
                )
                keyword_list = keyword_list.copy()
                exif["IPTC:Keywords"] = keyword_list
                exif["XMP:Subject"] = keyword_list
                exif["XMP:TagsList"] = keyword_list

        if options.persons and person_list:
            person_list = sorted(list(set(person_list)))
            exif["XMP:PersonInImage"] = person_list.copy()

        if options.face_regions and self.photo.face_info:
            exif.update(self._get_mwg_face_regions_exiftool())

        if self.photo.rating != 0:
            exif["XMP:Rating"] = self.photo.rating

        if options.favorite_rating:
            exif["XMP:Rating"] = 5 if self.photo.favorite else 0

        if self.photo._db._source == "iPhoto":
            # iPhoto (but not Photos) has star rating so use those
            exif["XMP:Rating"] = self.photo.rating

        if options.location:
            (lat, lon) = self.photo.location
            if lat is not None and lon is not None:
                if video or self.photo.ismovie:
                    exif["Keys:GPSCoordinates"] = f"{lat} {lon}"
                    exif["UserData:GPSCoordinates"] = f"{lat} {lon}"
                else:
                    exif["EXIF:GPSLatitude"] = lat
                    exif["EXIF:GPSLongitude"] = lon
                    lat_ref = "N" if lat >= 0 else "S"
                    lon_ref = "E" if lon >= 0 else "W"
                    exif["EXIF:GPSLatitudeRef"] = lat_ref
                    exif["EXIF:GPSLongitudeRef"] = lon_ref

        # process date/time and timezone offset
        # Photos exports the following fields and sets modify date to creation date
        # [EXIF]    Modify Date             : 2020:10:30 00:00:00
        # [EXIF]    Date/Time Original      : 2020:10:30 00:00:00
        # [EXIF]    Create Date             : 2020:10:30 00:00:00
        # [IPTC]    Digital Creation Date   : 2020:10:30
        # [IPTC]    Date Created            : 2020:10:30
        #
        # for videos:
        # [QuickTime]     CreateDate                      : 2020:12:11 06:10:10
        # [QuickTime]     ModifyDate                      : 2020:12:11 06:10:10
        # [Keys]          CreationDate                    : 2020:12:10 22:10:10-08:00
        # This code deviates from Photos in one regard:
        # if photo has modification date, use it otherwise use creation date

        if options.datetime:
            date = self.photo.date
            offsettime = utc_offset_time(date)
            subsec = subsec_time(date)

            # exiftool expects format to "2015:01:18 12:00:00"
            datetimeoriginal = exiftool_datetime(date)

            if video or self.photo.ismovie:
                # QuickTime spec specifies times in UTC
                # QuickTime:CreateDate and ModifyDate are in UTC w/ no timezone
                # QuickTime:CreationDate must include time offset or Photos shows invalid values
                # reference: https://exiftool.org/TagNames/QuickTime.html#Keys
                #            https://exiftool.org/forum/index.php?topic=11927.msg64369#msg64369
                exif["QuickTime:CreationDate"] = f"{datetimeoriginal}{offsettime}"

                # also add QuickTime:ContentCreateDate
                # reference: https://github.com/RhetTbull/osxphotos/pull/888
                # exiftool writes this field with timezone so include it here
                exif["QuickTime:ContentCreateDate"] = f"{datetimeoriginal}{offsettime}"

                date_utc = datetime_tz_to_utc(date)
                creationdate = exiftool_datetime(date_utc)
                exif["QuickTime:CreateDate"] = creationdate
                if self.photo.date_modified is None or options.ignore_date_modified:
                    exif["QuickTime:ModifyDate"] = creationdate
                else:
                    exif["QuickTime:ModifyDate"] = exiftool_datetime(
                        datetime_tz_to_utc(self.photo.date_modified)
                    )
            else:
                exif["EXIF:DateTimeOriginal"] = datetimeoriginal
                exif["EXIF:CreateDate"] = datetimeoriginal
                exif["EXIF:SubSecTimeOriginal"] = subsec
                exif["EXIF:OffsetTimeOriginal"] = offsettime

                dateoriginal = date.strftime("%Y:%m:%d")
                exif["IPTC:DateCreated"] = dateoriginal

                timeoriginal = date.strftime(f"%H:%M:%S{offsettime}")
                exif["IPTC:TimeCreated"] = timeoriginal

                if (
                    self.photo.date_modified is not None
                    and not options.ignore_date_modified
                ):
                    exif["EXIF:ModifyDate"] = exiftool_datetime(
                        self.photo.date_modified
                    )
                    exif["EXIF:SubSecTime"] = subsec_time(self.photo.date_modified)
                    exif["EXIF:OffsetTime"] = utc_offset_time(self.photo.date_modified)
                else:
                    exif["EXIF:ModifyDate"] = exiftool_datetime(self.photo.date)
                    exif["EXIF:SubSectime"] = subsec
                    exif["EXIF:OffsetTime"] = offsettime

        # if photo in PNG remove any IPTC tags (#1031)
        if self.photo.isphoto and self.photo.uti == "public.png":
            exif = {k: v for k, v in exif.items() if not k.startswith("IPTC:")}

        return exif

    def _get_mwg_face_regions_exiftool(self):
        """Return a dict with MWG face regions for use by exiftool"""
        if self.photo.orientation in [5, 6, 7, 8]:
            w = self.photo.height
            h = self.photo.width
        else:
            w = self.photo.width
            h = self.photo.height

        exif = {
            "XMP-mwg-rs:RegionInfo": {
                "AppliedToDimensions": {"W": w, "H": h, "Unit": "pixel"},
                "RegionList": [],
            }
        }

        for face in self.photo.face_info:
            if not face.name:
                continue
            area = face.mwg_rs_area
            region = {
                "Name": face.name,
                "Type": "Face",
                "Area": {
                    "X": area.x,
                    "Y": area.y,
                    "W": area.w,
                    "H": area.h,
                    "Unit": "normalized",
                },
            }
            exif["XMP-mwg-rs:RegionInfo"]["RegionList"].append(region)
        return exif

    def exiftool_json_sidecar(
        self,
        options: ExifOptions | None = None,
        tag_groups: bool = True,
        filename: str | None = None,
    ) -> str:
        """Return JSON string of EXIF details for building exiftool JSON sidecar or sending commands to ExifTool.
            Does not include all the EXIF fields as those are likely already in the image.

        Args:
            options (ExifOptions): options for export
            tag_groups (bool, default=True): if True, include tag groups in the output
            filename (str): name of target image file (without path); if not None, exiftool JSON signature will be included; if None, signature will not be included

        Returns: JSON string for dict with exiftool tags / values

        Exports the following:
            EXIF:ImageDescription (may include template)
            XMP:Description (may include template)
            XMP:Title
            IPTC:ObjectName
            XMP:TagsList (may include album name, person name, or template)
            IPTC:Keywords (may include album name, person name, or template)
            IPTC:Caption-Abstract
            XMP:Subject (set to keywords + persons)
            XMP:PersonInImage
            EXIF:GPSLatitudeRef, EXIF:GPSLongitudeRef
            EXIF:GPSLatitude, EXIF:GPSLongitude
            EXIF:GPSPosition
            EXIF:DateTimeOriginal
            EXIF:SubSecTimeOriginal
            EXIF:OffsetTimeOriginal (UTC offset for DateTimeOriginal)
            EXIF:ModifyDate
            EXIF:SubSecTime
            EXIF:OffsetTime (UTC offset for ModifyDate)
            IPTC:DateCreated
            IPTC:TimeCreated
            QuickTime:CreationDate
            QuickTime:ContentCreateDate
            QuickTime:CreateDate (UTC)
            QuickTime:ModifyDate (UTC)
            QuickTime:GPSCoordinates
            UserData:GPSCoordinates
            XMP:Rating
            XMP:RegionAppliedToDimensionsW
            XMP:RegionAppliedToDimensionsH
            XMP:RegionAppliedToDimensionsUnit
            XMP:RegionName
            XMP:RegionType
            XMP:RegionAreaX
            XMP:RegionAreaY
            XMP:RegionAreaW
            XMP:RegionAreaH
            XMP:RegionAreaUnit
            XMP:RegionPersonDisplayName
        """

        options = options or ExifOptions()
        exif = self.exiftool_dict(options=options, filename=filename)

        if not tag_groups:
            # strip tag groups
            exif_new = {}
            for k, v in exif.items():
                k = re.sub(r".*:", "", k)
                exif_new[k] = v
            exif = exif_new

        return json.dumps([exif])


def utc_offset_time(dt: datetime.datetime) -> str:
    """Find the UTC offset for a datetime in format expected by exiftool (+/-HH:MM)

    Args:
        dt: datetime object to find offset for

    Returns: string with offset in format "+/-HH:MM

    Raises:
        ValueError if datetime is not timezone aware or if timezone cannot be determined
    """
    if not datetime_has_tz(dt):
        raise ValueError("datetime must be timezone aware")
    offsettime = dt.strftime("%z")
    offset = re.findall(r"([+-]?)([\d]{2})([\d]{2})", offsettime)
    if not offset:
        raise ValueError(f"could not parse timezone from datetime {dt}")
    offset = offset[0]  # findall returns list of tuples
    if len(offset) != 3:
        raise ValueError(f"could not parse timezone from datetime {dt}")
    offsettime = f"{offset[0]}{offset[1]}:{offset[2]}"
    return offsettime


def exiftool_datetime(dt: datetime.datetime) -> str:
    """Format a datetime to the format expected by exiftool (YYYY:MM:DD HH:MM:SS)

    Args:
        dt: datetime to format

    Returns: string formatted as date/time value expected by exiftool in YYYY:MM:DD HH:MM:SS format
    """
    return dt.strftime("%Y:%m:%d %H:%M:%S")


def subsec_time(dt: datetime.datetime) -> str:
    """Return sub-second time as a string as expected by exiftool for EXIF:SubSecTime"""
    # strftime("%f") returns microseconds but only want milliseconds
    # if sub-second time is 0, it will return all zeros
    # strip off trailing zeroes
    return dt.strftime("%f")[:-3].rstrip("0")
