import csv
import datetime
import json
import os
import shutil
import tempfile
import time
from pathlib import Path

import pytz
import rasterio.crs
from PIL import Image
from colorama import Fore
from moviepy.editor import ImageSequenceClip
from rasterio.enums import Resampling
from tqdm import tqdm

from goesvideo import exceptions
from goesvideo.utils import editortools, gistools

"""
The GoesSceneEditor class is intended for post-processing of GOES scenes composited using
goesvideo.GoesCompositor or goesvideo.GoesAnimator classes. The workflow for post-processing
is streamlined through the use of sessions which are automatically created and stored in the
base dir under the "SceneEdits" subfolder. This approach ensures that the originally composited
images are left untouched so that they do not ever have to be re-composited unless the user
wishes to enlarge the bounding box or change the composited scene.

There are several functions available for image manipulations in this class: resize, recrop, 
reproject, add_timestamps, add_annotations, add_overlay. These functions can be chained together
in series to perform multiple manipulations. If desired, a single preview image can be generated for 
each function in the chain by setting the 'preview' argument to True in all of the chained functions.
When the user is satisfied with the output image, setting the 'preview' argument to False will
perform the modification on all of the images in the source folder path. 

Example Usage:
editor = GoesSceneEditor('~/goesimagery', 'Hawaii_Closeup', session_name='Reprojected_Annotated')
editor.reproject('EPSG:4326', preview=True)
editor.add_timestamps('upper-left', tz=pytz.utc, tzabbr='UTC', preview=True)

Finally, note that this class will handle either simple image (png) or geotiff images while preserving
the underlying geographic data of the image. Geographic operations with png files is possible due to
the metadata.json file that is automatically generated by the GoesCompositor and GoesAnimator classes.
"""


class GoesSceneEditor:
    def __init__(
        self, base_dir, scene_folder=None, session_name=None, reset_session=False
    ):
        """
        :param base_dir: (str) path to base directory; should be same as used for GoesCompositor/GoesAnimator
        :param scene_folder: (str) name of scene folder located in "basedir/Scenes" or an already existing edit
                             in "basedir/SceneEdits"
        :param session_name: (str) optional name for this edit session; if not provided, "Default_Session" will be
                             used
        :param reset_session: (bool) if True, any existing edit session with name 'session_name' will be overwritten
        """
        self.base_dir = Path(base_dir)
        self.scene_folder = scene_folder
        source_dir = Path(base_dir) / "Scenes" / scene_folder
        if not session_name:
            self.output_path = (
                Path(base_dir) / "SceneEdits" / scene_folder / "Default_Session"
            )
            self.session_name = "Default_Session"
        else:
            self.output_path = (
                Path(base_dir) / "SceneEdits" / scene_folder / session_name
            )
            self.session_name = session_name

        try:
            self.output_path.mkdir(parents=True, exist_ok=False)
            for f in source_dir.glob("*.*"):
                shutil.copyfile(str(f), str(self.output_path / f.name))
        except FileExistsError:
            if reset_session:
                shutil.rmtree(str(self.output_path))
                self.output_path.mkdir(parents=True, exist_ok=False)
                for f in source_dir.glob("*.*"):
                    shutil.copyfile(str(f), str(self.output_path / f.name))

        with open(str(self.output_path / "metadata.json"), "r") as file:
            metadata = json.load(file)

        crs = rasterio.crs.CRS.from_wkt(metadata["geodata"]["crs"])

        # Crop box is saved in lat/lon units by GoesCompositor and GoesAnimator, which
        # differs from the CRS. So, we need to try to convert the bbox to match the CRS
        # If this conversion fails, assume that the bbox is already stored in the same
        # units as the CRS in the metadata file
        if "Crop_Box" in metadata:
            bbox = (
                metadata["Crop_Box"][0],
                metadata["Crop_Box"][1],
                metadata["Crop_Box"][2],
                metadata["Crop_Box"][3],
            )
            try:
                bbox = gistools.transform_bbox(bbox, "EPSG:4326", crs)
            except rasterio._err.CPLE_AppDefinedError:
                pass
        else:
            if metadata["Satellite"] == "goes-east" and metadata["Region"] == "conus":
                bbox = (-3627271.2913, 1583173.6575, 1382771.9287, 4589199.5895)
            elif metadata["Satellite"] == "goes-east" and metadata["Region"] == "full":
                bbox = (-5434894.8851, -5434894.8851, 5434894.8851, 5434894.8851)
            elif metadata["Satellite"] == "goes-west" and metadata["Region"] == "conus":
                bbox = (-2505021.61, 1583173.6575, 2505021.61, 4589199.5895)
            elif metadata["Satellite"] == "goes-west" and metadata["Region"] == "full":
                bbox = (-5434894.8851, -5434894.8851, 5434894.8851, 5434894.8851)
            else:
                bbox = None

        resolution = metadata["Resolution"]

        self.img_timestamps = []
        self.img_filenames = []
        tz = pytz.timezone("UTC")
        with open(str(self.output_path / "timestamps.csv"), newline="") as csvfile:
            reader = csv.reader(csvfile, delimiter=",")
            for row in reader:
                if os.path.exists(str(self.output_path / row[1])):
                    self.img_filenames.append(row[1])
                    self.img_timestamps.append(
                        tz.localize(datetime.datetime.fromisoformat(row[0]))
                    )
                else:
                    print(
                        f"{Fore.RED}WARNING - File '{row[1]}' missing from '{self.scene_folder}'. Results may "
                        f"be unexpected."
                    )

        if self.img_filenames[0].split(".")[-1] == "png":
            self.type_is_geotiff = False
        elif self.img_filenames[0].split(".")[-1] == "tif":
            self.type_is_geotiff = True
        else:
            self.type_is_geotiff = False

        self.font = {"path": None, "color": None, "size": None}
        self.annotation_queue = []

        metadata["geodata"].pop("crs", None)
        transform = metadata["geodata"].pop("transform", None)
        self.geodata = {
            "image": {
                "bbox": bbox,
                "crs": crs,
                "transform": transform,
                "res_x": resolution,
                "res_y": resolution,
                "raster_profile": metadata["geodata"],
            },
            "preview": {
                "png_cache": None,
                "tif_cache": None,
                "exists": False,
                "bbox": bbox,
                "crs": crs,
                "transform": transform,
                "res_x": resolution,
                "res_y": resolution,
                "raster_profile": metadata["geodata"],
            },
        }

        self._create_tmp_files()

    def resize(self, newwidth, newheight, resample=None, preview=False, force=False):
        """
        Resize image(s)
        :param newwidth: (int) desired output width in pixels
        :param newheight: (int) desired output height in pixels
        :param resample: (rasterio.enums.Resampling) resampling mode
        :param preview: (bool) generate a single preview image
        :param force: (bool) default behavior is to not execute the function if it will not result in a detectable
                      change to the source image. This can be overriden by setting this parameter to True.
        :return: None
        """
        if not force and not preview:
            if self.geodata["image"]["raster_profile"]["height"] == newheight:
                if self.geodata["image"]["raster_profile"]["width"] == newwidth:
                    print(
                        f"{Fore.RED}Images in Scene: {self.scene_folder}, Session: {self.session_name} are "
                        f"already sized at the requested dimensions. Skipping resizing."
                    )
                    return

        print(
            f"{Fore.GREEN}Resizing images in Scene: {self.scene_folder}, Session: {self.session_name}..."
        )
        time.sleep(0.5)

        for f in tqdm(self.img_filenames, colour="green"):
            if not preview:
                outfile = str(self.output_path / f)
                infile = str(self.output_path / f)
                geodata = self.geodata["image"]
            else:
                infile, outfile = self._get_paths(f)
                geodata = self.geodata["preview"]

            if self.type_is_geotiff:
                _, gdict = gistools.resize(
                    infile, newwidth, newheight, resample=resample, outfile=outfile
                )
            else:
                _, gdict = gistools.resize(
                    infile,
                    newwidth,
                    newheight,
                    geodata=geodata,
                    resample=resample,
                    outfile=outfile,
                )

            if preview:
                break
            else:
                os.replace(outfile, str(self.output_path / f))

        if gdict:
            geodata.update(gdict)

        if self.type_is_geotiff:
            gistools.update_geodata(
                str(self.output_path / self.img_filenames[0]), geodata
            )

        if preview:
            self._finalize_preview(outfile)
        else:
            self._update_metadata()

        return

    def reproject(self, dst_crs, preview=False, force=False):
        """
        Reproject image(s) to a new CRS
        :param dst_crs: (str) or (rasterio.CRS) desired output CRS
        :param preview: (bool) generate a single preview image
        :param force: (bool) default behavior is to not execute the function if it will not result in a detectable
                      change to the source image. This can be overriden by setting this parameter to True.
        :return: None
        """
        if not force and not preview:
            if isinstance(dst_crs, str):
                _crs = rasterio.crs.CRS(dst_crs)
                _crs = _crs.wkt
            else:
                _crs = dst_crs

            if self.geodata["image"]["crs"] == _crs:
                print(
                    f"{Fore.RED}Images in Scene: {self.scene_folder}, Session: {self.session_name} are already "
                    f"in the requested projection. Skipping reprojection."
                )
                return

        print(
            f"{Fore.GREEN}Reprojecting images in Scene: {self.scene_folder}, Session: {self.session_name}..."
        )
        time.sleep(0.5)

        for f in tqdm(self.img_filenames, colour="green"):
            if not preview:
                outfile = str(self.output_path / f)
                infile = str(self.output_path / f)
                geodata = self.geodata["image"]
            else:
                infile, outfile = self._get_paths(f)
                geodata = self.geodata["preview"]

            if self.type_is_geotiff:
                _, gdict = gistools.reproject(infile, out_crs=dst_crs, outfile=outfile)
            else:
                _, gdict = gistools.reproject(
                    infile, out_crs=dst_crs, geodata=geodata, outfile=outfile
                )

            if preview:
                break

        if gdict:
            geodata.update(gdict)

        if self.type_is_geotiff:
            gistools.update_geodata(
                str(self.output_path / self.img_filenames[0]), geodata
            )

        if preview:
            self._finalize_preview(outfile)
        else:
            self._update_metadata()

        return

    def recrop(self, new_bbox, preview=False, force=False):
        """
        Crop image(s) to new bbox
        :param new_bbox: (tup) coordinates of bbox given as (west, south, east, north) in lat/lon
                               or image
        :param preview: (bool) generate a single preview image
        :param force: (bool) default behavior is to not execute the function if it will not result in a detectable
                      change to the source image. This can be overriden by setting this parameter to True.
        :return: None
        """
        # Check bbox
        if isinstance(new_bbox[0], str):
            coord1 = gistools.convert_dms_to_dd((new_bbox[1], new_bbox[0]))
            coord2 = gistools.convert_dms_to_dd((new_bbox[3], new_bbox[2]))
            new_bbox = (coord1[1], coord1[0], coord2[1], coord2[0])

        if not force and not preview:
            if self.geodata["image"]["bbox"] == new_bbox:
                print(
                    f"{Fore.RED}Images in Scene: {self.scene_folder}, Session: {self.session_name} are already "
                    f"cropped to the requested bbox. Skipping recropping."
                )
            return

        if preview:
            geodata = self.geodata["preview"]
        else:
            geodata = self.geodata["image"]

        # Ensure bbox is within bounds of image
        ll_bbox = gistools.transform_bbox(geodata["bbox"], geodata["crs"], "EPSG:4326")
        lat_ok, lon_ok = gistools.check_bbox(new_bbox, ll_bbox, strict=True)

        if not lat_ok or not lon_ok:
            print(
                f"{Fore.RED}WARNING - Requested bbox for recropping is outside the bounds of the original image. "
            )
            print("Results may be unexpected.")

        print(
            f"{Fore.GREEN}Recropping images in Scene: {self.scene_folder}, Session: {self.session_name}..."
        )
        time.sleep(0.5)
        for f in tqdm(self.img_filenames, colour="green"):
            if not preview:
                outfile = str(self.output_path / f)
                infile = str(self.output_path / f)
            else:
                infile, outfile = self._get_paths(f)

            if self.type_is_geotiff:
                _, gdict = gistools.crop(infile, new_bbox, outfile=outfile)
            else:
                _, gdict = gistools.crop(
                    infile, new_bbox, geodata=geodata, outfile=outfile
                )

            if preview:
                break
            else:
                os.replace(outfile, str(self.output_path / f))

        if gdict:
            geodata.update(gdict)

        if self.type_is_geotiff:
            gistools.update_geodata(
                str(self.output_path / self.img_filenames[0]), geodata
            )

        if preview:
            self._finalize_preview(outfile)
        else:
            self._update_metadata()

        return

    def adjust_alpha(self, opacity, preview=False):
        """
        Adjust the transparency of image(s)
        :param opacity: (float) Value between 0 and 1, with 0 being fully transparent
        :param preview: (bool) generate a single preview image
        :return: None
        """

        print(
            f"{Fore.GREEN}Adjusting transparency of images in Scene: {self.scene_folder}, Session: {self.session_name}..."
        )
        time.sleep(0.5)
        for f in tqdm(self.img_filenames, colour="green"):
            if not preview:
                outfile = str(self.output_path / f)
                infile = str(self.output_path / f)
            else:
                infile, outfile = self._get_paths(f)

            gistools.adjust_alpha(infile, opacity, outfile=outfile)

            if preview:
                break

        if preview:
            self._finalize_preview(outfile)

        return

    def add_timestamps(
        self, position="upper-left", tz=None, tzabbr=None, preview=False
    ):
        """
        Add timestamp(s) to image(s)
        :param position: (str) or (tup) choose from predefined positions 'upper-left', 'upper-middle',
                         'upper-right', 'lower-left', 'lower-middle', 'lower-right' or provide a tuple
                         with desired pixel position (x, y)
        :param tz: (pytz tz) pytz timezone object (e.g. pytz.timezone('US/Eastern')
        :param tzabbr: (str) 3-character abbreviation for timezone, if desired (e.g. 'EDT')
        :param preview: (bool) generate a single preview image
        :return: None
        """

        if not self.font["path"]:
            raise exceptions.UnspecifiedFontPathError(
                "Font path for ttf font must be set by calling 'set_font'"
            )
        if not self.font["color"]:
            self.font["color"] = (255, 0, 0)
        if not self.font["size"]:
            self.font["size"] = 10

        if not tz:
            tz = pytz.utc
            tzabbr = "UTC"

        tsopts = {"text": {}}
        tsopts["text"] = {
            "label": "",
            "position": position,
            "fontpath": self.font["path"],
            "fontcolor": self.font["color"],
            "fontsize": self.font["size"],
        }

        print(
            f"{Fore.GREEN}Adding timestamps to images in Scene: {self.scene_folder}, Session: {self.session_name}..."
        )
        time.sleep(0.5)
        for i, f in enumerate(tqdm(self.img_filenames, colour="green")):
            if not preview:
                outfile = str(self.output_path / f)
                infile = str(self.output_path / f)
                geodata = self.geodata["image"]
            else:
                infile, outfile = self._get_paths(f)
                geodata = self.geodata["preview"]

            tstr = datetime.datetime.isoformat(
                self.img_timestamps[i].astimezone(tz), timespec="seconds"
            )
            tstr = tstr[0:-6]
            tstr = tstr.replace("T", " ")
            tstr += " " + tzabbr
            tsopts["text"]["label"] = tstr

            if self.type_is_geotiff:
                newimg = gistools.geotiff_to_image(infile)
                newimg = editortools.modify_image(newimg, **tsopts)
                gistools.image_to_geotiff(newimg, geodata=geodata, outfile=outfile)
            else:
                newimg = Image.open(infile)
                newimg = editortools.modify_image(newimg, **tsopts)
                newimg.save(outfile)
                newimg.close()

            if preview:
                break

        if preview:
            self._finalize_preview(outfile)

        return

    def add_annotation(
        self,
        objtype,
        position,
        label=None,
        labelopts=None,
        timerange=None,
        **extraobjopts,
    ):
        newopts = {}
        if extraobjopts:
            newopts[objtype] = extraobjopts
        else:
            newopts[objtype] = {}

        # Check position argument to make sure it is correct for the given objtype
        if objtype == "text":
            if not isinstance(position, tuple) and not isinstance(position, str):
                raise exceptions.InvalidArgumentError(
                    f"Position of '{objtype}' must be passed as a tuple or string to "
                    "'add_annotation'."
                )

        elif objtype == "circle":
            if not isinstance(position, tuple):
                raise exceptions.InvalidArgumentError(
                    f"Position of '{objtype}' must be passed as a tuple to 'add_annotation'."
                )
        elif objtype == "arrow":
            if not isinstance(position, list):
                raise exceptions.InvalidArgumentError(
                    f"Position of '{objtype}' must be passed as a list containing two tuples to 'add_annotation'."
                )
            elif len(position) != 2:
                raise exceptions.InvalidArgumentError(
                    f"Position of '{objtype}' must be passed as a list containing two tuples to 'add_annotation'."
                )
        elif objtype == "triangle":
            if not isinstance(position, list):
                raise exceptions.InvalidArgumentError(
                    f"Position of '{objtype}' must be passed as a list containing three tuples to 'add_annotation'."
                )
            elif len(position) != 3:
                raise exceptions.InvalidArgumentError(
                    f"Position of '{objtype}' must be passed as a list containing three tuples to 'add_annotation'."
                )

        # Pack label
        if label:
            newopts[objtype]["label"] = {}
            newopts[objtype]["label"]["label"] = label
            if labelopts:
                newopts[objtype]["label"] = newopts[objtype]["label"] | labelopts
                if "fontpath" not in newopts[objtype]["label"]:
                    if self.font["path"]:
                        newopts[objtype]["label"]["fontpath"] = self.font["path"]
                    else:
                        raise exceptions.UnspecifiedFontPathError(
                            "Must set ttf font path by calling 'set_font' or"
                            "pass 'fontsize' as labelopts."
                        )
                if "fontcolor" not in newopts[objtype]["label"]:
                    if self.font["color"]:
                        newopts[objtype]["label"]["fontcolor"] = self.font["color"]
                    else:
                        newopts[objtype]["label"]["fontcolor"] = (255, 0, 0)
                if "fontsize" not in newopts[objtype]["label"]:
                    if self.font["size"]:
                        newopts[objtype]["label"]["fontsize"] = self.font["size"]
                    else:
                        newopts[objtype]["label"]["fontsize"] = 10
            else:
                if self.font["path"]:
                    newopts[objtype]["label"]["fontpath"] = self.font["path"]
                else:
                    raise exceptions.UnspecifiedFontPathError(
                        "Must set ttf font path by calling 'set_font' or"
                        "pass 'fontsize' as labelopts."
                    )
                if self.font["color"]:
                    newopts[objtype]["label"]["fontcolor"] = self.font["color"]
                else:
                    newopts[objtype]["label"]["fontcolor"] = (255, 0, 0)
                if self.font["size"]:
                    newopts[objtype]["label"]["fontsize"] = self.font["size"]
                else:
                    newopts[objtype]["label"]["fontsize"] = 10

            if objtype == "text":
                newopts[objtype] = newopts[objtype].pop("label", None)

        if objtype == "text":
            newopts[objtype]["position"] = position
            if isinstance(position[0], str):
                if "N" in position[0] or "S" in position[0]:
                    is_geographic = True
                    is_dms = True
                else:
                    is_geographic = False
                    is_dms = False
            elif isinstance(position[0], float):
                is_geographic = True
                is_dms = False
            else:
                is_geographic = False
                is_dms = False
            newopts[objtype]["is_geographic"] = is_geographic
            newopts[objtype]["is_dms"] = is_dms

        elif objtype == "arrow":
            newopts[objtype]["start_position"] = position[0]
            newopts[objtype]["end_position"] = position[1]
            if isinstance(position[0][0], str):
                if "N" in position[0][0] or "S" in position[0][0]:
                    is_geographic = True
                    is_dms = True
                else:
                    is_geographic = False
                    is_dms = False
            elif isinstance(position[0][0], float):
                is_geographic = True
                is_dms = False
            else:
                is_geographic = False
                is_dms = False
            newopts[objtype]["is_geographic"] = is_geographic
            newopts[objtype]["is_dms"] = is_dms

        elif objtype == "circle":
            newopts[objtype]["centerpos"] = position
            if isinstance(position[0], str):
                if "N" in position[0] or "S" in position[0]:
                    is_geographic = True
                    is_dms = True
                else:
                    is_geographic = False
                    is_dms = False
            elif isinstance(position[0], float):
                is_geographic = True
                is_dms = False
            else:
                is_geographic = False
                is_dms = False
            newopts[objtype]["is_geographic"] = is_geographic
            newopts[objtype]["is_dms"] = is_dms

        elif objtype == "triangle":
            newopts[objtype]["coords"] = [position[0], position[1], position[2]]
            if isinstance(position[0][0], str):
                if "N" in position[0][0] or "S" in position[0][0]:
                    is_geographic = True
                    is_dms = True
                else:
                    is_geographic = False
                    is_dms = False
            elif isinstance(position[0][0], float):
                is_geographic = True
                is_dms = False
            else:
                is_geographic = False
                is_dms = False
            newopts[objtype]["is_geographic"] = is_geographic
            newopts[objtype]["is_dms"] = is_dms

        # Check for valid time range
        if timerange:
            tstart = timerange[0]
            tend = timerange[1]
            if tstart >= self.img_timestamps[0] and tend <= self.img_timestamps[-1]:
                newopts["start_time"] = tstart
                newopts["end_time"] = tend
            else:
                print(
                    f"{Fore.RED}WARNING - Requested time range for annotation '{objtype}' is outside the range "
                    f"of Scene: {self.scene_folder}. Ignoring the requested range."
                )

        self.annotation_queue.append(newopts)

        return

    def process_annotations(self, preview=False):
        print(
            f"{Fore.GREEN}Adding annotations to images in Scene: {self.scene_folder}, Session: {self.session_name}..."
        )
        time.sleep(0.5)
        for i, f in enumerate(tqdm(self.img_filenames, colour="green")):
            if not preview:
                outfile = str(self.output_path / f)
                infile = str(self.output_path / f)
                geodata = self.geodata["image"]
            else:
                infile, outfile = self._get_paths(f)
                geodata = self.geodata["preview"]

            counter = 0
            for opts in self.annotation_queue:
                start_time = opts.pop("start_time", None)
                end_time = opts.pop("end_time", None)
                skip = False
                if start_time and end_time:
                    if start_time <= self.img_timestamps[i] <= end_time:
                        skip = False
                    else:
                        skip = True

                if not skip:
                    opts = self._convert_geo_position(opts, geodata)
                    if self.type_is_geotiff:
                        img = gistools.geotiff_to_image(infile)
                        newimg = editortools.modify_image(img, **opts)
                        gistools.image_to_geotiff(
                            newimg, geodata=geodata, outfile=outfile
                        )
                    else:
                        if counter == 0:
                            _tmpfile = tempfile.NamedTemporaryFile(
                                "w+b", suffix=".png", delete=False
                            )
                            _tmpfile.close()
                            _outfile = _tmpfile.name
                            newimg = Image.open(infile)
                        else:
                            newimg = Image.open(_outfile)
                        newimg = editortools.modify_image(newimg, **opts)
                        newimg.save(_outfile)
                        newimg.close()
                    counter += 1

            if preview:
                break
            else:
                os.replace(_outfile, outfile)

        if preview:
            self._finalize_preview(_outfile)
        else:
            self.annotation_queue = []

        return

    def add_overlay(
        self,
        oleditor: "GoesSceneEditor",
        opacity=0.7,
        dt_tol=20,
        bbox_tol=2,
        overlay_session_name=None,
        force_resize=False,
        force_recrop=False,
        cumulative=False,
        preview=False,
    ):
        """
        Add overlay to image(s)
        :param oleditor: (goesvideo.addons.GoesSceneEditor) scene editor containing desired overlay images
        :param opacity: (float) Value between 0 and 1, with 0 being fully transparent
        :param dt_tol: (float) Maximum allowed time differential between base image and overlay image in minutes
        :param bbox_tol: (float) Maximum allowed lat/lon differential between base image and overlay image in degrees
        :param overlay_session_name: (str) optional folder name for overlay session
        :param force_resize: (bool) if needed, resize overlay images to match base
        :param cumulative: (bool) apply accumulated overlay over time period of base image set
        :return: None
        """

        # Generate session folder in not provided
        # Path: /base_dir/SceneEdits/<source folder>/"<session_name>_Overlay_tmp_x"/
        if not preview:
            if not overlay_session_name:
                olfoldernum = []
                for _tup in os.walk(
                    str(self.base_dir / "SceneEdits" / self.scene_folder)
                ):
                    for subfolder in _tup[1]:
                        if "Overlay_tmp" in subfolder:
                            if self.session_name in subfolder:
                                olfoldernum.append(int(subfolder.split("_")[-1]))

                olnum = max(olfoldernum) + 1
                overlay_session_name = (
                    self.session_name + "_" + "Overlay_tmp_" + str(olnum)
                )
                olfolder = (
                    self.base_dir
                    / "SceneEdits"
                    / self.scene_folder
                    / overlay_session_name
                )

            else:
                olfolder = (
                    self.base_dir
                    / "SceneEdits"
                    / self.scene_folder
                    / overlay_session_name
                )

            olfolder.mkdir(parents=True, exist_ok=True)

            # Copy over metadata.json and timestamps.csv to overlay session
            shutil.copyfile(
                str(self.output_path / "metadata.json"), str(olfolder / "metadata.json")
            )
            shutil.copyfile(
                str(self.output_path / "timestamps.csv"),
                str(olfolder / "timestamps.csv"),
            )

        # Ensure overlay bbox matches base
        lon_ok, lat_ok = gistools.check_bbox(
            oleditor.geodata["image"]["bbox"],
            self.geodata["image"]["bbox"],
            degtol=bbox_tol,
        )
        if not lon_ok or not lat_ok:
            raise exceptions.MismatchedBoundsError(
                "Overlay bounds are not within the specified tolerance of the base "
                "image for the current session. Try calling 'recrop' on the base "
                "and/or overlay image sets or pass 'force_recrop' to function."
            )

        # Ensure projections match
        if self.geodata["image"]["crs"] != oleditor.geodata["image"]["crs"]:
            if not force_recrop:
                raise exceptions.MismatchedCRS(
                    "Overlay CRS does not match the current session. Try calling 'reproject' "
                    "on the overlay image set."
                )
            else:
                _bbox = gistools.transform_bbox(
                    self.geodata["image"]["bbox"],
                    self.geodata["image"]["crs"],
                    oleditor.geodata["image"]["bbox"],
                )

                oleditor.recrop(_bbox, preview=preview)

        # Ensure height and width match
        if (
            self.geodata["image"]["raster_profile"]["height"]
            != oleditor.geodata["image"]["raster_profile"]["height"]
        ):
            if (
                self.geodata["image"]["raster_profile"]["width"]
                != oleditor.geodata["image"]["raster_profile"]["width"]
            ):
                if not force_resize:
                    raise exceptions.MismatchedImageSize(
                        "Overlay dimensions do not match the current session. Try "
                        "calling 'resize' on the overlay image set or pass "
                        "'force_resize' to function."
                    )
                else:
                    oleditor.resize(
                        self.geodata["image"]["raster_profile"]["width"],
                        self.geodata["image"]["raster_profile"]["height"],
                        resample=Resampling.nearest,
                        preview=preview,
                    )

        # Match base timestamps to nearest overlay timestamps
        oltstamps = oleditor.img_timestamps
        skipped_idx_list = []
        matched_idx_list = []

        for i, tstamp in enumerate(self.img_timestamps):
            closest_time = min(oltstamps, key=lambda d: abs(d - tstamp))
            if abs(tstamp - closest_time).total_seconds() / 60.0 > dt_tol:
                skipped_idx_list.append(i)
            else:
                matched_idx_list.append(oltstamps.index(closest_time))

        if skipped_idx_list:
            print(
                f"{Fore.RED}WARNING - Total of {len(skipped_idx_list)} image timestamps were outside the specified "
                f"tolerance of {dt_tol} minutes. Erratic output should be expected. Try increasing the value of the "
                f"'dt_tol' parameter to improve the output."
            )

        # Create overlays
        print(
            f"{Fore.GREEN}Creating overlay images in Scene: {self.scene_folder}, Session: {overlay_session_name}..."
        )
        time.sleep(0.5)
        counter = 0
        for idx in tqdm(matched_idx_list, colour="green"):
            if self.type_is_geotiff:
                if not preview:
                    baseimg = gistools.geotiff_to_image(
                        str(self.output_path / self.img_filenames[idx])
                    )
                else:
                    baseimg = Image.open(self.geodata["preview"]["tif_cache"])

                baseimg = baseimg.convert("RGBA")
            else:
                if not preview:
                    baseimg = Image.open(
                        str(self.output_path / self.img_filenames[idx])
                    )
                else:
                    baseimg = Image.open(self.geodata["preview"]["png_cache"])

                baseimg = baseimg.convert("RGBA")

            if not preview:
                olpath = gistools.adjust_alpha(
                    str(oleditor.output_path / oleditor.img_filenames[idx]), opacity
                )
            else:
                if oleditor.type_is_geotiff:
                    olpath = gistools.adjust_alpha(
                        oleditor.geodata["preview"]["tif_cache"], opacity
                    )
                else:
                    olpath = gistools.adjust_alpha(
                        oleditor.geodata["preview"]["png_cache"], opacity
                    )

            olimg = Image.open(olpath)

            if counter == 0 and cumulative:
                cumulative_img = olimg
            elif cumulative:
                cumulative_img = Image.alpha_composite(cumulative_img, olimg)

            if cumulative:
                compimg = Image.alpha_composite(baseimg, cumulative_img)
            else:
                compimg = Image.alpha_composite(baseimg, olimg)

            if preview:
                compimg.save(self.geodata["preview"]["png_cache"])
                self._finalize_preview(self.geodata["preview"]["png_cache"])
            else:
                compimg.save(str(olfolder / self.img_filenames[idx]))
            counter += 1
            print(f"{Fore.GREEN}Overlays complete! Images saved to {str(olfolder)}.")
            olimg.close()
            try:
                os.unlink(olpath)
            except PermissionError:
                pass
        return

    def to_video(self, codec="mpeg4", fps=20):
        if codec == "mpeg4" or codec == "libx264":
            ext = ".mp4"
        elif codec == "rawvideo" or codec == "png":
            ext = ".avi"
        elif codec == "libvpx":
            ext = ".wmv"
        else:
            ext = ".mp4"

        output_path = self.base_dir / "Videos" / self.scene_folder / self.session_name
        output_path.mkdir(parents=True, exist_ok=True)

        if codec == "rawvideo" or codec == "png":
            imglist = []
            with tempfile.TemporaryDirectory() as tmpdirname:
                print(f"{Fore.GREEN}Converting video frames from RGB to BGR...")
                time.sleep(0.5)
                for f in tqdm(self.img_filenames, colour="green"):
                    img = Image.open(str(self.output_path / f))
                    img = editortools.convert_color(img)
                    img.save(tmpdirname + "/" + f)
                    imglist.append(tmpdirname + "/" + f)

                print(
                    f"{Fore.GREEN}Creating video for Scene: {self.scene_folder}, Session: {self.session_name}..."
                )
                clip = ImageSequenceClip(imglist, fps=fps)
                clip.write_videofile(str(output_path / ("video" + ext)), codec=codec)
        else:
            print(
                f"{Fore.GREEN}Creating video for Scene: {self.scene_folder}, Session: {self.session_name}..."
            )
            time.sleep(0.5)
            imglist = [str(self.output_path / f) for f in self.img_filenames]
            clip = ImageSequenceClip(imglist, fps=fps)
            clip.write_videofile(str(output_path / ("video" + ext)), codec=codec)

        return

    def set_font(self, fontpath=None, fontcolor=None, fontsize=None):
        if fontpath:
            self.font["path"] = fontpath

        if fontcolor:
            self.font["color"] = fontcolor

        if fontsize:
            self.font["size"] = fontsize

    def reset_preview_cache(self):
        self._cleanup_tmp_files()
        self._create_tmp_files()
        self.geodata["preview"] = self.geodata["preview"] | self.geodata["image"]
        return

    def _update_metadata(self):
        _file = str(self.output_path / "metadata.json")
        bbox_out = list(self.geodata["image"]["bbox"])
        crs_out = self.geodata["image"]["crs"]
        res_out = self.geodata["image"]["res_x"]
        transform = self.geodata["image"]["transform"]
        geodata_out = self.geodata["image"]["raster_profile"]
        geodata_out["transform"] = transform
        try:
            geodata_out["crs"] = crs_out.wkt
        except AttributeError:
            geodata_out["crs"] = crs_out

        with open(_file, "r") as f:
            _dict = json.load(f)
            _dict["geodata"] = _dict["geodata"] | geodata_out
            _dict["Resolution"] = res_out
            _dict["Crop_Box"] = bbox_out
        with open(_file, "w") as f:
            json.dump(_dict, f, indent=4)

        return

    def _convert_geo_position(self, opts, geodata):
        objtype = list(opts.keys())[0]
        is_geographic = opts[objtype].pop("is_geographic", None)
        is_dms = opts[objtype].pop("is_dms", None)

        if is_geographic and is_dms:
            if objtype == "text":
                pos = opts[objtype]["position"]
                opts[objtype]["position"] = gistools.xy_from_latlon(
                    gistools.convert_dms_to_dd(pos), geodata=geodata
                )
            elif objtype == "circle":
                pos = opts[objtype]["centerpos"]
                opts[objtype]["centerpos"] = gistools.xy_from_latlon(
                    gistools.convert_dms_to_dd(pos), geodata=geodata
                )
            elif objtype == "arrow":
                pos1 = opts[objtype]["start_position"]
                pos2 = opts[objtype]["end_position"]
                opts[objtype]["start_position"] = gistools.xy_from_latlon(
                    gistools.convert_dms_to_dd(pos1), geodata=geodata
                )
                opts[objtype]["end_position"] = gistools.xy_from_latlon(
                    gistools.convert_dms_to_dd(pos2), geodata=geodata
                )
            elif objtype == "triangle":
                poslist = opts[objtype]["coords"]
                newpos = [
                    gistools.xy_from_latlon(
                        gistools.convert_dms_to_dd(x) for x in poslist
                    )
                ]
                opts[objtype]["coords"] = newpos
        elif is_geographic and not is_dms:
            if objtype == "text":
                pos = opts[objtype]["position"]
                opts[objtype]["position"] = gistools.xy_from_latlon(
                    pos, geodata=geodata
                )
            elif objtype == "circle":
                pos = opts[objtype]["centerpos"]
                opts[objtype]["centerpos"] = gistools.xy_from_latlon(
                    pos, geodata=geodata
                )
            elif objtype == "arrow":
                pos1 = opts[objtype]["start_position"]
                pos2 = opts[objtype]["end_position"]
                opts[objtype]["start_position"] = gistools.xy_from_latlon(
                    pos1, geodata=geodata
                )
                opts[objtype]["end_position"] = gistools.xy_from_latlon(
                    pos2, geodata=geodata
                )
            elif objtype == "triangle":
                poslist = opts[objtype]["coords"]
                newpos = [gistools.xy_from_latlon(x) for x in poslist]
                opts[objtype]["coords"] = newpos

        return opts

    def _cleanup_tmp_files(self):
        try:
            os.unlink(self.geodata["preview"]["png_cache"])
            os.unlink(self.geodata["preview"]["tif_cache"])
        except FileNotFoundError:
            pass
        except PermissionError:
            pass

    def _create_tmp_files(self):
        tmpfile1 = tempfile.NamedTemporaryFile("w+b", suffix=".png", delete=False)
        tmpfile2 = tempfile.NamedTemporaryFile("w+b", suffix=".tif", delete=False)
        tmpfile1.close()
        tmpfile2.close()
        self.geodata["preview"]["png_cache"] = tmpfile1.name
        self.geodata["preview"]["tif_cache"] = tmpfile2.name

    def _get_paths(self, f):
        if self.type_is_geotiff:
            if self.geodata["preview"]["exists"]:
                infile = self.geodata["preview"]["tif_cache"]
                outfile = infile
            else:
                infile = str(self.output_path / f)
                outfile = self.geodata["preview"]["tif_cache"]
        else:
            if self.geodata["preview"]["exists"]:
                infile = self.geodata["preview"]["png_cache"]
                outfile = infile
            else:
                infile = str(self.output_path / f)
                outfile = self.geodata["preview"]["png_cache"]

        return infile, outfile

    def _finalize_preview(self, outfile):
        if self.type_is_geotiff:
            img = gistools.geotiff_to_image(outfile)
        else:
            img = Image.open(outfile)

        img.show()
        img.close()
        self.geodata["preview"]["exists"] = True

        return
