#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Figure 7: the boundary walk on the example basin 8595.

Input: ../Data/ex8595_*.tif.
Output: ../Figures/Fig07_BoundaryWalk_v11.png and .pdf.

Run from this folder; ../Data holds the inputs and the figure is written to ../Figures.
"""

import os

import numpy as np
import rasterio

import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.patches import Patch


# ============================================================================
# Style (ESSD / AGU – Helvetica, cm mathtext, 9 pt)
# ============================================================================
mpl.rcParams.update({
    "font.family":         "Helvetica",
    "font.size":            9,
    "axes.labelsize":       9,
    "axes.titlesize":      10,
    "axes.titleweight":    "bold",
    "xtick.labelsize":      8,
    "ytick.labelsize":      8,
    "mathtext.fontset":   "cm",
    "axes.linewidth":     0.6,
    "axes.unicode_minus": False,
})


# ============================================================================
# Paths
# ============================================================================
HERE     = os.path.dirname(os.path.abspath(__file__))
ROOT     = os.path.abspath(os.path.join(HERE, "..", ".."))
DATA     = os.path.join(HERE, "..", "Data")
OUT_DIR  = os.path.join(HERE, "..", "Figures")
os.makedirs(OUT_DIR, exist_ok=True)

FOCUS_BSN_ID = 8595      # global_basin_id (the v1.1 basin raster holds global ids)
SKEL_KM2     = 10.0      # UPA threshold for the basin skeleton (noise filter)


# ============================================================================
# D8 code conventions
# ============================================================================
# Direction (dr, dc) -> the D8 code that *makes a pixel point that way*.
# Used to find a pixel's *upstream* neighbours: a neighbour at (dr, dc)
# is upstream iff its D8 code is the inverse direction.
INV = {(0, 1): 16, (1, 1): 32, (1, 0): 64, (1, -1): 128,
       (0, -1): 1, (-1, -1): 2, (-1, 0): 4, (-1, 1): 8}

# Direct (dr, dc) lookup for a given D8 code (downstream offset).
D8_OFF = {1: (0, 1), 2: (1, 1), 4: (1, 0), 8: (1, -1),
          16: (0, -1), 32: (-1, -1), 64: (-1, 0), 128: (-1, 1)}


# ============================================================================
# Read rasters + tight crop around focus basin
# ============================================================================
def _read(name):
    with rasterio.open(os.path.join(DATA, f"ex8595_{name}.tif")) as s:
        return s.read(1)

dir_arr  = _read("dir")
upa      = _read("upa")
dfs_in   = _read("dfs_in")
dfs_out  = _read("dfs_out")
bsn_full = _read("bsn")
bmask_full = (bsn_full == FOCUS_BSN_ID)
H_full, W_full = bmask_full.shape

rows = np.where(bmask_full.any(axis=1))[0]
cols = np.where(bmask_full.any(axis=0))[0]
PAD  = 6
r0 = max(0, rows[0] - PAD); r1 = min(H_full, rows[-1] + 1 + PAD)
c0 = max(0, cols[0] - PAD); c1 = min(W_full, cols[-1] + 1 + PAD)
sl = (slice(r0, r1), slice(c0, c1))

dir_c   = dir_arr[sl]
upa_c   = upa[sl]
dfin_c  = dfs_in[sl]
dfout_c = dfs_out[sl]
bmask   = bmask_full[sl]
H, W    = bmask.shape


# ============================================================================
# Helpers
# ============================================================================
def upstream_neighbours(i, j, mask, dir_local):
    out = []
    for (di, dj), code in INV.items():
        ni, nj = i + di, j + dj
        if (0 <= ni < mask.shape[0] and 0 <= nj < mask.shape[1]
                and mask[ni, nj] and dir_local[ni, nj] == code):
            out.append((ni, nj))
    return out


def walk_max_upa(start, mask, upa_local, dir_local):
    """Greedy upstream walk: at each step take the upstream neighbour with
    the largest UPA.  Used for tracing the main stem and other skeleton
    branches."""
    chain = [start]
    cur   = start
    while True:
        ups = upstream_neighbours(*cur, mask, dir_local)
        if not ups:
            break
        cur = max(ups, key=lambda p: upa_local[p[0], p[1]])
        chain.append(cur)
    return chain


def ups_set(T):
    """Upstream catchment of pixel T: dfs_in(T) <= dfs_in <= dfs_out(T)."""
    a, b = dfin_c[T], dfout_c[T]
    return bmask & (dfin_c >= a) & (dfin_c <= b)


def boundary_8(mask):
    """Boundary pixels by the eight-neighbor test: pixels of the mask with at
    least one of their eight neighbors outside it, as in the production code
    (main_merit90m_flowmorph_step3_boundary_walk.c) and the Fig. 7 caption.
    (Until 1 Nov 2026 this was a 4-neighbor test, boundary_4.)"""
    out = np.zeros_like(mask)
    out[1:,  :] |= mask[1:,  :] & ~mask[:-1, :]
    out[:-1, :] |= mask[:-1, :] & ~mask[1:,  :]
    out[:, 1:]  |= mask[:, 1:]  & ~mask[:, :-1]
    out[:, :-1] |= mask[:, :-1] & ~mask[:, 1:]
    out[1:,  1:]  |= mask[1:,  1:]  & ~mask[:-1, :-1]
    out[1:,  :-1] |= mask[1:,  :-1] & ~mask[:-1, 1:]
    out[:-1, 1:]  |= mask[:-1, 1:]  & ~mask[1:,  :-1]
    out[:-1, :-1] |= mask[:-1, :-1] & ~mask[1:,  1:]
    out[0,  :]  |= mask[0,  :];  out[-1, :] |= mask[-1, :]
    out[:,  0]  |= mask[:, 0];   out[:, -1]  |= mask[:, -1]
    return out


# ============================================================================
# Outlet (max-UPA pixel within focus mask)
# ============================================================================
upa_in = np.where(bmask, upa_c, -1)
oi, oj = np.unravel_index(np.argmax(upa_in), upa_in.shape)


# Sanity check: ups_set(outlet) should equal bmask.  If not, fall back
# to ups_set(outlet) so border + interior always line up.
ups_out = ups_set((oi, oj))
diff = ups_out ^ bmask
print(f"|bmask|={int(bmask.sum())}  |ups_set(outlet)|={int(ups_out.sum())}  "
      f"diff={int(diff.sum())} px")
assert not diff.any(), "the outlet's interval does not hold exactly the basin"


# ============================================================================
# Main stem and two targets T1 (upstream) / T2 (downstream)
# ============================================================================
main_stem = walk_max_upa((oi, oj), bmask, upa_c, dir_c)
N_main    = len(main_stem)
print(f"main stem N = {N_main}")

# main_stem[0] = outlet, main_stem[N-1] = source.  T1 closer to source
# (upstream), T2 midstream (downstream of T1).
T1 = main_stem[(3 * N_main) // 4]
T2 = main_stem[N_main // 2]
ups_T1   = ups_set(T1)
ups_T2   = ups_set(T2)
ring_T1  = boundary_8(ups_T1)
ring_T2  = boundary_8(ups_T2)
ring_common  = ring_T1 &  ring_T2
ring_deleted = ring_T1 & ~ring_T2
ring_added   = ring_T2 & ~ring_T1
print(f"|ring(T1)|={int(ring_T1.sum())}  |ring(T2)|={int(ring_T2.sum())}  "
      f"common={int(ring_common.sum())}  deleted={int(ring_deleted.sum())}  "
      f"added={int(ring_added.sum())}")


# ============================================================================
# Skeleton extraction (UPA ≥ SKEL_KM2): greedy chain decomposition
# ============================================================================
skel_mask = bmask & (upa_c >= SKEL_KM2)
remaining = skel_mask.copy()
chains    = []
while remaining.any():
    upa_rem  = np.where(remaining, upa_c, -1)
    si, sj   = np.unravel_index(np.argmax(upa_rem), upa_rem.shape)
    chain    = walk_max_upa((si, sj), remaining, upa_c, dir_c)
    for (i, j) in chain:
        remaining[i, j] = False
    chains.append(chain)

main_idx = max(range(len(chains)), key=lambda k: upa_c[chains[k][0]])
main_chain = chains[main_idx]
ms_idx = {p: k for k, p in enumerate(reversed(main_chain))}  # source=0


def junction_to_main(chain):
    """Walk downstream from a chain's downstream end until reaching a
    main-stem pixel; return its index along the main stem (source=0)."""
    cur, seen = chain[0], set()
    for _ in range(dir_c.size):
        if cur in ms_idx: return ms_idx[cur]
        if cur in seen:   return -1
        seen.add(cur)
        d = int(dir_c[cur])
        if d not in D8_OFF: return -1
        di, dj = D8_OFF[d]
        ni, nj = cur[0] + di, cur[1] + dj
        if not (0 <= ni < dir_c.shape[0] and 0 <= nj < dir_c.shape[1]):
            return -1
        cur = (ni, nj)
    return -1


others = [c for k, c in enumerate(chains) if k != main_idx]
others_with_j = [(junction_to_main(c), c) for c in others]
others_with_j.sort(key=lambda t: t[0])
chains = [c for _, c in others_with_j] + [main_chain]
N_branches = len(chains)
print(f"N branches (skeleton) = {N_branches}")

# main-stem split for panel (d): 33 % and 67 % along source -> outlet
ms_src_to_out = list(reversed(chains[-1]))
L = len(ms_src_to_out)
cut_a = ms_src_to_out[L // 3]
cut_b = ms_src_to_out[(2 * L) // 3]
print(f"main stem split: cut@33%={cut_a}, cut@67%={cut_b}")


# ============================================================================
# Plot setup
# ============================================================================
fig, axes = plt.subplots(2, 2, figsize=(8.0, 3.8))

DFIN_DISP = np.where(bmask & (dfin_c >= 0), dfin_c, np.nan).astype(np.float64)
DFIN_VMIN = float(np.nanpercentile(DFIN_DISP, 1))
DFIN_VMAX = float(np.nanpercentile(DFIN_DISP, 99))
BASIN_BND = boundary_8(bmask)
BB_R, BB_C = np.where(BASIN_BND)


def base(ax, draw_skel=True):
    """All panels share: dfs_in viridis fill + skeleton lines + basin
    border (low-zorder so panel-specific overlays sit on top) + outlet ▼."""
    ax.set_facecolor("white")
    ax.imshow(DFIN_DISP, cmap="viridis", vmin=DFIN_VMIN, vmax=DFIN_VMAX,
              origin="upper", interpolation="nearest", alpha=0.9, zorder=1)
    if draw_skel:
        for ch in chains:
            rr = np.array([p[0] for p in ch])
            cc = np.array([p[1] for p in ch])
            ax.plot(cc, rr, "-", color="white",
                    linewidth=0.6, alpha=0.95, zorder=3)
    ax.scatter(BB_C, BB_R, s=3, c="black", marker="s",
               linewidths=0, alpha=1.0, zorder=5)
    ax.plot(oj, oi, marker="v", color="#E53935", markersize=6,
            markeredgecolor="black", markeredgewidth=0.6,
            linestyle="none", zorder=12)
    ax.set_xlim(-0.5, W - 0.5); ax.set_ylim(H - 0.5, -0.5)
    ax.set_aspect("equal")
    ax.set_xticks([]); ax.set_yticks([])
    for sp in ax.spines.values():
        sp.set_linewidth(0.5); sp.set_color("#888888")


def scatter_pixels(ax, mask, color, size=3.5, marker="s",
                   alpha=0.9, zorder=5):
    rr, cc = np.where(mask)
    ax.scatter(cc, rr, s=size, c=color, marker=marker,
               linewidths=0, alpha=alpha, zorder=zorder)


LABEL_BOX = dict(boxstyle="round,pad=0.16", fc="white", ec="#888888",
                 lw=0.4, alpha=0.95)


# ----------------------------------------------------------------------------
# (a) ring(T₁) — split into two parts per manuscript caption:
#   gray = segment on the basin perimeter (BASIN_BND ∩ ring_T1)
#   red  = new closing edge interior to the basin (ring_T1 \ BASIN_BND)
# ----------------------------------------------------------------------------
ring_T1_on_perim    = ring_T1 &  BASIN_BND
ring_T1_closing_edge = ring_T1 & ~BASIN_BND
ax = axes[0, 0]
base(ax)
scatter_pixels(ax, ring_T1_on_perim,    "#9aa0a6", size=3.5, alpha=1.0, zorder=7)
scatter_pixels(ax, ring_T1_closing_edge, "#dc2626", size=4.5, alpha=1.0, zorder=9)
ax.plot(T1[1], T1[0], marker="o", color="black", markersize=5,
        linestyle="none", zorder=13)
ax.text(T1[1] + 6, T1[0] + 6, r"$T_1$", color="black",
        fontsize=8.5, weight="bold",
        ha="left", va="top", bbox=LABEL_BOX, zorder=14)
ax.legend(handles=[
    Patch(facecolor="#9aa0a6", alpha=0.85, label="on basin perimeter"),
    Patch(facecolor="#dc2626", alpha=0.95, label="closing edge"),
], loc="lower right", fontsize=7.5, frameon=True,
   edgecolor="#888", framealpha=0.95)
ax.set_title(r"(a) Boundary of upstream$(T_1)$", loc="left", pad=3)


# ----------------------------------------------------------------------------
# (b) ring(T₁) ↔ ring(T₂) symmetric difference
# ----------------------------------------------------------------------------
ax = axes[0, 1]
base(ax)
scatter_pixels(ax, ring_common,  "#9aa0a6", size=3.5, alpha=0.95, zorder=7)
scatter_pixels(ax, ring_deleted, "#dc2626", size=4.0, alpha=0.95, zorder=8)
scatter_pixels(ax, ring_added,   "#16a34a", size=4.0, alpha=0.95, zorder=8)
for tgt, lab in [(T1, r"$T_1$"), (T2, r"$T_2$")]:
    ax.plot(tgt[1], tgt[0], marker="o", color="black",
            markersize=5, linestyle="none", zorder=13)
    ax.text(tgt[1] + 6, tgt[0] + 6, lab, color="black",
            fontsize=8.5, weight="bold",
            ha="left", va="top", bbox=LABEL_BOX, zorder=14)
ax.legend(handles=[
    Patch(facecolor="#9aa0a6", alpha=0.85, label="common"),
    Patch(facecolor="#dc2626", alpha=0.95, label="deleted"),
    Patch(facecolor="#16a34a", alpha=0.95, label="added"),
], loc="lower right", fontsize=7.5, frameon=True,
   edgecolor="#888", framealpha=0.95)
ax.set_title(r"(b) Move target to $T_2$", loc="left", pad=3)


def downstream_arrow(ax, pos, color="black", length=10.0, lw=1.4, zorder=11):
    d = int(dir_c[pos])
    if d not in D8_OFF: return
    di, dj = D8_OFF[d]
    norm = (di * di + dj * dj) ** 0.5
    tip  = (pos[1] + dj * length / norm, pos[0] + di * length / norm)
    base_pt = (pos[1], pos[0])
    ax.annotate("", xy=tip, xytext=base_pt,
                arrowprops=dict(arrowstyle="-|>", color=color,
                                lw=lw, mutation_scale=14,
                                shrinkA=2, shrinkB=0),
                zorder=zorder)


# ----------------------------------------------------------------------------
# (c) Branch tips
# ----------------------------------------------------------------------------
ax = axes[1, 0]
base(ax, draw_skel=True)
LABEL_FS = 8.5
for k, ch in enumerate(chains, 1):
    head = ch[-1]
    downstream_arrow(ax, head, color="black", length=10.0, lw=1.4)
    ax.plot(head[1], head[0], marker="o", color="black",
            markersize=4, linestyle="none", zorder=12)
    ax.text(head[1] + 2, head[0] - 2, f"S{k}", color="black",
            fontsize=LABEL_FS, weight="bold",
            ha="left", va="bottom", zorder=13)
ax.set_title(rf"(c) {N_branches} branch tips (S$_1$…S$_{{{N_branches}}}$)",
             loc="left", pad=3)


# ----------------------------------------------------------------------------
# (d) Main stem split into S_N, S_{N+1}, S_{N+2}
# ----------------------------------------------------------------------------
ax = axes[1, 1]
base(ax, draw_skel=True)
for k, ch in enumerate(chains, 1):
    head = ch[-1]
    downstream_arrow(ax, head, color="black", length=10.0, lw=1.4)
    ax.plot(head[1], head[0], marker="o", color="black",
            markersize=4, linestyle="none", zorder=12)
    ax.text(head[1] + 2, head[0] - 2, f"S{k}", color="black",
            fontsize=LABEL_FS, weight="bold",
            ha="left", va="bottom", zorder=13)

SPLIT_BLUE = "#2563EB"
for k, cut in enumerate([cut_a, cut_b], 1):
    downstream_arrow(ax, cut, color=SPLIT_BLUE, length=10.0,
                     lw=1.6, zorder=14)
    ax.plot(cut[1], cut[0], marker="o", color=SPLIT_BLUE,
            markersize=5, linestyle="none", zorder=15)
    ax.text(cut[1] + 3, cut[0] - 3, f"S{N_branches + k}",
            color=SPLIT_BLUE, fontsize=LABEL_FS + 0.5, weight="bold",
            ha="left", va="bottom", zorder=16)
ax.set_title(rf"(d) Split main stem S$_{{{N_branches}}}$ into S$_{{{N_branches}}}$, "
             rf"S$_{{{N_branches+1}}}$, S$_{{{N_branches+2}}}$",
             loc="left", pad=3)


# ============================================================================
# Save
# ============================================================================
fig.subplots_adjust(left=0.015, right=0.985, top=0.93, bottom=0.02,
                    hspace=0.18, wspace=0.04)

for ext in ("png", "pdf"):
    out = os.path.join(OUT_DIR, f"Fig07_BoundaryWalk_v11.{ext}")
    fig.savefig(out, dpi=300, bbox_inches="tight", facecolor="white")
    print(f"Saved: {out}")
plt.close(fig)
