import os
import re
import sys
import subprocess
import tempfile
import zipfile
from pathlib import Path

import bpy
from bpy.types import Operator, PropertyGroup
from bpy_extras.io_utils import ExportHelper

from blender_asset_tracer.pack import zipped, progress

# Matches filenames like scene_v02.blend (single integer version only).
# Does NOT match: _v02.1.blend, _v2-1.blend, or files without _vNN suffix.
VERSION_RE = re.compile(r'_v(\d+)\.blend$', re.IGNORECASE)


class BlenderProgressCallback(progress.Callback):
    """Progress callback that updates Blender's wm.progress cursor indicator."""

    def __init__(self, wm):
        self._wm = wm
        self._assets_traced = 0
        self._total_bytes = 0
        self._transferred_bytes = 0
        self._phase = "init"

    def pack_start(self):
        self._phase = "trace"
        self._assets_traced = 0
        self._wm.progress_begin(0, 1000)
        self._wm.progress_update(0)
        print("[BAT] Tracing dependencies...")

    def trace_asset(self, filename):
        self._assets_traced += 1
        # Log scale so progress bar never saturates during trace phase
        # 100 assets → ~185, 1000 → ~320, 10000 → ~385
        import math
        val = int(400 * (1 - 1 / (1 + math.log1p(self._assets_traced) / 10)))
        self._wm.progress_update(min(val, 399))
        if self._assets_traced % 100 == 0:
            print("[BAT] Traced %d assets..." % self._assets_traced)

    def transfer_file(self, src, dst):
        if self._phase != "transfer":
            self._phase = "transfer"
            print("[BAT] Transferring files...")

    def transfer_progress(self, total_bytes, transferred_bytes):
        self._total_bytes = total_bytes
        self._transferred_bytes = transferred_bytes
        if total_bytes > 0:
            # Transfer phase: map to 400-1000 range
            pct = transferred_bytes / total_bytes
            val = 400 + int(pct * 600)
            self._wm.progress_update(val)

    def pack_done(self, output_blendfile, missing_files):
        self._wm.progress_update(1000)
        self._wm.progress_end()
        print("[BAT] Pack complete! (%d assets traced)" % self._assets_traced)
        if missing_files:
            print("[BAT] Warning: %d missing files" % len(missing_files))

    def pack_aborted(self, reason):
        self._wm.progress_end()
        print("[BAT] Pack aborted: %s" % reason)


class ExportBatPack(Operator, ExportHelper):
    bl_idname = "export_bat.pack"
    bl_label = "BAT - Zip pack (flat)"
    filename_ext = ".zip"

    @classmethod
    def poll(cls, context):
        return bpy.data.is_saved

    def execute(self, context):
        outfname = bpy.path.ensure_ext(self.filepath, ".zip")
        self.report({"INFO"}, "Executing ZipPacker ...")

        wm = context.window_manager
        progress_cb = BlenderProgressCallback(wm)

        try:
            with zipped.ZipPacker(
                [Path(bpy.data.filepath)],
                Path(bpy.data.filepath).parent,
                str(self.filepath),
            ) as packer:
                packer.progress_cb = progress_cb
                packer.strategise()
                packer.execute()
        except Exception as ex:
            import traceback
            traceback.print_exc()
            self.report({"ERROR"}, "Packing failed: %s" % str(ex))
            return {"CANCELLED"}
        finally:
            try:
                wm.progress_end()
            except Exception:
                pass

        self.report({"INFO"}, "Packing successful!")

        with zipfile.ZipFile(str(self.filepath)) as inzip:
            inzip.testzip()

        self.report({"INFO"}, "Written to %s" % outfname)
        return {"FINISHED"}


def open_folder(folderpath):
    """Open the folder at the path given with cmd relative to user's OS."""
    from shutil import which

    my_os = sys.platform
    if my_os.startswith(("linux", "freebsd")):
        cmd = "xdg-open"
    elif my_os.startswith("win"):
        cmd = "explorer"
        if not folderpath:
            return
    else:
        cmd = "open"

    if not folderpath:
        return

    folderpath = str(folderpath)
    if os.path.isfile(folderpath):
        select = False
        if my_os.startswith("win"):
            cmd = "explorer /select,"
            select = True
        elif my_os.startswith(("linux", "freebsd")):
            if which("nemo"):
                cmd = "nemo --no-desktop"
                select = True
            elif which("nautilus"):
                cmd = "nautilus --no-desktop"
                select = True
        if not select:
            folderpath = os.path.dirname(folderpath)

    folderpath = os.path.normpath(folderpath)
    fullcmd = cmd.split() + [folderpath]
    subprocess.Popen(fullcmd)


class BAT_OT_export_zip(Operator, ExportHelper):
    """Export current blendfile with hierarchy preservation"""

    bl_label = "BAT - Zip pack (keep hierarchy)"
    bl_idname = "bat.export_zip"

    filename_ext = ".zip"

    root_dir: bpy.props.StringProperty(
        name="Root",
        description="Top Level Folder of your project."
        "\nFor now Copy/Paste correct folder by hand if default is incorrect."
        "\n!!! Everything outside won't be zipped !!!",
    )

    use_zip: bpy.props.BoolProperty(
        name="Output as ZIP",
        description="If enabled, pack into a ZIP archive. If disabled, copy to a directory.",
        default=True,
    )

    @classmethod
    def poll(cls, context):
        return bpy.data.is_saved

    def execute(self, context):
        from blender_asset_tracer.pack import Packer
        from blender_asset_tracer.pack.zipped import ZipPacker

        bfile = Path(bpy.data.filepath)
        project = Path(self.root_dir) if self.root_dir else bfile.parent
        target = str(self.filepath)

        if self.use_zip:
            target = bpy.path.ensure_ext(target, ".zip")
            packer_cls = ZipPacker
        else:
            if target.lower().endswith(".zip"):
                target = target[:-4]
            packer_cls = Packer

        self.report({"INFO"}, "Packing with hierarchy...")

        wm = context.window_manager
        progress_cb = BlenderProgressCallback(wm)

        try:
            with packer_cls([bfile], project, target, keep_hierarchy=True) as packer:
                packer.progress_cb = progress_cb
                packer.strategise()
                packer.execute()
        except Exception as ex:
            import traceback
            traceback.print_exc()
            self.report({"ERROR"}, "Packing failed: %s" % str(ex))
            return {"CANCELLED"}
        finally:
            try:
                wm.progress_end()
            except Exception:
                pass

        if self.use_zip:
            with zipfile.ZipFile(target) as inzip:
                inzip.testzip()
            log_output = Path(tempfile.gettempdir(), "README.txt")
            with open(log_output, "w") as log:
                log.write("Packed with BAT (keep-hierarchy mode)")
                log.write(f"\nBlend file: {bpy.data.filepath}")
            with zipfile.ZipFile(target, "a") as zipObj:
                zipObj.write(log_output, log_output.name)

        self.report({"INFO"}, "Written to %s" % target)
        open_folder(Path(target).parent)
        return {"FINISHED"}


class BAT_SequenceFileEntry(PropertyGroup):
    filepath: bpy.props.StringProperty(name="File Path")
    enabled: bpy.props.BoolProperty(name="Enabled", default=True)
    shot_name: bpy.props.StringProperty(name="Shot")


# Studio templates define where to find published blend files.
# Each template is a list of path segments (case-insensitive) to walk from each
# shot directory to the folder containing versioned .blend files.
STUDIO_TEMPLATES = {
    'ADM': {
        'label': "Autour de Minuit",
        'description': "Scans <shot>/<task>/ for the latest versioned .blend (e.g. lighting3d, anim3d)",
        'path_segments': ["{task}"],
        'task_choices': [
            ('lighting3d', "Lighting 3D", ""),
            ('anim3d', "Animation 3D", ""),
            ('layout3d', "Layout 3D", ""),
            ('compositing', "Compositing", ""),
        ],
    },
    'LCPROD': {
        'label': "La Cabane Productions - Lamb Stew",
        'description': "Scans <shot>/03_ANIMATION/Publish/ for the latest versioned .blend",
        'path_segments': ["03_ANIMATION", "Publish"],
    },
}

STUDIO_TEMPLATE_ITEMS = [
    (key, tpl['label'], tpl['description'])
    for key, tpl in STUDIO_TEMPLATES.items()
]

# Collect all task choices across templates that have them.
TASK_CHOICE_ITEMS = []
_seen = set()
for _tpl in STUDIO_TEMPLATES.values():
    for item in _tpl.get('task_choices', []):
        if item[0] not in _seen:
            TASK_CHOICE_ITEMS.append(item)
            _seen.add(item[0])
if not TASK_CHOICE_ITEMS:
    TASK_CHOICE_ITEMS.append(('NONE', "None", "No task filter"))


def _find_subdir_ci(parent, name):
    """Find a child directory matching `name` exactly (case-insensitive)."""
    name_upper = name.upper()
    for child in parent.iterdir():
        if child.is_dir() and child.name.upper() == name_upper:
            return child
    return None


def find_latest_publishes(root_dir, template_key, task=''):
    """Scan a sequence folder for the latest .blend in each shot using the given template.

    Returns (results, errors) where results is a list of (shot_name, filepath) tuples
    and errors is a list of (shot_name, error_message) tuples for shots that could not
    be scanned.
    """
    template = STUDIO_TEMPLATES.get(template_key)
    if not template:
        return [], []

    results = []
    errors = []
    root = Path(root_dir)
    if not root.is_dir():
        return results, errors

    path_segments = [
        seg.replace("{task}", task) if "{task}" in seg and task else seg
        for seg in template['path_segments']
    ]

    for shot_dir in sorted(root.iterdir()):
        if not shot_dir.is_dir():
            continue

        try:
            # Walk the template path segments from the shot directory
            current = shot_dir
            for segment in path_segments:
                current = _find_subdir_ci(current, segment)
                if current is None:
                    break
            if current is None:
                continue

            # Find the .blend with the highest _vNNN version number
            best_version = -1
            best_file = None
            for f in current.iterdir():
                if f.suffix.lower() != '.blend':
                    continue
                m = VERSION_RE.search(f.name)
                if m:
                    ver = int(m.group(1))
                    if ver > best_version:
                        best_version = ver
                        best_file = f
            if best_file:
                results.append((shot_dir.name, best_file.absolute()))
        except OSError as ex:
            errors.append((shot_dir.name, str(ex)))

    return results, errors


class BAT_OT_scan_sequence(Operator):
    """Scan the current file browser directory for latest published blend files"""

    bl_idname = "bat.scan_sequence"
    bl_label = "Scan Current Folder"

    def execute(self, context):
        wm = context.window_manager

        # Read the directory the user is currently browsing in the file selector
        try:
            seq_dir = context.space_data.params.directory.decode('utf-8')
        except (AttributeError, UnicodeDecodeError):
            seq_dir = None

        if not seq_dir:
            self.report({"ERROR"}, "Could not read current file browser directory")
            return {"CANCELLED"}

        wm.bat_sequence_dir = seq_dir
        wm.bat_sequence_files.clear()
        publishes, scan_errors = find_latest_publishes(seq_dir, wm.bat_sequence_template, wm.bat_sequence_task)

        if scan_errors:
            error_summary = "; ".join("%s: %s" % (n, e) for n, e in scan_errors)
            self.report({"ERROR"}, "Failed to scan %d shot(s): %s" % (len(scan_errors), error_summary))
            return {"CANCELLED"}

        if not publishes:
            self.report({"WARNING"}, "No published blend files found in %s" % seq_dir)
            return {"CANCELLED"}

        for shot_name, filepath in publishes:
            entry = wm.bat_sequence_files.add()
            entry.shot_name = shot_name
            entry.filepath = str(filepath)
            entry.enabled = True

        # Update the file browser filename based on the scanned folder name
        folder_name = Path(seq_dir.rstrip("/\\")).name
        try:
            context.space_data.params.filename = folder_name + "_bat_pack.zip"
        except (AttributeError, TypeError):
            pass

        self.report({"INFO"}, "Found %d published blend files" % len(publishes))
        return {"FINISHED"}


class BAT_OT_sequence_pack(Operator, ExportHelper):
    """Pack a sequence of shots with hierarchy preservation and shared asset deduplication"""

    bl_idname = "bat.sequence_pack"
    bl_label = "BAT - Pack Sequence"
    filename_ext = ".zip"

    filter_glob: bpy.props.StringProperty(default="*.zip", options={'HIDDEN'})

    def invoke(self, context, event):
        wm = context.window_manager
        # Default sequence_dir to blend file's parent if not already set
        if not wm.bat_sequence_dir and bpy.data.is_saved:
            wm.bat_sequence_dir = str(Path(bpy.data.filepath).parent)
        # Pre-fill filename from the last scanned directory
        if wm.bat_sequence_dir:
            folder_name = Path(wm.bat_sequence_dir.rstrip("/\\")).name
            self.filepath = folder_name + "_bat_pack.zip"
        return super().invoke(context, event)

    def draw(self, context):
        layout = self.layout
        wm = context.window_manager

        layout.label(text="Studio Template:")
        layout.prop(wm, "bat_sequence_template", text="")

        # Show task selector if the current template has task choices
        template = STUDIO_TEMPLATES.get(wm.bat_sequence_template)
        if template and template.get('task_choices'):
            layout.prop(wm, "bat_sequence_task", text="Task")

        layout.separator()
        layout.label(text="Navigate to the sequence folder, then:")
        layout.operator(BAT_OT_scan_sequence.bl_idname, icon='FILE_REFRESH')

        if wm.bat_sequence_dir:
            layout.label(text="Scanned: %s" % Path(wm.bat_sequence_dir).name)

        if len(wm.bat_sequence_files) > 0:
            layout.separator()
            layout.label(text="Published blend files:")
            box = layout.box()
            for entry in wm.bat_sequence_files:
                row = box.row()
                row.prop(entry, "enabled", text="")
                row.label(text=entry.shot_name)
                row.label(text=Path(entry.filepath).name)

    def execute(self, context):
        from blender_asset_tracer.pack.zipped import ZipPacker

        wm = context.window_manager
        bpaths = [Path(entry.filepath) for entry in wm.bat_sequence_files if entry.enabled]

        if not bpaths:
            self.report({"ERROR"}, "No blend files selected. Scan a folder first.")
            return {"CANCELLED"}

        target = bpy.path.ensure_ext(self.filepath, ".zip")

        try:
            project = Path(os.path.commonpath([p.parent for p in bpaths]))
        except ValueError:
            self.report({"ERROR"},
                        "Blend files span multiple drives. Cannot determine project root.")
            return {"CANCELLED"}
        if project == Path(project.anchor):
            self.report({"WARNING"},
                        "Project root is the filesystem root (%s). "
                        "Consider setting a Root directory." % project)

        self.report({"INFO"}, "Packing %d blend files..." % len(bpaths))

        progress_cb = BlenderProgressCallback(wm)

        try:
            with ZipPacker(bpaths, project, target, keep_hierarchy=True) as packer:
                packer.progress_cb = progress_cb
                packer.strategise()
                packer.execute()
        except Exception as ex:
            import traceback
            traceback.print_exc()
            self.report({"ERROR"}, "Packing failed: %s" % str(ex))
            return {"CANCELLED"}
        finally:
            try:
                wm.progress_end()
            except Exception:
                pass

        with zipfile.ZipFile(target) as inzip:
            inzip.testzip()

        self.report({"INFO"}, "Written to %s" % target)
        open_folder(Path(target).parent)
        return {"FINISHED"}


def menu_func(self, context):
    layout = self.layout
    layout.separator()
    layout.operator(ExportBatPack.bl_idname)
    filepath = layout.operator(BAT_OT_export_zip.bl_idname)
    layout.operator(BAT_OT_sequence_pack.bl_idname)

    try:
        prefs = bpy.context.preferences.addons["blender_asset_tracer"].preferences
        root_dir_env = None
        if prefs.use_env_root:
            root_dir_env = os.getenv("ZIP_ROOT")
            if not root_dir_env:
                root_dir_env = os.getenv("PROJECT_ROOT")
            if not root_dir_env:
                root_dir_env = prefs.root_default
        filepath.root_dir = "" if root_dir_env is None else root_dir_env
    except Exception:
        filepath.root_dir = os.getenv("ZIP_ROOT") or os.getenv("PROJECT_ROOT") or ""


