"""Build an offline APP image and descendant transaction; never starts services."""
import copy
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import time

import connection
from source_prepare import inspect_tree

RUNTIME = Path("/workspace/contextra/runtime")
DATA = Path("/workspace/contextra/data")
TOOLS = RUNTIME / "deploy-tools/provider-20260927-r01"
sys.path.insert(0, str(TOOLS))


def normalize(value):
    value = copy.deepcopy(value)
    for service in value["services"].values():
        service.pop("build", None)
        service["volumes"] = sorted(service.get("volumes", []), key=lambda x: x["target"])
    return value


def apply(source, expected_active, confirmed=False):
    from deployment_kit import site, release, compose_snapshot
    from deployment_kit.core import sha256, write_json, atomic_write, lock
    if os.geteuid() != 0 or not confirmed:
        raise ValueError("sudo, --yes and expected active SHA256 required")
    if shutil.disk_usage(RUNTIME).free < 17*1024**3:
        raise ValueError("Keep 15GiB root reserve plus bounded APP build space")
    source = Path(source)
    if (not source.is_absolute() or not source.is_relative_to("/workspace/contextra/source")
            or any(p.is_symlink() for p in (source,*source.parents))):
        raise ValueError("Approved source path required")
    files = inspect_tree(source / "app")
    state = RUNTIME / "state"
    def command(args, timeout=45):
        p = subprocess.run(args, check=True, capture_output=True, text=True, timeout=timeout,
            env=compose_snapshot.command_environment())
        return p.stdout
    with lock(state):
        active_path = state / "active.json"
        if sha256(active_path) != expected_active:
            raise ValueError("Active deployment changed; inspect again")
        if command(["docker","ps","--filter","label=com.docker.compose.project=contextra-icerti","--format","{{.ID}}"] ).strip():
            raise ValueError("Run ct stop before source apply")
        active = json.loads(active_path.read_text())
        if active.get("incremental_patch",{}).get("source_head") != "0f172d948cacf1e4444c40ecc09d583f29644c74":
            raise ValueError("Different image/native baseline; review compatibility first")
        if json.loads((state / "inflight.json").read_text()).get("status") != "DONE":
            raise ValueError("Another transaction is in progress")
        old_site_hash = sha256(state / "site.json")
        current = site.load(state / "site.json")
        if sha256(state / "site.json") != old_site_hash:raise ValueError("Site changed while reading")
        old_compose = connection.active_compose()
        if old_compose.parent.name != active["transaction"]:
            raise ValueError("Active/unit transaction mismatch")
        old = json.loads(old_compose.read_text())
        args,_ = release.compose_args(current, RUNTIME / "releases" / active["release_id"])
        persistent = json.loads(command(["docker",*args,"config","--format","json"]))
        if normalize(old) != normalize(persistent):
            raise ValueError("Persistent Compose drift")
        name = "offline-source-" + time.strftime("%Y%m%dT%H%M%S")
        tx = state / "transactions" / name
        tx.mkdir(mode=0o700)
        snapshot = RUNTIME / "patches" / name / "app"
        snapshot.parent.mkdir(parents=True, mode=0o700)
        shutil.copytree(source / "app", snapshot, ignore=shutil.ignore_patterns("__pycache__"))
        if inspect_tree(snapshot) != files or inspect_tree(source / "app") != files:
            raise ValueError("Source changed while freezing; no active files changed")
        for path in [snapshot,*snapshot.rglob("*")]:
            path.chmod(0o755 if path.is_dir() else 0o644)
        base = old["services"]["analytics"]["image"]
        base_info = json.loads(command(["docker","image","inspect",base]))[0]
        if base_info["Id"] != active["images"]["analytics"]:
            raise ValueError("Current image identity changed")
        if base_info["Config"].get("User","") not in ("","root","0","0:0"):
            raise ValueError("Non-root APP baseline requires separate review")
        base_tag = "contextra-offline-base:" + base_info["Id"].split(":")[1][:16]
        command(["docker","tag",base_info["Id"],base_tag])
        dockerfile = snapshot.parent / "Dockerfile"
        dockerfile.write_text("FROM " + base_tag + "\nRUN rm -rf /opt/contextra/app\nCOPY app /opt/contextra/app\n")
        new_tag = "contextra-analytics:" + name
        env = dict(compose_snapshot.command_environment(),DOCKER_BUILDKIT="0")
        built = subprocess.run(["docker","build","--pull=false","--network=none","--memory=3g",
            "--cpu-period=100000","--cpu-quota=200000","-t",new_tag,str(snapshot.parent)],
            check=True,capture_output=True,text=True,timeout=240,env=env)
        atomic_write(tx/"build.log",built.stdout + built.stderr,new=True)
        new_id = json.loads(command(["docker","image","inspect",new_tag]))[0]["Id"]
        doc = copy.deepcopy(old)
        doc["services"]["analytics"]["image"] = new_id
        doc["services"]["analytics"].setdefault("labels",{})[release.TRANSACTION_LABEL] = name
        ids = {k:v["image"] for k,v in doc["services"].items()}
        release.validate_rendered(doc,current,ids)
        compose_snapshot.seal(doc,tx / "compose.json",current["project"])
        overlay = DATA / "site" / (name+".compose.json")
        overlay_doc = copy.deepcopy(doc)
        overlay_doc.pop("name",None)
        write_json(overlay,overlay_doc,new=True)
        newsite = copy.deepcopy(current)
        previous_overlay = active.get("offline_source",{}).get("overlay")
        if previous_overlay:
            if newsite["compose_files"][-1] != previous_overlay:
                raise ValueError("Another overlay follows the source overlay")
            newsite["compose_files"][-1] = str(overlay)
        else:
            newsite["compose_files"].append(str(overlay))
        args,inputs = release.compose_args(newsite,RUNTIME/"releases"/active["release_id"])
        if normalize(json.loads(command(["docker",*args,"config","--format","json"]))) != normalize(doc):
            raise ValueError("New persistent Compose differs")
        unit_before = connection.UNIT.read_bytes()
        oldpart = "/transactions/"+active["transaction"]+"/"
        if unit_before.decode().count(oldpart) != 3:
            raise ValueError("Unexpected service layout")
        write_json(tx/"previous-active.json",active,new=True)
        write_json(tx/"previous-site.json",current,new=True)
        atomic_write(tx/"previous-unit",unit_before,new=True)
        write_json(tx/"source-manifest.json",dict(files=files,worktree=str(source)),new=True)
        unit_hash = __import__("hashlib").sha256(unit_before).hexdigest()
        write_json(state/"inflight.json",dict(status="APPLYING",stage="SOURCE_SETTINGS",transaction=name))
        write_json(state/"site.json",newsite,expected=old_site_hash)
        atomic_write(connection.UNIT,unit_before.decode().replace(oldpart,"/transactions/"+name+"/"),expected=unit_hash,mode=0o644)
        updated = copy.deepcopy(active)
        updated.update(transaction=name,images=ids,compose_sha256=sha256(tx/"compose.json"),normalized_sha256=sha256(tx/"compose.normalized.json"),
            site_sha256=sha256(state/"site.json"),config_inputs=inputs,bound_files=compose_snapshot.file_bindings(doc))
        updated["offline_source"] = dict(worktree=str(source),snapshot=str(snapshot),files=files,overlay=str(overlay),
            parent_transaction=active["transaction"],base_image=base_info["Id"],app_image_tag=new_tag,native_unchanged=True)
        updated.setdefault("acceptance",{})["SOURCE_APP_IMAGE"]="PENDING_RUNTIME_CHECK"
        write_json(active_path,updated,expected=expected_active)
        write_json(tx/"accepted.json",updated,new=True)
        write_json(state/"inflight.json",dict(status="DONE",stage="SOURCE_PREPARED_RUNTIME_STOPPED",transaction=name))
        import field_runtime
        field_runtime.remember_baseline()
        command(["systemctl","daemon-reload"])
        print(json.dumps(dict(status="APPLIED_SERVICES_STOPPED",transaction=name,files=len(files),image=ids["analytics"],source=str(source))))
