# 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 hashlib
import json
import os
import re
import shutil
import time
import zipfile

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

# ── shareable tilesets (.enctileset.zip) ─────────────────────────────────────
# The exchange container is the saved-set folder zipped, plus one manifest at
# the zip root. Import is deny-by-default: unlisted members, path traversal,
# pickle files, bad checksums and newer formats are all refused.
_EXCHANGE_MANIFEST = "encore_tileset.json"
_EXCHANGE_FORMAT = "encore-tileset"
_EXCHANGE_VERSION = 1
_SHARE_NOTICE = (
    "NOT FOR NAVIGATION. Baked chart tiles are chart-derived data: whoever "
    "shares this file is responsible for the licence/permission of the "
    "source ENCs and for respecting the ownership and IP of the dataset "
    "producers.")


def _plugin_version():
    try:
        meta = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                            "metadata.txt")
        with open(meta, encoding="utf-8") as fh:
            for line in fh:
                if line.startswith("version="):
                    return line.split("=", 1)[1].strip()
    except OSError:
        pass
    return "unknown"


def _sha256(path):
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


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 _migrate_legacy_decode(d):
    """One-time upgrade of a pre-v1.187 saved set: convert its pickle decode
    cache to the JSON format so the set keeps working across plugin versions
    instead of demanding a re-bake.

    Security containment: this is the ONLY place the plugin ever unpickles,
    and it refuses anything outside the profile's own tileset library — these
    are caches the user's own plugin wrote before the JSON switch. Shared /
    imported sets never reach here (the import path refuses .pkl outright).
    On any failure the set is left untouched (the old re-bake message then
    still applies)."""
    pkl = os.path.join(d, "decode.pkl")
    if os.path.exists(os.path.join(d, "decode.json")) or not os.path.isfile(pkl):
        return
    root = os.path.realpath(store_root())
    if not os.path.realpath(pkl).startswith(root + os.sep):
        return                                          # never unpickle elsewhere
    try:
        import pickle                                   # nosec B403
        try:
            from .enc_tile_bake import write_decode_cache
        except ImportError:
            from enc_tile_bake import write_decode_cache
        with open(pkl, "rb") as fh:
            # Own-library-only migration of a cache this plugin wrote itself
            # (path-guarded above); rewritten as JSON and the pickle removed.
            per_cell = pickle.load(fh)                  # nosec B301
        write_decode_cache(os.path.join(d, "decode.json"), per_cell)
        os.remove(pkl)
        mf = os.path.join(d, _MANIFEST)
        try:
            with open(mf, encoding="utf-8") as fh:
                m = json.load(fh)
            m["decode"] = os.path.join(d, "decode.json")
            with open(mf, "w", encoding="utf-8") as fh:
                json.dump(m, fh, indent=2)
        except Exception:                               # noqa: BLE001
            pass                                        # list_saved re-resolves anyway
        _log(f"Tileset '{os.path.basename(d)}': legacy decode cache migrated "
             "to JSON (one-time)")
    except Exception as e:                              # noqa: BLE001
        _log(f"Tileset '{os.path.basename(d)}': legacy cache migration "
             f"failed ({e}) — the set will ask for a re-bake")


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
        _migrate_legacy_decode(d)                       # pre-v1.187 sets → JSON
        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


def export_set(set_dir, out_zip, extra_meta=None):
    """Export a SAVED tileset folder as a shareable ``.enctileset.zip``:
    the folder's files plus an exchange manifest (format + schema versions,
    per-file sha256, the NOT-FOR-NAVIGATION / IP notice). The mbtiles are
    stored uncompressed (their tiles are already gzip'd MVT). Unlike the
    library helpers this RAISES ValueError with the refusal reason, so the
    GUI can show it. Returns ``out_zip``."""
    if not set_dir or not os.path.isdir(set_dir):
        raise ValueError("the tileset folder does not exist")
    if not os.path.isfile(os.path.join(set_dir, _MANIFEST)):
        raise ValueError("not a saved tileset (manifest.json missing) — "
                         "save it to the library first")
    files = {}
    for root, _dirs, fns in os.walk(set_dir):
        for fn in fns:
            p = os.path.join(root, fn)
            rel = os.path.relpath(p, set_dir).replace("\\", "/")
            if rel.lower().endswith(".pkl"):
                raise ValueError(
                    "this set still carries a pickle-era cache — open the "
                    "tileset library once (it migrates automatically), then "
                    "export again")
            files[rel] = {"size": os.path.getsize(p), "sha256": _sha256(p)}
    if not files:
        raise ValueError("the tileset folder is empty")
    try:
        from .enc_tile_bake import _SCHEMA as _DECODE_SCHEMA
    except ImportError:
        from enc_tile_bake import _SCHEMA as _DECODE_SCHEMA
    manifest = {
        "format": _EXCHANGE_FORMAT,
        "format_version": _EXCHANGE_VERSION,
        "decode_schema": _DECODE_SCHEMA,
        "plugin_version": _plugin_version(),
        "created_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "creator": "",                                  # privacy by default
        "notice": _SHARE_NOTICE,
        "files": files,
    }
    manifest.update(extra_meta or {})
    with zipfile.ZipFile(out_zip, "w") as z:
        z.writestr(_EXCHANGE_MANIFEST,
                   json.dumps(manifest, indent=1),
                   compress_type=zipfile.ZIP_DEFLATED)
        for rel in sorted(files):
            comp = (zipfile.ZIP_STORED if rel.lower().endswith(".mbtiles")
                    else zipfile.ZIP_DEFLATED)
            z.write(os.path.join(set_dir, *rel.split("/")), rel,
                    compress_type=comp)
    _log(f"Tileset exported: {set_dir} → {out_zip} "
         f"({human_size(os.path.getsize(out_zip))})")
    return out_zip


def _safe_member(name):
    """True when a zip member path is safe to extract: relative, forward
    slashes, no drive letters, no parent-directory escapes."""
    if not name or "\\" in name or ":" in name:
        return False
    if name.startswith("/"):
        return False
    parts = name.split("/")
    return all(p not in ("", ".", "..") for p in parts)


def import_set(zip_path):
    """Validate and install a shared ``.enctileset.zip`` into the library.
    Deny-by-default: refuses a missing/foreign manifest, newer format or
    decode schema, unsafe member paths (zip-slip), pickle members, unlisted
    members and checksum mismatches. RAISES ValueError with the reason.
    Returns the new set folder path."""
    try:
        z = zipfile.ZipFile(zip_path)
    except (OSError, zipfile.BadZipFile) as e:
        raise ValueError(f"not a readable zip file ({e})")
    with z:
        names = [n for n in z.namelist() if not n.endswith("/")]
        if _EXCHANGE_MANIFEST not in names:
            raise ValueError("not an ENCoreGIS tileset (no encore_tileset.json)")
        try:
            m = json.loads(z.read(_EXCHANGE_MANIFEST).decode("utf-8"))
        except Exception as e:                          # noqa: BLE001
            raise ValueError(f"broken exchange manifest ({e})")
        if m.get("format") != _EXCHANGE_FORMAT:
            raise ValueError("not an ENCoreGIS tileset (wrong format marker)")
        try:
            fv = int(m.get("format_version"))
        except (TypeError, ValueError):
            raise ValueError("exchange manifest has no format_version")
        if fv > _EXCHANGE_VERSION:
            raise ValueError(
                f"tileset container format {fv} is newer than this plugin "
                f"understands (≤{_EXCHANGE_VERSION}) — update ENCoreGIS")
        try:
            from .enc_tile_bake import _SCHEMA as _DECODE_SCHEMA
        except ImportError:
            from enc_tile_bake import _SCHEMA as _DECODE_SCHEMA
        try:
            ds = int(m.get("decode_schema"))
        except (TypeError, ValueError):
            raise ValueError("exchange manifest has no decode_schema")
        if ds > _DECODE_SCHEMA:
            raise ValueError(
                f"tile cache schema {ds} is newer than this plugin "
                f"understands (≤{_DECODE_SCHEMA}) — update ENCoreGIS")
        listed = m.get("files") or {}
        members = [n for n in names if n != _EXCHANGE_MANIFEST]
        for n in members:
            if not _safe_member(n):
                raise ValueError(f"unsafe path in the zip refused: {n!r}")
            if n.lower().endswith(".pkl"):
                raise ValueError(
                    "the set contains a pickle cache — refused; ask the "
                    "sharer to re-export with ENCoreGIS ≥ 1.187")
            if n not in listed:
                raise ValueError(f"unlisted file in the zip refused: {n!r}")
        for req in (_MANIFEST, "decode.json"):
            if req not in members:
                raise ValueError(f"incomplete tileset: {req} is missing")
        if not any(n.endswith(".mbtiles") for n in members):
            raise ValueError("incomplete tileset: no tile planes (.mbtiles)")
        root = store_root()
        slug = _slug(m.get("name") or
                     os.path.splitext(os.path.basename(zip_path))[0])
        dst = os.path.join(root, slug)
        i = 1
        while os.path.isdir(dst):                        # never clobber a set
            dst = os.path.join(root, f"{slug}_{i}")
            i += 1
        os.makedirs(dst)
        try:
            for n in members:
                out = os.path.join(dst, *n.split("/"))
                os.makedirs(os.path.dirname(out), exist_ok=True)
                with z.open(n) as src, open(out, "wb") as dstf:
                    shutil.copyfileobj(src, dstf)
                ent = listed[n]
                if (os.path.getsize(out) != int(ent.get("size", -1))
                        or _sha256(out) != ent.get("sha256")):
                    raise ValueError(f"checksum mismatch on {n!r} — the "
                                     "file is corrupt or was tampered with")
        except Exception:
            shutil.rmtree(dst, ignore_errors=True)       # all-or-nothing
            raise
    _log(f"Tileset imported: {zip_path} → {dst}")
    return dst
