"""Explicit offline network transition. No OS NIC changes, login or auto Start."""
import copy
import ipaddress
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import sys

import connection
from network_prepare import RUNTIME, DATA, VENDOR, sha
from apply_source import normalize


def firewall_script(ai_ip, web_ip, port):
    ipaddress.IPv4Address(ai_ip)
    peers = [web_ip] if isinstance(web_ip, str) else list(web_ip)
    if not 1 <= len(peers) <= 32: raise ValueError('Explicit Redis allowlist required')
    peers = sorted({str(ipaddress.IPv4Address(value)) for value in peers})
    if not 1024<=int(port)<=65535:raise ValueError("Redis port")
    allow = '\n'.join("run('-A',C,'-s',"+repr(value+'/32')+",'-j','RETURN')" for value in peers)
    return f'''#!/usr/bin/python3
import shlex,subprocess
I='/usr/sbin/iptables'
C='CTX-ICERTI-REDIS'
def run(*args,check=True):
    return subprocess.run([I,'-w',*args],check=check,capture_output=True,text=True,timeout=15)
existing=run('-S',C,check=False)
if existing.returncode==0:
    managed=[shlex.split(row) for row in existing.stdout.splitlines() if row.startswith('-A ')]
    if (not 2<=len(managed)<=33 or any(len(row)!=6 or row[:3]!=['-A',C,'-s'] or row[4:]!=['-j','RETURN'] for row in managed[:-1])
            or managed[-1]!=['-A',C,'-j','DROP']):raise RuntimeError('Unexpected managed chain rules; no mutation')
for chain in ('DOCKER-USER','INPUT'):
    rows=run('-S',chain).stdout.splitlines()
    for row in rows:
        parts=shlex.split(row)
        if '-j' in parts and parts[parts.index('-j')+1]==C:
            if parts[0]!='-A' or parts[1]!=chain:raise RuntimeError('Unknown managed rule')
            run('-D',*parts[1:])
if run('-S',C,check=False).returncode:run('-N',C)
run('-F',C)
{allow}
run('-A',C,'-j','DROP')
run('-I','DOCKER-USER','1','-p','tcp','-m','conntrack','--ctdir','ORIGINAL','--ctorigdst','{ai_ip}','--ctorigdstport','{port}','-j',C)
run('-I','INPUT','1','-d','{ai_ip}','-p','tcp','--dport','{port}','-j',C)
'''


def apply(stage,confirmed=False):
    if os.geteuid()!=0 or not confirmed:raise ValueError("sudo and --yes required")
    if shutil.disk_usage(RUNTIME).free < 15*1024**3:raise ValueError("Root free space below 15GiB")
    stage=Path(stage)
    if not stage.is_relative_to(DATA/"site") or any(p.is_symlink() for p in (stage,*stage.parents)):
        raise ValueError("Staged site path required")
    record=json.loads((stage/"record.json").read_text())
    desired=record["desired"]
    sys.path.insert(0,str(VENDOR))
    from tools.secure_bridge_tls import verify
    from control_sidecar.config import Config
    verify(stage/"tls/control-tls.key",stage/"tls/control-tls.crt",stage/"bridge/manager-ca.crt",desired["ai_ip"])
    Config.from_env(json.loads((stage/"host-enrollment.json").read_text()))
    if (stage/"tls/control-tls.crt").read_bytes()!=(stage/"bridge/control-tls.crt").read_bytes():
        raise ValueError("Gateway and sidecar certificate differ")
    sys.path.insert(0,str(RUNTIME/"deploy-tools/provider-20260927-r01"))
    from deployment_kit import site,release,compose_snapshot
    from deployment_kit.core import write_json,atomic_write,sha256,lock
    def run(args,timeout=35):
        return subprocess.run(args,check=True,capture_output=True,text=True,timeout=timeout,
            env=compose_snapshot.command_environment()).stdout
    interfaces=json.loads(run(["ip","-j","-4","address","show"]))
    if desired["ai_ip"] not in {a["local"] for d in interfaces for a in d.get("addr_info",[])}:
        raise ValueError("Assign the new OS IP first on the physical console; no NIC changes made")
    with lock(RUNTIME/"state"):
        state=RUNTIME/"state"
        active_path=state/"active.json"
        if sha(active_path)!=record["base_active_sha256"]:raise ValueError("Active changed since network prepare")
        if run(["docker","ps","--filter","label=com.docker.compose.project=contextra-icerti","--format","{{.ID}}"] ).strip():
            raise ValueError("ct stop is required before network apply")
        active=json.loads(active_path.read_text())
        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_path=connection.active_compose()
        args,_=release.compose_args(current,RUNTIME/"releases"/active["release_id"])
        if normalize(json.loads(run(["docker",*args,"config","--format","json"])))!=normalize(json.loads(old_path.read_text())):
            raise ValueError("Persistent settings changed")
        script=DATA/"site/redis-access.sh"
        if sha(script)!=record["script_sha256"]:raise ValueError("Redis access policy changed")
        name=stage.name
        tx=state/"transactions"/name
        tx.mkdir(mode=0o700)
        doc=json.loads((stage/"compose.proposed.json").read_text())
        for service in ("analytics","ai-control-sidecar","ai-gateway","redis"):
            doc["services"][service].setdefault("labels",{})[release.TRANSACTION_LABEL]=name
        site_next=copy.deepcopy(current)
        site_next["ai_ip"]=desired["ai_ip"]
        release.validate_rendered(doc,site_next,active["images"])
        compose_snapshot.seal(doc,tx/"compose.json",current["project"])
        overlay=stage/"network.compose.json"
        overlay_doc=copy.deepcopy(doc);overlay_doc.pop("name",None)
        write_json(overlay,overlay_doc,new=True)
        old_overlay=active.get("offline_network",{}).get("overlay")
        if old_overlay:
            if site_next["compose_files"][-1]!=old_overlay:raise ValueError("Later overlay needs review")
            site_next["compose_files"][-1]=str(overlay)
        else:site_next["compose_files"].append(str(overlay))
        args,inputs=release.compose_args(site_next,RUNTIME/"releases"/active["release_id"])
        if normalize(json.loads(run(["docker",*args,"config","--format","json"])))!=normalize(doc):raise ValueError("Network Compose roundtrip differs")
        unit_before=connection.UNIT.read_bytes()
        oldpart="/transactions/"+active["transaction"]+"/"
        if unit_before.decode().count(oldpart)!=3:raise ValueError("Service layout changed")
        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)
        atomic_write(tx/"previous-redis-access",script.read_bytes(),new=True)
        write_json(state/"inflight.json",dict(status="APPLYING",stage="NETWORK_FIREWALL",transaction=name))
        proposed_script=firewall_script(desired["ai_ip"],desired["web_ip"],desired["redis_port"])
        atomic_write(script,proposed_script,expected=record["script_sha256"],mode=0o700)
        run([str(script)])
        write_json(state/"site.json",site_next,expected=old_site_hash)
        atomic_write(connection.UNIT,unit_before.decode().replace(oldpart,"/transactions/"+name+"/"),expected=__import__("hashlib").sha256(unit_before).hexdigest(),mode=0o644)
        updated=copy.deepcopy(active)
        updated.update(transaction=name,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_network"]=dict(stage=str(stage),overlay=str(overlay),parent_transaction=active["transaction"],desired=desired)
        updated.setdefault("acceptance",{})["OFFLINE_NETWORK"]="PENDING_START_AND_WEB_CHECK"
        write_json(active_path,updated,expected=record["base_active_sha256"])
        write_json(tx/"accepted.json",updated,new=True)
        write_json(state/"inflight.json",dict(status="DONE",stage="NETWORK_APPLIED_STOPPED",transaction=name))
        run(["systemctl","daemon-reload"])
        print(json.dumps(dict(status="NETWORK_APPLIED_SERVICES_STOPPED",transaction=name,
            next="ct start; confirm CA/WSS/Redis with the Web operator; no automatic camera Start")))
