import sys, os, math, re
import numpy as np
from PIL import Image
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QPushButton, QLabel, QListWidget, QMessageBox, QSpinBox, QCheckBox,
    QGridLayout, QSizePolicy
)
from PyQt5.QtCore import Qt, pyqtSignal, QMimeData
from PyQt5.QtGui import QPixmap, QDrag, QImage
from PIL import ImageQt

# =============================================================================
# Helper: Bilinear Sampling (with clipping)
# =============================================================================

def bilinear_sample(image, xs, ys):
    """
    Performs bilinear interpolation on image at floating‑point coordinates.
    image: NumPy array of shape (H, W, C)
    xs, ys: arrays of shape (H, W) with float coordinates.
    Returns an array of shape (H, W, C).
    """
    H, W, C = image.shape
    xs = np.clip(np.asarray(xs), 0, W - 1)
    ys = np.clip(np.asarray(ys), 0, H - 1)
    x0 = np.floor(xs).astype(np.int32)
    y0 = np.floor(ys).astype(np.int32)
    x1 = np.clip(x0 + 1, 0, W - 1)
    y1 = np.clip(y0 + 1, 0, H - 1)
    wx = xs - x0
    wy = ys - y0
    Ia = image[y0, x0]
    Ib = image[y0, x1]
    Ic = image[y1, x0]
    Id = image[y1, x1]
    wa = (1 - wx) * (1 - wy)
    wb = wx * (1 - wy)
    wc = (1 - wx) * wy
    wd = wx * wy
    return Ia * wa[..., None] + Ib * wb[..., None] + Ic * wc[..., None] + Id * wd[..., None]

# =============================================================================
# Equirectangular Remapping for Horizon Movement
# =============================================================================

def remap_horizon(eq_img, move):
    """
    Remaps the vertical coordinate of an equirectangular image so that the original
    horizon (equator at y = H/2) is shifted to:
         y_target = (H/2) * (1 - move/100).
    For each output y (0 <= y < H), the source y is:
         if y < y_target:
             y_src = (y / y_target) * (H/2)
         else:
             y_src = H/2 + ((y - y_target)/(H - y_target)) * (H/2)
    With move=0 the mapping is the identity.
    """
    eq_array = np.array(eq_img)
    H, W, C = eq_array.shape
    y_target = (H/2) * (1 - move/100.0)
    y_out = np.arange(H, dtype=np.float32)
    y_src = np.empty_like(y_out)
    idx_top = y_out < y_target
    y_src[idx_top] = (y_out[idx_top] / y_target) * (H/2)
    idx_bot = ~idx_top
    y_src[idx_bot] = (H/2) + ((y_out[idx_bot] - y_target) / (H - y_target)) * (H/2)
    X, Y = np.meshgrid(np.arange(W), y_src)
    remapped = bilinear_sample(eq_array, X, Y)
    return Image.fromarray(remapped.astype(np.uint8))

# =============================================================================
# Equirectangular <-> Cubemap Conversion (Standard Mapping)
# =============================================================================

def sample_cubemap(new_dx, new_dy, new_dz, input_images):
    """
    Given direction arrays new_dx, new_dy, new_dz (each of shape (H, W)),
    sample the appropriate cubemap face from input_images.
    We use the following mapping for Kingpin/Quake II (with move=0):
      up:   (u,  1, -v)  [But here we choose up as (u, 1, -v) so that the top of the image is at v = -1]
      dn:   (u, -1, -v)  [Down: (u, -1, -v), i.e. no vertical flip]
      ft:   (u,  v, -1)  [Front faces -Z]
      bk:   (u,  v,  1)  [Back faces +Z]
      rt:   (1,  v,  u)  [Right: +X]
      lf:   (-1, v, -u)  [Left: -X]
    (These formulas were derived by testing and adjusting to eliminate unwanted flips.)
    """
    H, W = new_dx.shape
    output = np.zeros((H, W, 3), dtype=np.float32)
    absX = np.abs(new_dx)
    absY = np.abs(new_dy)
    absZ = np.abs(new_dz)
    # Right face (+X)
    mask = (absX >= absY) & (absX >= absZ) & (new_dx > 0)
    if np.any(mask):
        maxAxis = absX[mask]
        u_tex = 0.5 * (-new_dz[mask] / maxAxis + 1)
        v_tex = 0.5 * (new_dy[mask] / maxAxis + 1)  # note: use new_dy (not -new_dy) for this mapping
        img = input_images["rt"]
        H_img, W_img, _ = img.shape
        xs = u_tex * (W_img - 1)
        ys = v_tex * (H_img - 1)
        output[mask] = bilinear_sample(img, xs, ys)
    # Left face (-X)
    mask = (absX >= absY) & (absX >= absZ) & (new_dx <= 0)
    if np.any(mask):
        maxAxis = absX[mask]
        u_tex = 0.5 * (new_dz[mask] / maxAxis + 1)
        v_tex = 0.5 * (new_dy[mask] / maxAxis + 1)
        img = input_images["lf"]
        H_img, W_img, _ = img.shape
        xs = u_tex * (W_img - 1)
        ys = v_tex * (H_img - 1)
        output[mask] = bilinear_sample(img, xs, ys)
    # Up face (+Y)
    mask = (absY >= absX) & (absY >= absZ) & (new_dy > 0)
    if np.any(mask):
        maxAxis = absY[mask]
        u_tex = 0.5 * (new_dx[mask] / maxAxis + 1)
        v_tex = 0.5 * (-new_dz[mask] / maxAxis + 1)
        img = input_images["up"]
        H_img, W_img, _ = img.shape
        xs = u_tex * (W_img - 1)
        ys = v_tex * (H_img - 1)
        output[mask] = bilinear_sample(img, xs, ys)
    # Down face (-Y)
    mask = (absY >= absX) & (absY >= absZ) & (new_dy <= 0)
    if np.any(mask):
        maxAxis = absY[mask]
        u_tex = 0.5 * (new_dx[mask] / maxAxis + 1)
        v_tex = 0.5 * (-new_dz[mask] / maxAxis + 1)
        img = input_images["dn"]
        H_img, W_img, _ = img.shape
        xs = u_tex * (W_img - 1)
        ys = v_tex * (H_img - 1)
        output[mask] = bilinear_sample(img, xs, ys)
    # Front face (ft) – front = -Z
    mask = (absZ >= absX) & (absZ >= absY) & (new_dz < 0)
    if np.any(mask):
        maxAxis = absZ[mask]
        u_tex = 0.5 * (new_dx[mask] / maxAxis + 1)
        v_tex = 0.5 * (new_dy[mask] / maxAxis + 1)
        img = input_images["ft"]
        H_img, W_img, _ = img.shape
        xs = u_tex * (W_img - 1)
        ys = v_tex * (H_img - 1)
        output[mask] = bilinear_sample(img, xs, ys)
    # Back face (bk) – back = +Z
    mask = (absZ >= absX) & (absZ >= absY) & (new_dz >= 0)
    if np.any(mask):
        maxAxis = absZ[mask]
        u_tex = 0.5 * (-new_dx[mask] / maxAxis + 1)
        v_tex = 0.5 * (new_dy[mask] / maxAxis + 1)
        img = input_images["bk"]
        H_img, W_img, _ = img.shape
        xs = u_tex * (W_img - 1)
        ys = v_tex * (H_img - 1)
        output[mask] = bilinear_sample(img, xs, ys)
    return output

def cubemap_to_equirectangular(input_images, eq_width, eq_height):
    """
    Converts a cubemap (dict mapping face->NumPy array) into an equirectangular panorama.
    """
    xs, ys = np.meshgrid(np.arange(eq_width), np.arange(eq_height))
    theta = (xs/eq_width)*2*math.pi - math.pi
    phi = (math.pi/2) - (ys/eq_height)*math.pi
    d_x = np.sin(theta)*np.cos(phi)
    d_y = np.sin(phi)
    d_z = np.cos(theta)*np.cos(phi)
    eq_array = sample_cubemap(d_x, d_y, d_z, input_images)
    eq_array = np.clip(eq_array, 0, 255).astype(np.uint8)
    return Image.fromarray(eq_array)

def equirectangular_to_cubemap(eq_img, face_resolution):
    """
    Converts an equirectangular panorama (PIL Image) into cubemap faces of size (face_resolution x face_resolution)
    using standard spherical projection with the following conventions:
      up:   (u,  1, -v)
      dn:   (u, -1, -v)
      ft:   (u,  v, -1)
      bk:   (u,  v,  1)
      rt:   (1,  v,  u)
      lf:   (-1, v, -u)
    These formulas have been adjusted so that with move=0 no unwanted flipping occurs.
    """
    eq_array = np.array(eq_img)
    H, W, _ = eq_array.shape
    output = {}
    def face_from_eq(face):
        xs, ys = np.meshgrid(np.arange(face_resolution), np.arange(face_resolution))
        u = 2*(xs+0.5)/face_resolution - 1
        v = 2*(ys+0.5)/face_resolution - 1
        if face == "up":
            d_x, d_y, d_z = u, 1*np.ones_like(u), -v
        elif face == "dn":
            d_x, d_y, d_z = u, -1*np.ones_like(u), -v
        elif face == "ft":
            d_x, d_y, d_z = u, v, -1*np.ones_like(u)
        elif face == "bk":
            d_x, d_y, d_z = -u, v, 1*np.ones_like(u)
        elif face == "rt":
            d_x, d_y, d_z = 1*np.ones_like(u), v, -u
        elif face == "lf":
            d_x, d_y, d_z = -1*np.ones_like(u), v, u
        else:
            raise ValueError("Unknown face: " + face)
        norm = np.sqrt(d_x**2 + d_y**2 + d_z**2)
        d_x /= norm; d_y /= norm; d_z /= norm
        theta = np.arctan2(d_x, d_z)
        phi = np.arcsin(d_y)
        eq_x = (theta+math.pi)/(2*math.pi)*W
        eq_y = (math.pi/2 - phi)/math.pi*H
        face_arr = bilinear_sample(eq_array, eq_x, eq_y)
        face_arr = np.clip(face_arr, 0, 255).astype(np.uint8)
        return Image.fromarray(face_arr)
    for face in ["up", "rt", "ft", "lf", "bk", "dn"]:
        output[face] = face_from_eq(face)
    return output

def reproject_cubemap(face_paths, resolution, move):
    """
    Loads 6 cubemap face images from face_paths (dict with keys: up, rt, ft, lf, bk, dn),
    crops them to square, resizes them to (resolution x resolution),
    converts them into an equirectangular panorama of size (2*resolution x 2*resolution),
    applies vertical remapping via remap_horizon (shifting the horizon by move %),
    then reprojects back into cubemap faces.
    Returns a dict mapping face names to PIL Images.
    """
    cubemap = {}
    for face, path in face_paths.items():
        img = Image.open(path).convert("RGB")
        w, h = img.size
        dim = min(w, h)
        left = (w-dim)//2
        top = (h-dim)//2
        img = img.crop((left, top, left+dim, top+dim))
        if img.size != (resolution, resolution):
            img = img.resize((resolution, resolution), Image.LANCZOS)
        cubemap[face] = img
    eq_width = 2 * resolution
    eq_height = 2 * resolution
    input_images = {face: np.array(img) for face, img in cubemap.items()}
    eq_img = cubemap_to_equirectangular(input_images, eq_width, eq_height)
    remapped_eq = remap_horizon(eq_img, move)
    output_cubemap = equirectangular_to_cubemap(remapped_eq, resolution)
    return output_cubemap

# =============================================================================
# GUI Code
# =============================================================================

class ThumbnailLabel(QLabel):
    swapRequested = pyqtSignal(str, str)
    def __init__(self, face, file_path=None, parent=None):
        super().__init__(parent)
        self.face = face
        self.file_path = file_path
        self.setAcceptDrops(True)
        self.setScaledContents(True)
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
        self.setMinimumSize(1,1)
        self.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
        if file_path:
            self.update_image(file_path)
        else:
            self.setText(face.upper())
    def update_image(self, file_path):
        self.file_path = file_path
        if file_path.lower().endswith(".tga"):
            try:
                pil_image = Image.open(file_path).convert("RGB")
                data = pil_image.tobytes("raw", "RGB")
                qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format_RGB888)
                pixmap = QPixmap.fromImage(qimage)
            except Exception:
                pixmap = QPixmap()
        else:
            pixmap = QPixmap(file_path)
        if pixmap.isNull():
            self.setText(self.face.upper())
        else:
            self.setPixmap(pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
        self.setToolTip(os.path.basename(file_path))
    def update_preview(self, pil_image):
        try:
            qimage = ImageQt.ImageQt(pil_image)
            pixmap = QPixmap.fromImage(qimage)
        except Exception:
            pixmap = QPixmap()
        if pixmap.isNull():
            self.setText(self.face.upper())
        else:
            self.setPixmap(pixmap.scaled(self.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))
        self.setToolTip("Preview: " + self.face.upper())
    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.drag_start_position = event.pos()
        super().mousePressEvent(event)
    def mouseMoveEvent(self, event):
        if not (event.buttons() & Qt.LeftButton):
            return
        if (event.pos() - self.drag_start_position).manhattanLength() < QApplication.startDragDistance():
            return
        drag = QDrag(self)
        mime_data = QMimeData()
        mime_data.setText(self.face)
        drag.setMimeData(mime_data)
        if self.pixmap():
            drag.setPixmap(self.pixmap().scaled(64, 64, Qt.KeepAspectRatio))
        drag.exec_(Qt.MoveAction)
        super().mouseMoveEvent(event)
    def dragEnterEvent(self, event):
        if event.mimeData().hasText():
            event.acceptProposedAction()
        else:
            event.ignore()
    def dropEvent(self, event):
        source_face = event.mimeData().text()
        if source_face and source_face != self.face:
            self.swapRequested.emit(source_face, self.face)
        event.acceptProposedAction()

class ThumbnailWidget(QWidget):
    swapRequested = pyqtSignal(str, str)
    def __init__(self, parent=None):
        super().__init__(parent)
        # Create a grid of 3 rows and 4 columns.
        self.layout = QGridLayout(self)
        self.layout.setHorizontalSpacing(0)
        self.layout.setVerticalSpacing(0)
        self.layout.setContentsMargins(0,0,0,0)
        # Place UP in A1 (row 0, col 0); cells (0,1)-(0,3) remain empty.
        self.up_thumb = ThumbnailLabel("up")
        self.layout.addWidget(self.up_thumb, 0, 0, 1, 1)
        # Place side faces in row 1:
        self.rt_thumb = ThumbnailLabel("rt")
        self.ft_thumb = ThumbnailLabel("ft")
        self.lf_thumb = ThumbnailLabel("lf")
        self.bk_thumb = ThumbnailLabel("bk")
        self.layout.addWidget(self.rt_thumb, 1, 0)
        self.layout.addWidget(self.ft_thumb, 1, 1)
        self.layout.addWidget(self.lf_thumb, 1, 2)
        self.layout.addWidget(self.bk_thumb, 1, 3)
        # Place DN in A3 (row 2, col 0); cells (2,1)-(2,3) remain empty.
        self.dn_thumb = ThumbnailLabel("dn")
        self.layout.addWidget(self.dn_thumb, 2, 0, 1, 1)
        self.thumbnails = {
            "up": self.up_thumb,
            "rt": self.rt_thumb,
            "ft": self.ft_thumb,
            "lf": self.lf_thumb,
            "bk": self.bk_thumb,
            "dn": self.dn_thumb,
        }
        for widget in self.thumbnails.values():
            widget.swapRequested.connect(self.handle_swap)
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
    def handle_swap(self, source_face, target_face):
        self.swapRequested.emit(source_face, target_face)
    def update_thumbnails(self, face_mapping):
        for face, path in face_mapping.items():
            widget = self.thumbnails.get(face)
            if widget:
                if path:
                    widget.update_image(path)
                else:
                    widget.setText(face.upper())
                    widget.setPixmap(QPixmap())
    def update_preview_images(self, preview_mapping):
        for face, pil_img in preview_mapping.items():
            widget = self.thumbnails.get(face)
            if widget:
                widget.update_preview(pil_img)
    def resizeEvent(self, event):
        total_width = self.width()
        # For row 1 (side faces), divide total width by 4.
        cell_size = total_width // 4
        for face in ["rt", "ft", "lf", "bk"]:
            self.thumbnails[face].setFixedSize(cell_size, cell_size)
        # For UP and DN, use the same cell size (so they do not stretch).
        self.up_thumb.setFixedSize(cell_size, cell_size)
        self.dn_thumb.setFixedSize(cell_size, cell_size)
        super().resizeEvent(event)

class DropListWidget(QListWidget):
    filesChanged = pyqtSignal()
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setAcceptDrops(True)
    def dragEnterEvent(self, event):
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
    def dragMoveEvent(self, event):
        event.acceptProposedAction()
    def dropEvent(self, event):
        for url in event.mimeData().urls():
            path = url.toLocalFile()
            if os.path.isfile(path):
                self.addItem(path)
        self.filesChanged.emit()

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Skybox Horizon Mover")
        self.resize(600,600)
        central = QWidget()
        self.setCentralWidget(central)
        self.face_mapping = {}  # keys: up, rt, ft, lf, bk, dn
        layout = QVBoxLayout(central)
        info_label = QLabel("Drag and drop 6 cubemap face images (named with suffixes: up, rt, ft, lf, bk, dn):")
        layout.addWidget(info_label)
        self.dropList = DropListWidget()
        layout.addWidget(self.dropList)
        self.dropList.filesChanged.connect(self.update_face_mapping_from_list)
        thumb_label = QLabel("Thumbnail Preview:\nA1: UP; Row2: A2=RT, B2=FT, C2=LF, D2=BK; A3: DN")
        layout.addWidget(thumb_label)
        self.thumbnail_widget = ThumbnailWidget()
        self.thumbnail_widget.swapRequested.connect(self.handle_thumbnail_swap)
        layout.addWidget(self.thumbnail_widget)
        controls_layout = QHBoxLayout()
        res_layout = QHBoxLayout()
        res_label = QLabel("Output Face Resolution (square):")
        self.res_spin = QSpinBox()
        self.res_spin.setRange(16,8192)
        self.res_spin.setValue(512)
        res_layout.addWidget(res_label)
        res_layout.addWidget(self.res_spin)
        controls_layout.addLayout(res_layout)
        off_layout = QHBoxLayout()
        off_label = QLabel("Move Horizon %:")
        self.off_spin = QSpinBox()
        self.off_spin.setRange(-100,100)
        self.off_spin.setValue(0)
        off_layout.addWidget(off_label)
        off_layout.addWidget(self.off_spin)
        controls_layout.addLayout(off_layout)
        layout.addLayout(controls_layout)
        self.tga_checkbox = QCheckBox("Convert output images to 24-bit compressed TGA")
        layout.addWidget(self.tga_checkbox)
        buttons_layout = QHBoxLayout()
        self.preview_button = QPushButton("Preview")
        self.preview_button.clicked.connect(self.preview_skybox)
        buttons_layout.addWidget(self.preview_button)
        self.process_button = QPushButton("Process Cubemap")
        self.process_button.clicked.connect(self.process_skybox)
        buttons_layout.addWidget(self.process_button)
        layout.addLayout(buttons_layout)
    def update_face_mapping_from_list(self):
        mapping = {"up": None, "rt": None, "ft": None, "lf": None, "bk": None, "dn": None}
        count = self.dropList.count()
        for i in range(count):
            path = self.dropList.item(i).text()
            basename = os.path.basename(path).lower()
            for face in mapping.keys():
                if (basename.endswith(face + ".tga") or basename.endswith(face + ".png") or
                    basename.endswith(face + ".jpg") or basename.endswith(face + ".jpeg") or
                    basename.endswith(face + ".webp")):
                    mapping[face] = path
        self.face_mapping = mapping
        self.thumbnail_widget.update_thumbnails(self.face_mapping)
    def handle_thumbnail_swap(self, source_face, target_face):
        self.face_mapping[source_face], self.face_mapping[target_face] = self.face_mapping.get(target_face), self.face_mapping.get(source_face)
        self.thumbnail_widget.update_thumbnails(self.face_mapping)
    def preview_skybox(self):
        if not self.face_mapping or any(self.face_mapping[face] is None for face in ["up","rt","ft","lf","bk","dn"]):
            QMessageBox.warning(self, "Error", "Missing one or more cubemap faces.")
            return
        res = self.res_spin.value()
        move = self.off_spin.value()
        try:
            preview_cubemap = reproject_cubemap(self.face_mapping, res, move)
        except Exception as e:
            QMessageBox.critical(self, "Error", "Preview failed:\n" + str(e))
            return
        self.thumbnail_widget.update_preview_images(preview_cubemap)
    def process_skybox(self):
        if not self.face_mapping or any(self.face_mapping[face] is None for face in ["up","rt","ft","lf","bk","dn"]):
            QMessageBox.warning(self, "Error", "Missing one or more cubemap faces.")
            return
        res = self.res_spin.value()
        move = self.off_spin.value()
        try:
            output_cubemap = reproject_cubemap(self.face_mapping, res, move)
        except Exception as e:
            QMessageBox.critical(self, "Error", "Processing failed:\n" + str(e))
            return
        for face, img in output_cubemap.items():
            input_path = self.face_mapping[face]
            dir_name = os.path.dirname(input_path)
            base, ext = os.path.splitext(os.path.basename(input_path))
            pattern = re.compile(r"^(.*?)(\d+)?(" + face + r")$", re.IGNORECASE)
            m = pattern.match(base)
            if m:
                prefix = m.group(1)
                number = m.group(2)
                marker = m.group(3)
                new_number = str(int(number)+1) if number else "2"
                new_base = prefix + new_number + marker
            else:
                if base.lower().endswith(face.lower()):
                    new_base = base[:-len(face)] + "2" + base[-len(face):]
                else:
                    new_base = base + "2"
            new_ext = ".tga" if self.tga_checkbox.isChecked() else ext
            output_filename = new_base + new_ext
            output_path = os.path.join(dir_name, output_filename)
            try:
                if self.tga_checkbox.isChecked():
                    img.convert("RGB").save(output_path, format="TGA", compress=True)
                else:
                    img.save(output_path)
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Saving {output_filename} failed:\n{e}")
                return
        QMessageBox.information(self, "Done", "Cubemap processing complete.\nOutput images have been saved.")

def reproject_cubemap(face_paths, resolution, move):
    """
    Loads 6 cubemap face images from face_paths (dict with keys: up, rt, ft, lf, bk, dn),
    crops them to square and resizes them to (resolution x resolution),
    converts them into an equirectangular panorama of size (2*resolution x 2*resolution),
    applies vertical remapping via remap_horizon (shifting the horizon by move %),
    then reprojects back into cubemap faces.
    Returns a dict mapping face names to PIL Images.
    """
    cubemap = {}
    for face, path in face_paths.items():
        img = Image.open(path).convert("RGB")
        w, h = img.size
        dim = min(w, h)
        left = (w-dim)//2
        top = (h-dim)//2
        img = img.crop((left, top, left+dim, top+dim))
        if img.size != (resolution, resolution):
            img = img.resize((resolution, resolution), Image.LANCZOS)
        cubemap[face] = img
    eq_width = 2 * resolution
    eq_height = 2 * resolution
    input_images = {face: np.array(img) for face, img in cubemap.items()}
    eq_img = cubemap_to_equirectangular(input_images, eq_width, eq_height)
    remapped_eq = remap_horizon(eq_img, move)
    output_cubemap = equirectangular_to_cubemap(remapped_eq, resolution)
    return output_cubemap

def main():
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())

if __name__ == "__main__":
    main()
