# Copyright (C) 2026 Wilhelmus Dingenus de Pooter
# SPDX-License-Identifier: GPL-3.0-or-later
"""
enc_tilestore.py
----------------
Persistent library of baked Chart-Viewer vector tilesets.

The scan-time bake normally writes an EPHEMERAL cache (a ``enc_tilecache_*`` temp
dir, deleted on rescan / unload — see enc_dock_widget.cleanup_tile_cache). This
module lets the user SAVE such a set — under a name — into a persistent folder in
the QGIS profile, list the saved sets, load one back, or delete it.

A saved set is a self-contained folder under ``<profile>/enc_tilesets/<slug>/``:
    wet.mbtiles · dry.mbtiles · decode.json       (the tile planes + decode cache;
                                                   pre-1.187 sets carry decode.pkl,
                                                   which lists but needs a re-scan)
    coverage/…                                    (copied S-102/S-104/S-111 files)
    manifest.json                                 (name, created, paths, coverage)

Saving COPIES the associated coverage datasets (S-102 now; S-104/S-111 when their
detection lands) into the set so it stays loadable in a future session — the
ORIGINAL loaded source files are never touched. Deleting removes the whole folder
(tiles + copied coverage), never the originals.

QGIS-free except for locating the profile dir (best-effort); safe to import
anywhere. All functions never raise for I/O problems — they log and return a safe
value — so the GUI stays responsive.
"""

import json
import os
import re
import shutil
import time

_MANIFEST = "manifest.json"
_STORE_DIRNAME = "enc_tilesets"


def _log(msg):
    try:
        from qgis.core import QgsMessageLog, Qgis
        QgsMessageLog.logMessage(str(msg), "ENCore GIS", Qgis.Info)
    except Exception:                                   # noqa: BLE001
        pass


def store_root():
    """The persistent tileset library folder (created on demand). Lives in the
    active QGIS profile so it travels with the user's profile, per the agreed
    'cache to the profile repo' policy."""
    base = None
    try:
        from qgis.core import QgsApplication
        base = QgsApplication.qgisSettingsDirPath()     # …/profiles/<name>/
    except Exception:                                   # noqa: BLE001
        base = None
    if not base:
        base = os.path.join(os.path.expanduser("~"), ".enc_gis")
    root = os.path.join(base, _STORE_DIRNAME)
    try:
        os.makedirs(root, exist_ok=True)
    except OSError:
        pass
    return root


def _slug(name):
    s = re.sub(r"[^A-Za-z0-9_.-]+", "_", (name or "").strip()).strip("_")
    return s or "tileset"


def dir_size(path):
    """Total size (bytes) of everything under ``path``."""
    total = 0
    for root, _dirs, files in os.walk(path or ""):
        for f in files:
            try:
                total += os.path.getsize(os.path.join(root, f))
            except OSError:
                pass
    return total


def human_size(n):
    n = float(n or 0)
    for unit in ("B", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} GB"


def save_set(name, cache_dir, coverage_files=None, meta=None):
    """Copy an (ephemeral) tile cache dir + its associated coverage files into a
    persistent, named folder under the store root. The originals are NOT moved or
    modified. Returns the manifest dict, or None on failure."""
    if not cache_dir or not os.path.isdir(cache_dir):
        return None
    root = store_root()
    slug = _slug(name)
    dst = os.path.join(root, slug)
    i = 1
    while os.path.isdir(dst):                            # never clobber another set
        dst = os.path.join(root, f"{slug}_{i}")
        i += 1
    try:
        os.makedirs(dst)
        for fn in os.listdir(cache_dir):                # wet/dry mbtiles + decode.json
            src = os.path.join(cache_dir, fn)
            if os.path.isfile(src):
                shutil.copy2(src, os.path.join(dst, fn))
        cov_saved = []              # relative-to-dst paths, for the manifest JSON
        if coverage_files:
            for cf in coverage_files:
                if cf and os.path.isfile(cf):
                    # Preserve the source's immediate parent folder: S-111 depth
                    # layers share a basename and differ only by their folder
                    # (0-5, 0-10, 0-15). Keeping it avoids collisions AND lets the
                    # depth label be read back from the path after a reload.
                    parent = os.path.basename(os.path.dirname(cf))
                    subdir = (os.path.join("coverage", parent) if parent
                              else "coverage")
                    outdir = os.path.join(dst, subdir)
                    os.makedirs(outdir, exist_ok=True)
                    base = os.path.basename(cf)
                    out = os.path.join(outdir, base)
                    n = 1
                    while os.path.exists(out):
                        stem, ext = os.path.splitext(base)
                        out = os.path.join(outdir, f"{stem}__{n}{ext}")
                        n += 1
                    shutil.copy2(cf, out)
                    cov_saved.append(os.path.relpath(out, dst).replace("\\", "/"))
        manifest = {
            "name": name,
            "created": time.strftime("%Y-%m-%d %H:%M"),
            "wet": _member(dst, "wet.mbtiles"),
            "dry": _member(dst, "dry.mbtiles"),
            "decode": (_member(dst, "decode.json")      # legacy sets: decode.pkl
                       or _member(dst, "decode.pkl")),
            "native": _member(dst, "native.gpkg"),   # AtoN·LIGHTS·SOUNDG overlay
            "coverage": [os.path.join(dst, r) for r in cov_saved],  # absolute (return)
            "dir": dst,
        }
        if meta:
            manifest.update(meta)
        disk = dict(manifest)
        disk["coverage"] = cov_saved                  # relative paths in the JSON
        with open(os.path.join(dst, _MANIFEST), "w", encoding="utf-8") as fh:
            json.dump(disk, fh, indent=2)
        _log(f"Tileset saved: {name!r} → {dst} ({human_size(dir_size(dst))})")
        return manifest
    except Exception as e:                              # noqa: BLE001
        _log(f"Tileset save failed for {name!r}: {e}")
        shutil.rmtree(dst, ignore_errors=True)
        return None


def _member(d, fn):
    p = os.path.join(d, fn)
    return p if os.path.exists(p) else None


def list_saved():
    """All saved sets (each a manifest dict with paths re-resolved under its own
    folder, so the library survives the profile being moved). Sorted by name."""
    root = store_root()
    out = []
    try:
        names = sorted(os.listdir(root))
    except OSError:
        return out
    for nm in names:
        d = os.path.join(root, nm)
        mf = os.path.join(d, _MANIFEST)
        if not (os.path.isdir(d) and os.path.isfile(mf)):
            continue
        try:
            with open(mf, encoding="utf-8") as fh:
                m = json.load(fh)
        except Exception:                               # noqa: BLE001
            continue
        m["dir"] = d
        for k in ("wet", "dry", "decode", "native"):
            m[k] = _member(d, os.path.basename(m.get(k) or "")) if m.get(k) else None
        cov = []
        for c in (m.get("coverage") or []):
            c = str(c).replace("\\", "/")
            # new format = path relative to the set dir (keeps the depth folder);
            # legacy = an absolute path → fall back to its basename under coverage/.
            p = (os.path.join(d, "coverage", os.path.basename(c))
                 if os.path.isabs(c) else os.path.join(d, c))
            if os.path.exists(p):
                cov.append(p)
        m["coverage"] = cov
        m["saved"] = True
        m["size"] = dir_size(d)
        out.append(m)
    return out


def delete_set(d):
    """Delete a saved-set folder (tiles + copied coverage). Guarded to the store
    root so nothing outside the library can ever be removed. Returns True/False."""
    if not d:
        return False
    root = os.path.realpath(store_root())
    rd = os.path.realpath(d)
    if os.path.isdir(rd) and (rd == root or rd.startswith(root + os.sep)) \
            and rd != root:
        shutil.rmtree(rd, ignore_errors=True)
        _log(f"Tileset deleted: {rd}")
        return True
    return False
