# Texture copying, sanitization, relative URI building.

import os
import re
import shutil
import hashlib
from urllib.parse import quote

from .. import settings


_UNSAFE = re.compile(r"[^A-Za-z0-9._\-]+")

_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".tga", ".bmp",
               ".exr", ".hdr", ".psd", ".tx", ".gif", ".webp")


def build_texture_index(roots, max_files=20000, max_dirs=5000):
    # Walk the given root folders once and map lowercase basename -> full path
    # for every image file found. Used to recover textures whose stored path is
    # stale (moved/renamed folders, Windows paths opened elsewhere, etc.).
    index = {}
    count = 0
    dir_count = 0
    skipped_dirs = {
        ".git", ".svn", "__pycache__", "cache", "caches", "node_modules"
    }
    for root in roots or []:
        if not root or not os.path.isdir(root):
            continue
        try:
            for dirpath, dirs, filenames in os.walk(root, followlinks=False):
                dirs[:] = [name for name in dirs
                           if name.lower() not in skipped_dirs]
                dir_count += 1
                if dir_count > max_dirs:
                    return index
                for fn in filenames:
                    ext = os.path.splitext(fn)[1].lower()
                    if ext in _IMAGE_EXTS:
                        index.setdefault(fn.lower(), os.path.join(dirpath, fn))
                        count += 1
                        if count >= max_files:
                            return index
        except Exception:
            continue
    return index


def resolve_texture_path(path, index):
    # Returns (resolved_path, relocated_bool). If the literal path exists it is
    # returned unchanged. Otherwise the basename is looked up in the index.
    if not path:
        return None, False
    try:
        if os.path.isfile(os.path.normpath(path)):
            return path, False
    except Exception:
        pass
    if index:
        base = os.path.basename(path).lower()
        found = index.get(base)
        if found and os.path.isfile(found):
            return found, True
    return path, False


def sanitize_filename(name):
    base = os.path.basename(name) if name else ""
    if not base:
        return "texture"
    safe = _UNSAFE.sub("_", base).lstrip("._-")
    return safe or "texture"


def _same_file(a, b):
    try:
        return os.path.samefile(a, b)
    except Exception:
        try:
            return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b))
        except Exception:
            return False


def copy_texture(source_path, output_folder):
    # Copies the texture into <output_folder>/textures/. Returns (abs_dest, warnings).
    warnings = []
    if not source_path:
        return None, ["Empty texture path."]
    src = os.path.normpath(source_path)
    if not os.path.isfile(src):
        return None, ["Missing texture on disk: '{0}'".format(source_path)]
    tex_dir = os.path.join(output_folder, "textures")
    try:
        if not os.path.isdir(tex_dir):
            os.makedirs(tex_dir)
    except Exception as exc:
        return None, ["Could not create textures folder: {0}".format(exc)]
    safe = sanitize_filename(src)
    dest = os.path.join(tex_dir, safe)
    if os.path.exists(dest) and not _same_file(src, dest):
        stem, ext = os.path.splitext(safe)
        i = 1
        while True:
            cand = os.path.join(tex_dir, "{0}_{1}{2}".format(stem, i, ext))
            if not os.path.exists(cand) or _same_file(src, cand):
                dest = cand
                break
            i += 1
    if not os.path.exists(dest) or not _same_file(src, dest):
        try:
            shutil.copy2(src, dest)
        except Exception as exc:
            return None, ["Failed to copy '{0}': {1}".format(src, exc)]
    return dest, warnings


def read_texture_bytes(path):
    try:
        with open(path, "rb") as f:
            return f.read()
    except Exception:
        return None


def _downsample_to_max(image, max_size):
    # Resize an MImage in place so its largest dimension <= max_size, preserving
    # aspect ratio. Returns True if it resized. max_size <= 0 means no limit.
    if not max_size or max_size <= 0:
        return False
    try:
        w, h = image.getSize()
    except Exception:
        return False
    longest = max(w, h)
    if longest <= max_size:
        return False
    scale = float(max_size) / float(longest)
    nw = max(1, int(round(w * scale)))
    nh = max(1, int(round(h * scale)))
    try:
        image.resize(nw, nh, False)
        return True
    except Exception:
        return False


def ensure_native_texture(source_path, output_folder, max_size=0):
    # glTF 2.0 core supports PNG and JPEG images. Convert renderer-native
    # formats such as EXR, HDR, TIFF, and TX to PNG through Maya MImage, and
    # downsample any texture whose largest dimension exceeds max_size. 4K EXR
    # sets are both slow to embed and bloat the GLB, so a cap is important.
    warnings = []
    if not source_path:
        return None, ["Empty texture path."]
    src = os.path.normpath(source_path)
    if not os.path.isfile(src):
        return None, ["Missing texture on disk: '{0}'".format(source_path)]
    ext = os.path.splitext(src)[1].lower()
    is_native = ext in settings.GLTF_NATIVE_IMAGE_EXTENSIONS

    tex_dir = os.path.join(output_folder, "textures")
    try:
        if not os.path.isdir(tex_dir):
            os.makedirs(tex_dir)
    except Exception as exc:
        return None, ["Could not create textures folder: {0}".format(exc)]

    stem = os.path.splitext(sanitize_filename(src))[0]
    digest = hashlib.sha1(
        (os.path.normcase(os.path.abspath(src)) + "|" + str(max_size)).encode("utf-8")
    ).hexdigest()[:8]

    try:
        import maya.api.OpenMaya as om
        image = om.MImage()
        image.readFromFile(src)
        resized = _downsample_to_max(image, max_size)
    except Exception as exc:
        if is_native:
            # Could not inspect it but it is already a glTF-native format; ship
            # the original rather than failing the whole material.
            return src, ["Could not inspect '{0}' for resizing: {1}".format(src, exc)]
        return None, [
            "Unsupported texture '{0}' could not be converted to PNG: {1}".format(src, exc)
        ]

    if is_native and not resized:
        # Already a compatible format and within the size cap; use as-is.
        return src, warnings

    out_ext = ".jpg" if ext in (".jpg", ".jpeg") else ".png"
    out_fmt = "jpg" if out_ext == ".jpg" else "png"
    dest = os.path.join(tex_dir, "{0}_{1}{2}".format(stem, digest, out_ext))
    try:
        image.writeToFile(dest, out_fmt)
    except Exception as exc:
        return None, ["Could not write converted texture '{0}': {1}".format(dest, exc)]
    if not os.path.isfile(dest):
        return None, ["Texture conversion did not create '{0}'".format(dest)]
    if not is_native:
        warnings.append("Converted unsupported texture '{0}' to PNG.".format(src))
    if resized:
        warnings.append("Downsampled large texture '{0}' to max {1}px.".format(src, max_size))
    return dest, warnings


def pack_metallic_roughness(metallic_path, roughness_path, output_folder,
                            invert_roughness=False, max_size=0):
    # glTF stores roughness in G and metallic in B. Maya renderers commonly
    # use separate grayscale maps, so pack them into one portable PNG.
    # invert_roughness converts a glossiness/smoothness map to roughness
    # (roughness = 1 - glossiness) on the way in. max_size caps the working
    # resolution so the per-pixel pack loop stays fast on 4K source maps.
    warnings = []
    if not metallic_path and not roughness_path:
        return None, warnings

    try:
        import ctypes
        import maya.api.OpenMaya as om
    except Exception as exc:
        return None, ["Maya image API is unavailable: {0}".format(exc)]

    paths = [path for path in (roughness_path, metallic_path) if path]
    for path in paths:
        if not os.path.isfile(os.path.normpath(path)):
            return None, ["Missing texture on disk: '{0}'".format(path)]

    images = {}
    width = 0
    height = 0
    try:
        for label, path in (
                ("roughness", roughness_path),
                ("metallic", metallic_path)):
            if not path:
                continue
            image = om.MImage()
            image.readFromFile(os.path.normpath(path))
            w, h = image.getSize()
            if not width or not height:
                width, height = w, h
            images[label] = image

        if not width or not height:
            return None, ["Could not determine metallic-roughness texture size."]

        # Cap working resolution before the per-pixel pack loop.
        if max_size and max_size > 0:
            longest = max(width, height)
            if longest > max_size:
                scale = float(max_size) / float(longest)
                width = max(1, int(round(width * scale)))
                height = max(1, int(round(height * scale)))

        for image in images.values():
            w, h = image.getSize()
            if w != width or h != height:
                image.resize(width, height, False)

        pixel_data = {}
        for label, image in images.items():
            pointer = image.pixels()
            pixel_data[label] = ctypes.string_at(pointer, width * height * 4)

        packed = bytearray(width * height * 4)
        rough_bytes = pixel_data.get("roughness")
        metal_bytes = pixel_data.get("metallic")
        # glTF samples roughness from G and metallic from B, then multiplies by
        # roughnessFactor / metallicFactor. When a channel has no map we write
        # 255 (1.0) so the scalar factor stays in control instead of forcing
        # the value to zero and discarding authored constants.
        for index in range(width * height):
            offset = index * 4
            rough = rough_bytes[offset] if rough_bytes else 255
            if rough_bytes and invert_roughness:
                rough = 255 - rough
            packed[offset] = 255
            packed[offset + 1] = rough
            packed[offset + 2] = metal_bytes[offset] if metal_bytes else 255
            packed[offset + 3] = 255

        digest_source = "|".join(
            os.path.normcase(os.path.abspath(path)) if path else ""
            for path in (metallic_path, roughness_path, "inv" if invert_roughness else "")
        )
        digest = hashlib.sha1(digest_source.encode("utf-8")).hexdigest()[:8]
        tex_dir = os.path.join(output_folder, "textures")
        if not os.path.isdir(tex_dir):
            os.makedirs(tex_dir)
        dest = os.path.join(tex_dir, "metallic_roughness_{0}.png".format(digest))

        output = om.MImage()
        output.setPixels(bytes(packed), width, height)
        output.writeToFile(dest, "png")
    except Exception as exc:
        return None, [
            "Could not pack metallic-roughness texture: {0}".format(exc)
        ]

    if not os.path.isfile(dest):
        return None, ["Metallic-roughness packing did not create '{0}'".format(dest)]
    return dest, warnings


def pack_base_color_alpha(base_color_path, opacity_path, output_folder, max_size=0):
    # glTF reads alpha from the base-color texture. Merge a separate opacity
    # map into that alpha channel when the Maya material uses one. max_size caps
    # the working resolution so the per-pixel merge loop stays fast.
    if not base_color_path or not opacity_path:
        return base_color_path, []
    if _same_file(base_color_path, opacity_path):
        return base_color_path, []

    try:
        import ctypes
        import maya.api.OpenMaya as om
    except Exception as exc:
        return None, ["Maya image API is unavailable: {0}".format(exc)]

    for path in (base_color_path, opacity_path):
        if not os.path.isfile(os.path.normpath(path)):
            return None, ["Missing texture on disk: '{0}'".format(path)]

    try:
        color_image = om.MImage()
        color_image.readFromFile(os.path.normpath(base_color_path))
        width, height = color_image.getSize()

        if max_size and max_size > 0:
            longest = max(width, height)
            if longest > max_size:
                scale = float(max_size) / float(longest)
                width = max(1, int(round(width * scale)))
                height = max(1, int(round(height * scale)))
                color_image.resize(width, height, False)

        opacity_image = om.MImage()
        opacity_image.readFromFile(os.path.normpath(opacity_path))
        opacity_width, opacity_height = opacity_image.getSize()
        if opacity_width != width or opacity_height != height:
            opacity_image.resize(width, height, False)

        color_data = ctypes.string_at(
            color_image.pixels(), width * height * 4
        )
        opacity_data = ctypes.string_at(
            opacity_image.pixels(), width * height * 4
        )
        packed = bytearray(color_data)
        for index in range(width * height):
            offset = index * 4
            packed[offset + 3] = opacity_data[offset]

        digest_source = "|".join(
            os.path.normcase(os.path.abspath(path))
            for path in (base_color_path, opacity_path)
        )
        digest = hashlib.sha1(digest_source.encode("utf-8")).hexdigest()[:8]
        tex_dir = os.path.join(output_folder, "textures")
        if not os.path.isdir(tex_dir):
            os.makedirs(tex_dir)
        dest = os.path.join(tex_dir, "base_color_alpha_{0}.png".format(digest))

        output = om.MImage()
        output.setPixels(bytes(packed), width, height)
        output.writeToFile(dest, "png")
    except Exception as exc:
        return None, ["Could not pack base-color alpha: {0}".format(exc)]

    if not os.path.isfile(dest):
        return None, ["Base-color alpha packing did not create '{0}'".format(dest)]
    return dest, []


def normalize_uri(absolute_path, gltf_folder):
    if not absolute_path:
        return ""
    try:
        rel = os.path.relpath(absolute_path, gltf_folder)
    except ValueError:
        rel = absolute_path
    return rel.replace("\\", "/")


def encode_uri(uri):
    # Percent-encode for safe placement in glTF JSON.
    if not uri:
        return uri
    return quote(uri, safe="/-_.~()")


def mime_for_path(path):
    if not path:
        return "image/png"
    lower = path.lower()
    if lower.endswith(".jpg") or lower.endswith(".jpeg"):
        return "image/jpeg"
    if lower.endswith(".png"):
        return "image/png"
    return None
