import functools
import math
from typing import Dict, Optional, List, Any, Union, Tuple, Callable

import numpy as np
import opensfm.synthetic_data.synthetic_dataset as sd
import opensfm.synthetic_data.synthetic_generator as sg
import opensfm.synthetic_data.synthetic_metrics as sm
from opensfm import pygeometry, types, pymap, geo


def get_camera(
    type: str, id: str, focal: float, k1: float, k2: float
) -> pygeometry.Camera:
    camera: Optional[pygeometry.Camera] = None
    if type == "perspective":
        camera = pygeometry.Camera.create_perspective(focal, k1, k2)
    elif type == "fisheye":
        camera = pygeometry.Camera.create_fisheye(focal, k1, k2)
    elif type == "spherical":
        camera = pygeometry.Camera.create_spherical()
    else:
        raise RuntimeError(f"Invalid camera type {type}")

    camera.id = id

    camera.height = 1600
    camera.width = 2000
    return camera


def get_scene_generator(type: str, length: float, **kwargs) -> functools.partial:
    generator = None
    if type == "circle":
        generator = functools.partial(sg.ellipse_generator, length, length)
    if type == "ellipse":
        ellipse_ratio = 2
        generator = functools.partial(
            sg.ellipse_generator, length, length / ellipse_ratio
        )
    if type == "line":
        center_x = kwargs.get("center_x", 0)
        center_y = kwargs.get("center_y", 0)
        transpose = kwargs.get("transpose", True)
        generator = functools.partial(
            sg.line_generator, length, center_x, center_y, transpose
        )
    assert generator
    return generator


def camera_pose(
    position: np.ndarray, lookat: np.ndarray, up: np.ndarray
) -> pygeometry.Pose:
    """
    Pose from position and look at direction

    >>> position = [1.0, 2.0, 3.0]
    >>> lookat = [0., 10.0, 2.0]
    >>> up = [0.0, 0.0, 1.0]
    >>> pose = camera_pose(position, lookat, up)
    >>> np.allclose(pose.get_origin(), position)
    True
    """

    def normalized(x: np.ndarray) -> np.ndarray:
        return x / np.linalg.norm(x)

    ez = normalized(np.array(lookat) - np.array(position))
    ex = normalized(np.cross(ez, up))
    ey = normalized(np.cross(ez, ex))
    pose = pygeometry.Pose()
    pose.set_rotation_matrix(np.array([ex, ey, ez]))
    pose.set_origin(position)
    return pose


class SyntheticScene(object):
    def get_reconstruction(self) -> types.Reconstruction:
        raise NotImplementedError()


class SyntheticCubeScene(SyntheticScene):
    """Scene consisting of cameras looking at point in a cube."""

    def __init__(self, num_cameras: int, num_points: int, noise: float) -> None:
        self.reconstruction = types.Reconstruction()
        self.cameras = {}
        for i in range(num_cameras):
            camera = camera = pygeometry.Camera.create_perspective(0.9, -0.1, 0.01)
            camera.id = "camera%04d" % i
            camera.height = 600
            camera.width = 800
            self.cameras[camera.id] = camera

        self.reconstruction.cameras = self.cameras

        r = 2.0
        for i in range(num_cameras):
            phi = np.random.rand() * math.pi
            theta = np.random.rand() * 2.0 * math.pi
            x = r * np.sin(theta) * np.cos(phi)
            y = r * np.sin(theta) * np.sin(phi)
            z = r * np.cos(theta)
            position = np.array([x, y, z])

            alpha = np.random.rand()
            lookat = np.array([0.0, 0, 0])
            up = np.array([alpha * 0.2, alpha * 0.2, 1.0])
            shot_id = "shot%04d" % i
            camera_id = "camera%04d" % i
            pose = camera_pose(position, lookat, up)
            self.reconstruction.create_shot(
                shot_id, camera_id, pose, rig_camera_id=None, rig_instance_id=None
            )

        points = np.random.rand(num_points, 3) - [0.5, 0.5, 0.5]
        for i, p in enumerate(points):
            point_id = "point" + str(i)
            pt = self.reconstruction.create_point(point_id, p)
            pt.color = np.array([100, 100, 20])

    def get_reconstruction(self) -> types.Reconstruction:
        reconstruction = types.Reconstruction()
        # Copy our original reconstruction
        # since we do not want to modify the reference
        reconstruction.cameras = self.cameras
        for shot in self.reconstruction.shots.values():
            reconstruction.create_shot(
                shot.id,
                shot.camera.id,
                shot.pose,
                rig_camera_id=None,
                rig_instance_id=None,
            )
        for point in self.reconstruction.points.values():
            pt = reconstruction.create_point(point.id, point.coordinates)
            pt.color = point.color
        return reconstruction


class SyntheticStreetScene(SyntheticScene):
    """Scene consisting in a virtual street extruded along some
    parametric shape (line, ellipse), with camera placed along
    the shape.
    """

    generator: Optional[Callable]
    wall_points: Optional[np.ndarray]
    floor_points: Optional[np.ndarray]
    shot_ids: List[List[str]]
    cameras: List[List[pygeometry.Camera]]
    instances_positions: List[np.ndarray]
    instances_rotations: List[np.ndarray]
    rig_instances: List[List[List[Tuple[str, str]]]]
    rig_cameras: List[List[pymap.RigCamera]]
    width: float

    def __init__(
        self,
        generator: Optional[Callable],
        reference: Optional[geo.TopocentricConverter] = None,
    ) -> None:
        self.generator = generator
        self.reference = reference
        self.wall_points = None
        self.floor_points = None
        self.shot_ids = []
        self.cameras = []
        self.instances_positions = []
        self.instances_rotations = []
        self.rig_instances = []
        self.rig_cameras = []
        self.width = 0.0

    def combine(self, other_scene: "SyntheticStreetScene") -> "SyntheticStreetScene":
        combined_scene = SyntheticStreetScene(None)
        combined_scene.wall_points = np.concatenate(
            (self.wall_points, other_scene.wall_points)
        )
        combined_scene.floor_points = np.concatenate(
            (self.floor_points, other_scene.floor_points)
        )
        combined_scene.cameras = self.cameras + other_scene.cameras
        combined_scene.instances_positions = (
            self.instances_positions + other_scene.instances_positions
        )
        combined_scene.instances_rotations = (
            self.instances_rotations + other_scene.instances_rotations
        )
        combined_scene.rig_instances = self.rig_instances + other_scene.rig_instances
        combined_scene.rig_cameras = self.rig_cameras + other_scene.rig_cameras
        combined_scene.shot_ids = self.shot_ids + other_scene.shot_ids

        shift = 0
        for subshots in combined_scene.shot_ids:
            for i in range(len(subshots)):
                subshots[i] = f"Shot {i+shift:04d}"
            shift += len(subshots)
        return combined_scene

    def add_street(
        self, points_count: int, height: float, width: float
    ) -> "SyntheticStreetScene":
        generator = self.generator
        assert generator
        self.wall_points, self.floor_points = sg.generate_street(
            sg.samples_generator_random_count(int(points_count // 3)),
            generator,
            height,
            width,
        )
        self.width = width
        return self

    def perturb_walls(self, walls_pertubation: List[float]) -> "SyntheticStreetScene":
        wall_points = self.wall_points
        assert wall_points is not None
        sg.perturb_points(wall_points, walls_pertubation)
        return self

    def perturb_floor(self, floor_pertubation: List[float]) -> "SyntheticStreetScene":
        floor_points = self.floor_points
        assert floor_points is not None
        sg.perturb_points(floor_points, floor_pertubation)
        return self

    def set_terrain_hill(
        self, height: float, radius: float, repeated: bool
    ) -> "SyntheticStreetScene":
        if not repeated:
            self._set_terrain_hill_single(height, radius)
        else:
            self._set_terrain_hill_repeated(height, radius)
        return self

    def _set_terrain_hill_single(self, height: float, radius: float) -> None:
        wall_points, floor_points = self.wall_points, self.floor_points
        assert wall_points is not None and floor_points is not None
        wall_points[:, 2] += height * np.exp(
            -0.5 * np.linalg.norm(wall_points[:, :2], axis=1) ** 2 / radius ** 2
        )
        floor_points[:, 2] += height * np.exp(
            -0.5 * np.linalg.norm(floor_points[:, :2], axis=1) ** 2 / radius ** 2
        )

        for positions in self.instances_positions:
            for position in positions:
                position[2] += height * np.exp(
                    -0.5
                    * np.linalg.norm(
                        (position[0] ** 2 + position[1] ** 2) / radius ** 2
                    )
                )

    def _set_terrain_hill_repeated(self, height: float, radius: float) -> None:
        wall_points, floor_points = self.wall_points, self.floor_points
        assert wall_points is not None and floor_points is not None
        wall_points[:, 2] += height * np.sin(
            np.linalg.norm(wall_points[:, :2], axis=1) / radius
        )
        floor_points[:, 2] += height * np.sin(
            np.linalg.norm(floor_points[:, :2], axis=1) / radius
        )

        for positions in self.instances_positions:
            for position in positions:
                position[2] += height * np.sin(
                    math.sqrt(position[0] ** 2 + position[1] ** 2) / radius
                )

    def add_camera_sequence(
        self,
        camera: pygeometry.Camera,
        length: float,
        height: float,
        interval: float,
        position_noise: List[float],
        rotation_noise: float,
        positions_shift: Optional[List[float]] = None,
        end: Optional[float] = None,
    ) -> "SyntheticStreetScene":
        default_noise_interval = 0.25 * interval
        actual_end = length if end is None else end
        generator = self.generator
        assert generator
        positions, rotations = sg.generate_cameras(
            sg.samples_generator_interval(
                length, actual_end, interval, default_noise_interval
            ),
            generator,
            height,
        )
        sg.perturb_points(positions, position_noise)
        sg.perturb_rotations(rotations, rotation_noise)
        if positions_shift:
            positions += np.array(positions_shift)

        shift = 0 if len(self.shot_ids) == 0 else sum(len(s) for s in self.shot_ids)
        new_shot_ids = [f"Shot {shift+i:04d}" for i in range(len(positions))]
        self.shot_ids.append(new_shot_ids)
        self.cameras.append([camera])

        rig_camera = pymap.RigCamera(pygeometry.Pose(), camera.id)
        self.rig_cameras.append([rig_camera])

        rig_instances = []
        for shot_id in new_shot_ids:
            rig_instances.append([(shot_id, camera.id)])
        self.rig_instances.append(rig_instances)
        self.instances_positions.append(positions)
        self.instances_rotations.append(rotations)

        return self

    def add_rig_camera_sequence(
        self,
        cameras: List[pygeometry.Camera],
        relative_positions: List[List[float]],
        relative_rotations: List[List[float]],
        length: float,
        height: float,
        interval: float,
        position_noise: List[float],
        rotation_noise: float,
        end: Optional[float] = None,
    ) -> "SyntheticStreetScene":
        default_noise_interval = 0.25 * interval
        actual_end = length if end is None else end
        generator = self.generator
        assert generator
        instances_positions, instances_rotations = sg.generate_cameras(
            sg.samples_generator_interval(
                length, actual_end, interval, default_noise_interval
            ),
            generator,
            height,
        )
        sg.perturb_points(instances_positions, position_noise)
        sg.perturb_rotations(instances_rotations, rotation_noise)

        shift = sum(len(s) for s in self.shot_ids)
        shots_ids_per_camera = []
        for j, (rig_camera_p, rig_camera_r) in enumerate(
            zip(relative_positions, relative_rotations)
        ):
            # pyre-fixme[6]: For 1st argument expected `ndarray` but got `List[float]`.
            pose_rig_camera = pygeometry.Pose(rig_camera_r)
            # pyre-fixme[6]: For 1st argument expected `ndarray` but got `List[float]`.
            pose_rig_camera.set_origin(rig_camera_p)

            rotations = []
            positions = []
            for instance_p, instance_r in zip(instances_positions, instances_rotations):
                pose_instance = pygeometry.Pose(instance_r)
                pose_instance.set_origin(instance_p)
                composed = pose_rig_camera.compose(pose_instance)
                rotations.append(composed.rotation)
                positions.append(composed.get_origin())

            camera_shot_ids = []
            for i in range(len(positions)):
                shot_index = i * len(relative_positions) + j
                camera_shot_ids.append(f"Shot {shift+shot_index:04d}")
            shots_ids_per_camera.append(camera_shot_ids)
        self.cameras.append(cameras)
        self.shot_ids += shots_ids_per_camera

        rig_camera_ids = []
        rig_cameras = []
        rig_camera_id_shift = sum(len(s) for s in self.rig_cameras)
        for i, (rig_camera_p, rig_camera_r) in enumerate(
            zip(relative_positions, relative_rotations)
        ):
            # pyre-fixme[6]: For 1st argument expected `ndarray` but got `List[float]`.
            pose_rig_camera = pygeometry.Pose(rig_camera_r)
            # pyre-fixme[6]: For 1st argument expected `ndarray` but got `List[float]`.
            pose_rig_camera.set_origin(rig_camera_p)
            rig_camera_id = f"RigCamera {rig_camera_id_shift + i}"
            rig_camera = pymap.RigCamera(pose_rig_camera, rig_camera_id)
            rig_camera_ids.append(rig_camera_id)
            rig_cameras.append(rig_camera)
        self.rig_cameras.append(rig_cameras)

        rig_instances: List[List[Tuple[str, str]]] = []
        for i in range(len(instances_positions)):
            instance = []
            for j in range(len(shots_ids_per_camera)):
                instance.append((shots_ids_per_camera[j][i], rig_camera_ids[j]))
            rig_instances.append(instance)
        self.rig_instances.append(rig_instances)
        self.instances_positions.append(instances_positions)
        self.instances_rotations.append(instances_rotations)

        return self

    def get_reconstruction(self) -> types.Reconstruction:
        floor_color = [120, 90, 10]
        wall_color = [10, 90, 130]

        return sg.create_reconstruction(
            points=np.asarray([self.floor_points, self.wall_points], dtype=object),
            colors=np.asarray([floor_color, wall_color]),
            cameras=self.cameras,
            shot_ids=self.shot_ids,
            rig_shots=self.rig_instances,
            rig_positions=self.instances_positions,
            rig_rotations=self.instances_rotations,
            rig_cameras=self.rig_cameras,
            reference=self.reference,
        )


class SyntheticInputData:
    """Class that generate all data necessary to run SfM processes end-to-end
    based on some input Reconstruction (synthetic or not), by re-creating inputs
    (GPS, projections) based on some required amount of noise.
    """

    reconstruction: types.Reconstruction
    exifs: Dict[str, Any]
    features: sd.SyntheticFeatures
    tracks_manager: pymap.TracksManager
    gcps: Dict[str, pymap.GroundControlPoint]

    def __init__(
        self,
        reconstruction: types.Reconstruction,
        reference: geo.TopocentricConverter,
        projection_max_depth: float,
        projection_noise: float,
        gps_noise: Union[Dict[str, float], float],
        imu_noise: float,
        gcp_noise: Tuple[float, float],
        causal_gps_noise: bool,
        gcps_count: Optional[int] = None,
        gcps_shift: Optional[np.ndarray] = None,
        on_disk_features_filename: Optional[str] = None,
        generate_projections: bool = True,
    ) -> None:
        self.reconstruction = reconstruction
        self.exifs = sg.generate_exifs(
            reconstruction,
            reference,
            gps_noise,
            imu_noise,
            causal_gps_noise=causal_gps_noise,
        )

        if generate_projections:
            (self.features, self.tracks_manager, self.gcps) = sg.generate_track_data(
                reconstruction,
                projection_max_depth,
                projection_noise,
                gcp_noise,
                gcps_count,
                gcps_shift,
                on_disk_features_filename,
            )
        else:
            self.features = sd.SyntheticFeatures(None)
            self.tracks_manager = pymap.TracksManager()


def compare(
    reference: types.Reconstruction,
    gcps: Dict[str, pymap.GroundControlPoint],
    reconstruction: types.Reconstruction,
) -> Dict[str, float]:
    """Compare a reconstruction with reference groundtruth."""
    completeness = sm.completeness_errors(reference, reconstruction)

    absolute_position = sm.position_errors(reference, reconstruction)
    absolute_rotation = sm.rotation_errors(reference, reconstruction)
    absolute_points = sm.points_errors(reference, reconstruction)
    absolute_gps = sm.gps_errors(reconstruction)
    absolute_gcp = sm.gcp_errors(reconstruction, gcps)

    aligned = sm.aligned_to_reference(reference, reconstruction)
    aligned_position = sm.position_errors(reference, aligned)
    aligned_rotation = sm.rotation_errors(reference, aligned)
    aligned_points = sm.points_errors(reference, aligned)
    aligned_gps = sm.gps_errors(aligned)

    return {
        "ratio_cameras": completeness[0],
        "ratio_points": completeness[1],
        "absolute_position_rmse": sm.rmse(absolute_position),
        "absolute_position_mad": sm.mad(absolute_position),
        "absolute_rotation_rmse": sm.rmse(absolute_rotation),
        "absolute_rotation_median": np.median(absolute_rotation),
        "absolute_points_rmse": sm.rmse(absolute_points),
        "absolute_points_mad": sm.mad(absolute_points),
        "absolute_gps_rmse": sm.rmse(absolute_gps),
        "absolute_gps_mad": sm.mad(absolute_gps),
        "absolute_gcp_rmse_horizontal": sm.rmse(absolute_gcp[:, :2])
        if len(absolute_gcp.shape) > 1
        else 0.0,
        "absolute_gcp_rmse_vertical": sm.rmse(absolute_gcp[:, 2])
        if len(absolute_gcp.shape) > 1
        else 0.0,
        "aligned_position_rmse": sm.rmse(aligned_position),
        "aligned_position_mad": sm.mad(aligned_position),
        "aligned_rotation_rmse": sm.rmse(aligned_rotation),
        "aligned_rotation_median": np.median(aligned_rotation),
        "aligned_gps_rmse": sm.rmse(aligned_gps),
        "aligned_gps_mad": sm.mad(aligned_gps),
        "aligned_points_rmse": sm.rmse(aligned_points),
        "aligned_points_mad": sm.mad(aligned_points),
    }
