# SGVI Enrollment.csv — map asset_number → content Path for ad sync, progressives publish,
# and production log upload (same Path, Logs/ child). Fixture Dropbox logs/N is separate.
#
# CSV columns: Asset, Location, Path
#   Asset  — int or * (pooled content)
#   Path   — server URI prefix, e.g. /St_Thomas/B-Downtown/758_Bar_and_Restaurant/
#
# Identity Apply upserts this cabinet's row in Enrollment.csv (machine move or
# first onsite enroll of POG-CC + Pi). DF 4.0.1.4 ignores leftover Heltec /asset
# (Pi Identity owns the cabinet). Enrollment.local.csv is the same row and wins
# after an OTA pull of an older catalog.
# The Pi does not publish Dropbox SGVI (workshop catalog is SGVI/Enrollment.csv).
#
# asset_number 0 = unset (no enrollment lookup).

from __future__ import annotations

import csv
import os
import re
import sys
from dataclasses import dataclass
from typing import Dict, List, Optional, Union
from urllib.parse import urlparse, urlunparse

CONFIG_FILENAME = "config.ini"
DEFAULT_PI_ENROLLMENT = "/home/topbox/EtherGold/sync/Enrollment.csv"
DEFAULT_PI_ENROLLMENT_LOCAL = "/home/topbox/EtherGold/sync/Enrollment.local.csv"
LOCAL_ENROLLMENT_NAME = "Enrollment.local.csv"
ASSET_UNSET = 0


@dataclass(frozen=True)
class EnrollmentRow:
    asset: str
    location: str
    path: str


def _config_dir() -> str:
    return os.path.dirname(os.path.abspath(__file__))


def _normalize_path(path: str) -> str:
    text = (path or "").strip()
    if not text:
        return ""
    if not text.startswith("/"):
        text = "/" + text
    if not text.endswith("/"):
        text += "/"
    return text


def _normalize_base_url(base: str) -> str:
    text = (base or "").strip()
    if not text:
        return ""
    return text.rstrip("/")


def _slug_folder_name(path: str, asset_number: int) -> str:
    cleaned = _normalize_path(path).strip("/")
    if not cleaned:
        return f"asset_{asset_number}"
    leaf = cleaned.split("/")[-1]
    leaf = re.sub(r"[^\w.-]+", "_", leaf).strip("_")
    return leaf or f"asset_{asset_number}"


def resolve_enrollment_csv_path(configured: str = "") -> Optional[str]:
    """First existing enrollment file from config, env, or Pi field default."""
    candidates = []
    if configured.strip():
        p = configured.strip()
        if not os.path.isabs(p):
            p = os.path.join(_config_dir(), p)
        candidates.append(os.path.normpath(p))
    env_path = os.environ.get("ENROLLMENT_CSV", "").strip()
    if env_path:
        candidates.append(os.path.normpath(env_path))
    if sys.platform != "win32":
        candidates.append(DEFAULT_PI_ENROLLMENT)
    candidates.append(os.path.join(_config_dir(), "Enrollment.csv"))

    seen = set()
    for path in candidates:
        if path in seen:
            continue
        seen.add(path)
        if os.path.isfile(path):
            return path
    return None


def enrollment_local_path(master_csv: str = "") -> str:
    """Pi-local overlay beside the SGVI master. Survives OTA; not written to SGVI."""
    env_local = os.environ.get("ENROLLMENT_LOCAL_CSV", "").strip()
    if env_local:
        return os.path.normpath(env_local)
    master = ""
    if master_csv.strip():
        master = master_csv.strip()
        if not os.path.isabs(master):
            master = os.path.join(_config_dir(), master)
        master = os.path.normpath(master)
    else:
        found = resolve_enrollment_csv_path("")
        master = found or DEFAULT_PI_ENROLLMENT
    return os.path.join(os.path.dirname(master), LOCAL_ENROLLMENT_NAME)


def load_enrollment(csv_path: str) -> Dict[str, EnrollmentRow]:
    """Load enrollment table keyed by Asset column string."""
    rows: Dict[str, EnrollmentRow] = {}
    with open(csv_path, newline="", encoding="utf-8-sig") as handle:
        reader = csv.DictReader(handle)
        for raw in reader:
            asset = (raw.get("Asset") or "").strip()
            if not asset:
                continue
            location = (raw.get("Location") or "").strip().strip('"')
            path = _normalize_path(raw.get("Path") or "")
            if not path:
                continue
            rows[asset] = EnrollmentRow(asset=asset, location=location, path=path)
    return rows


def load_enrollment_merged(csv_path: str) -> Dict[str, EnrollmentRow]:
    """SGVI master plus Enrollment.local.csv (local asset keys win)."""
    table: Dict[str, EnrollmentRow] = {}
    if csv_path and os.path.isfile(csv_path):
        table.update(load_enrollment(csv_path))
    local = enrollment_local_path(csv_path)
    if local and os.path.isfile(local) and os.path.normpath(local) != os.path.normpath(csv_path or ""):
        table.update(load_enrollment(local))
    return table


def list_locations(csv_path: str = "") -> List[Dict[str, str]]:
    """Unique Location + Path pairs for the Identity dropdown (skip pooled *)."""
    master = resolve_enrollment_csv_path(csv_path) or csv_path
    table = load_enrollment_merged(master) if master else {}
    seen: Dict[str, Dict[str, str]] = {}
    for row in table.values():
        if row.asset == "*":
            continue
        if not row.location or not row.path:
            continue
        if row.path not in seen:
            seen[row.path] = {"location": row.location, "path": row.path}
    return sorted(seen.values(), key=lambda item: item["location"].lower())


def assets_by_number(csv_path: str = "") -> Dict[str, Dict[str, str]]:
    master = resolve_enrollment_csv_path(csv_path) or csv_path
    table = load_enrollment_merged(master) if master else {}
    out: Dict[str, Dict[str, str]] = {}
    for asset, row in table.items():
        if not asset.isdigit():
            continue
        out[asset] = {"location": row.location, "path": row.path}
    return out


def _csv_row_line(asset: str, location: str, path: str) -> str:
    loc = (location or "").strip().strip('"')
    dest = _normalize_path(path)
    return f'{asset},"""{loc}""",{dest}\n'


def _line_asset_key(line: str) -> str:
    text = (line or "").lstrip("\ufeff").strip()
    if not text or text.lower().startswith("asset"):
        return ""
    return text.split(",", 1)[0].strip().strip('"')


def upsert_enrollment_csv_file(
    csv_path: str,
    asset_number: int,
    location: str,
    path: str,
    *,
    create: bool = True,
) -> str:
    """Replace or append this asset's row in Enrollment.csv. Preserves other lines."""
    if int(asset_number) == ASSET_UNSET:
        raise ValueError("asset unset")
    loc = (location or "").strip().strip('"')
    dest = _normalize_path(path)
    if not loc or not dest:
        raise ValueError("location and path required")
    target = os.path.normpath(csv_path)
    asset_s = str(int(asset_number))
    new_line = _csv_row_line(asset_s, loc, dest)
    os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
    if not os.path.isfile(target):
        if not create:
            raise FileNotFoundError(target)
        with open(target, "w", encoding="utf-8", newline="\n") as handle:
            handle.write("Asset,Location,Path\n")
            handle.write(new_line)
        return target
    with open(target, encoding="utf-8-sig") as handle:
        raw = handle.read()
    lines = raw.splitlines()
    out: List[str] = []
    found = False
    for line in lines:
        key = _line_asset_key(line)
        if key == asset_s:
            if not found:
                out.append(new_line.rstrip("\n"))
                found = True
            continue
        out.append(line)
    if not found:
        out.append(new_line.rstrip("\n"))
    tmp = target + ".tmp"
    with open(tmp, "w", encoding="utf-8", newline="\n") as handle:
        handle.write("\n".join(out) + "\n")
    os.replace(tmp, target)
    return target


def upsert_enrollment_row(
    asset_number: int,
    location: str,
    path: str,
    master_csv: str = "",
) -> str:
    """Machine move: upsert this asset in Enrollment.csv and Enrollment.local.csv.

    Local overlay still wins after an OTA pull of an older SGVI catalog.
    Does not publish Dropbox SGVI from the Pi.
    """
    if int(asset_number) == ASSET_UNSET:
        raise ValueError("asset unset")
    loc = (location or "").strip().strip('"')
    dest = _normalize_path(path)
    if not loc or not dest:
        raise ValueError("location and path required")
    master = (master_csv or "").strip()
    if master and not os.path.isabs(master):
        master = os.path.join(_config_dir(), master)
    if not (master and os.path.isfile(master)):
        master = resolve_enrollment_csv_path(master_csv) or master
    local = enrollment_local_path(master or master_csv)
    if master:
        upsert_enrollment_csv_file(master, asset_number, loc, dest, create=True)
    upsert_enrollment_csv_file(local, asset_number, loc, dest, create=True)
    return local


def append_local_enrollment_row(
    asset_number: int,
    location: str,
    path: str,
    master_csv: str = "",
) -> str:
    """Compatibility alias — machine move upserts Enrollment.csv, not append-only."""
    return upsert_enrollment_row(asset_number, location, path, master_csv=master_csv)


def upsert_enrollment_from_identity(
    asset_number: int,
    location: str = "",
    path: str = "",
    master_csv: str = "",
) -> Optional[str]:
    """Onsite Identity Apply: write the row when location+path are set.

    Returns the local overlay path, or None when asset/location/path are incomplete.
    """
    if int(asset_number) == ASSET_UNSET:
        return None
    loc = (location or "").strip().strip('"')
    dest = _normalize_path(path)
    if not loc or not dest:
        return None
    return upsert_enrollment_row(int(asset_number), loc, dest, master_csv=master_csv)


def lookup_enrollment(
    asset_number: int,
    csv_path: Optional[str] = None,
    *,
    table: Optional[Dict[str, EnrollmentRow]] = None,
) -> Optional[EnrollmentRow]:
    if asset_number == ASSET_UNSET:
        return None
    if table is None:
        if not csv_path:
            return None
        table = load_enrollment_merged(csv_path)
    return table.get(str(asset_number))


def _apply_content_port(base: str, download_port: int) -> str:
    """
    pog-atlas.com directory listings are served on :51709, not 443/80.
    If the configured base URL has no explicit port, append download_url_port.
    """
    text = (base or "").strip()
    if not text:
        return ""
    if "://" not in text:
        text = "http://" + text
    parsed = urlparse(text)
    if parsed.port is not None:
        return _normalize_base_url(text)
    host = parsed.hostname
    if not host:
        return _normalize_base_url(text)
    port = int(download_port)
    scheme = parsed.scheme or "http"
    if port == 443 and scheme == "https":
        netloc = host
    elif port == 80 and scheme == "http":
        netloc = host
    else:
        netloc = f"{host}:{port}"
    path = (parsed.path or "").rstrip("/")
    rebuilt = urlunparse((scheme, netloc, path, "", "", ""))
    return _normalize_base_url(rebuilt)


def build_content_base_url(
    content_base_url: str,
    download_ip: str,
    download_port: int,
) -> str:
    explicit = (content_base_url or "").strip()
    if explicit:
        return _apply_content_port(explicit, download_port)
    ip = (download_ip or "").strip()
    if not ip:
        return ""
    port = int(download_port)
    if port == 443:
        return _apply_content_port(f"https://{ip}", port)
    if port == 80:
        return _apply_content_port(f"http://{ip}", port)
    return f"http://{ip}:{port}"


def build_content_sync_url(base_url: str, enrollment_path: str) -> str:
    base = _normalize_base_url(base_url)
    path = _normalize_path(enrollment_path)
    if not base or not path:
        return ""
    return f"{base}{path}"


def build_progressives_publish_url(content_sync_url: str) -> str:
    url = (content_sync_url or "").strip()
    if not url:
        return ""
    if not url.endswith("/"):
        url += "/"
    return f"{url}progressives.csv"


def resolve_content_sync_targets(
    asset_number: int,
    *,
    enrollment_csv: str = "",
    content_base_url: str = "",
    content_local_folder: str = "",
    download_ip: str = "",
    download_port: int = 80,
    enrollment_path: str = "",
    enrollment_location: str = "",
) -> Dict[str, Optional[str]]:
    """
    Resolve sync.py URL + local ads folder from asset_number and config.

    Returns dict with keys: enrollment_csv, row, enrollment_path, content_base_url,
    content_sync_url, content_local_folder, progressives_publish_url, error.
    Identity Apply / config.ini enrollment_path is enough onsite before the
    asset appears in the SGVI catalog.
    """
    result: Dict[str, Optional[str]] = {
        "enrollment_csv": None,
        "row": None,
        "enrollment_path": None,
        "content_base_url": None,
        "content_sync_url": None,
        "content_local_folder": None,
        "progressives_publish_url": None,
        "error": None,
    }

    if asset_number == ASSET_UNSET:
        result["error"] = "asset_number is unset (0)"
        return result

    csv_path = resolve_enrollment_csv_path(enrollment_csv)
    if csv_path:
        result["enrollment_csv"] = csv_path
        row = lookup_enrollment(asset_number, csv_path)
    else:
        row = None

    identity_path = _normalize_path(enrollment_path)
    identity_loc = (enrollment_location or "").strip().strip('"')
    if row is not None:
        use_path = row.path
        result["row"] = f"{row.location} -> {row.path}"
    elif identity_path:
        use_path = identity_path
        result["row"] = f"{identity_loc or 'identity'} -> {identity_path}"
    elif csv_path:
        result["error"] = f"asset {asset_number} not found in {csv_path}"
        return result
    else:
        result["error"] = "Enrollment.csv not found (set enrollment_csv in config.ini)"
        return result
    result["enrollment_path"] = use_path

    base = build_content_base_url(content_base_url, download_ip, download_port)
    if not base:
        result["error"] = "content_base_url / download_url_ip:port not configured"
        return result
    result["content_base_url"] = base

    sync_url = build_content_sync_url(base, use_path)
    result["content_sync_url"] = sync_url
    result["progressives_publish_url"] = build_progressives_publish_url(sync_url)

    folder = (content_local_folder or "").strip()
    if not folder:
        folder = _slug_folder_name(use_path, asset_number)
    result["content_local_folder"] = folder
    return result


def read_config_content_settings(config) -> Dict[str, Union[str, int]]:
    """Read enrollment/content keys from ExtendedConfigParser TOPBOX section."""
    asset = ASSET_UNSET
    if config.has_option("TOPBOX", "ASSET_NUMBER"):
        asset = config.getint("TOPBOX", "ASSET_NUMBER")
    elif config.has_option("TOPBOX", "POG_ASSET_NUMBER"):
        asset = config.getint("TOPBOX", "POG_ASSET_NUMBER")

    enrollment_csv = ""
    if config.has_option("TOPBOX", "ENROLLMENT_CSV"):
        enrollment_csv = config.get("TOPBOX", "ENROLLMENT_CSV")

    content_base_url = ""
    if config.has_option("TOPBOX", "CONTENT_BASE_URL"):
        content_base_url = config.get("TOPBOX", "CONTENT_BASE_URL")

    content_local_folder = ""
    if config.has_option("TOPBOX", "CONTENT_LOCAL_FOLDER"):
        content_local_folder = config.get("TOPBOX", "CONTENT_LOCAL_FOLDER")

    download_ip = config.get("TOPBOX", "DOWNLOAD_URL_IP")
    download_port = config.getint("TOPBOX", "DOWNLOAD_URL_PORT")

    enrollment_path = ""
    if config.has_option("TOPBOX", "ENROLLMENT_PATH"):
        enrollment_path = config.get("TOPBOX", "ENROLLMENT_PATH")
    enrollment_location = ""
    if config.has_option("TOPBOX", "ENROLLMENT_LOCATION"):
        enrollment_location = config.get("TOPBOX", "ENROLLMENT_LOCATION")

    return {
        "asset_number": asset,
        "enrollment_csv": enrollment_csv,
        "content_base_url": content_base_url,
        "content_local_folder": content_local_folder,
        "download_ip": download_ip,
        "download_port": download_port,
        "enrollment_path": enrollment_path,
        "enrollment_location": enrollment_location,
    }


def _main() -> None:
    import argparse
    import json

    parser = argparse.ArgumentParser(description="Enrollment.csv lookup (SGVI assets)")
    parser.add_argument("--asset", type=int, required=True, help="Asset number")
    parser.add_argument("--csv", default="", help="Path to Enrollment.csv")
    parser.add_argument("--base-url", default="", help="Content base URL override")
    parser.add_argument("--ip", default="pog-atlas.com")
    parser.add_argument("--port", type=int, default=51709)
    args = parser.parse_args()

    out = resolve_content_sync_targets(
        args.asset,
        enrollment_csv=args.csv,
        content_base_url=args.base_url,
        download_ip=args.ip,
        download_port=args.port,
    )
    print(json.dumps(out, indent=2))


if __name__ == "__main__":
    _main()
