"""Explicit offline deployment transactions. A symlink switch is NOT a container rollout.

Private rendered Compose snapshots retain the exact env/overlay result for rollback.
No live stream Stop/reap is guessed: affected containers must already be stopped.
"""
from __future__ import annotations
import copy
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import tempfile
import time
import uuid
from . import bundle, host, storage, compose_snapshot
from .site import validate
from .image_identity import resolve as resolve_image
from .core import absolute, identifier, relative, require, KitError, read_json, write_json, atomic_write, sha256, canonical, lock, now, docker, run, image_id

TRANSACTION_LABEL = 'io.contextra.deployment.transaction'


def verify_failed_ownership(rows, record):
    """A failed first install may resume only its own stopped Compose resources."""
    require(record and record.get('stage') in ('APPLY', 'HEALTH', 'ACTIVATE'),
            'EXISTING_PROJECT', '최초 설치의 소유 원장이 없는 컨테이너를 인수하지 않습니다.')
    images = record.get('intended_images', {})
    for row in rows:
        require(row.get('transaction') == record.get('transaction')
                and row['service'] in images and row['image_id'] == images[row['service']],
                'FAILED_RESOURCE_OWNERSHIP', '실패 배포의 label/image와 실제 자원이 다릅니다. 자동 인수/삭제하지 않습니다.')
    if record.get('reviewed_containers') is not None:
        require(sorted(r['id'] for r in rows) == sorted(record['reviewed_containers']),
                'FAILED_RESOURCE_CHANGED', '복구 검토 이후 컨테이너가 변경됐습니다.')


def state_dir(site):
    return Path(site['install_root'])/'state'


def active(site):
    root=Path(site['install_root']); path=state_dir(site)/'active.json'
    link=root/'current'
    if not path.exists():
        require(not link.exists() and not link.is_symlink(),'CURRENT_MISMATCH','active 기록 없는 current 링크입니다. 복구 검토가 필요합니다.')
        return None
    a=read_json(path); identifier(a['release_id']); identifier(a['transaction'])
    require(link.is_symlink() and os.readlink(link)=='releases/'+a['release_id'],
            'CURRENT_MISMATCH','current와 active 원장이 다릅니다. 배포를 반복하지 마세요.')
    return a


def switch_current(root, release_id, *, previous=None):
    root=absolute(root,no_symlinks=True); identifier(release_id)
    target=root/'releases'/release_id
    require(target.is_dir() and not target.is_symlink(),'RELEASE_MISSING','활성화할 release가 없습니다.')
    link=root/'current'
    if previous is None:
        require(not link.exists() and not link.is_symlink(),'CURRENT_MISMATCH','기존 current를 덮어쓰지 않습니다.')
    else:
        require(link.is_symlink() and os.readlink(link)=='releases/'+previous,
                'CURRENT_MISMATCH','배포 중 활성 release가 바뀌었습니다.')
    temp=root/('.current-'+uuid.uuid4().hex)
    try:
        os.symlink('releases/'+release_id,temp); os.replace(temp,link)
        fd=os.open(root,os.O_RDONLY|os.O_DIRECTORY)
        try: os.fsync(fd)
        finally: os.close(fd)
    finally:
        if temp.is_symlink(): temp.unlink()


def snapshot_path(site, a):
    p=state_dir(site)/'transactions'/identifier(a['transaction'])/'compose.json'
    require(p.is_file() and sha256(p)==a['compose_sha256'],'SNAPSHOT_CHANGED','private Compose snapshot이 바뀌었습니다.')
    return absolute(p,no_symlinks=True)


def containers(project):
    identifier(project)
    _,raw=docker('ps','-aq','--filter','label=com.docker.compose.project='+project)
    ids=raw.split()
    require(all(re.fullmatch(r'[0-9a-f]{12,64}',x) for x in ids),'CONTAINER_ID','컨테이너 식별 실패')
    if not ids: return []
    _,raw=docker('inspect','--format','{{json .}}',*ids,limit=8*1024**2)
    result=[]
    for line in raw.splitlines():
        d=json.loads(line); labels=d['Config'].get('Labels') or {}; s=d['State']
        require(labels.get('com.docker.compose.project')==project,'PROJECT_SCOPE','다른 project 컨테이너가 섞여 있습니다.')
        ports=[int(v['HostPort']) for values in (d['NetworkSettings'].get('Ports') or {}).values() for v in (values or []) if v.get('HostPort')]
        result.append({'id':d['Id'],'service':labels.get('com.docker.compose.service'),
            'image_id':d['Image'],'running':bool(s.get('Running')),'status':s.get('Status'),
            'exit_code':s.get('ExitCode'),'health':s.get('Health',{}).get('Status'),
            'ports':ports,'transaction':labels.get(TRANSACTION_LABEL)})
    return result


def check_quiescence(rows, services):
    busy=[x['service'] for x in rows if x['service'] in services and (x['running'] or x.get('status') in ('paused','restarting','removing'))]
    require(not busy,'MAINTENANCE_REQUIRED',
            '변경 대상 컨테이너가 실행 중입니다. 기존 Runtime 도구로 채널 Stop/reap 및 writer 정리를 먼저 완료하세요.',
            'deployctl guide maintenance ; 실제 상태 확인 후 같은 update 명령 재실행')


def compose_args(site, root, extra=None):
    args=['compose','--project-name',site['project'],'--project-directory',str(root/'source')]
    hashes={}
    for path in site['env_files']:
        p=absolute(path,no_symlinks=True)
        require(p.is_file() and not p.stat().st_mode & 0o077,'PRIVATE_PERMISSIONS','private env는 소유자 전용 권한이어야 합니다.')
        args+=['--env-file',str(p)]; hashes[str(p)]=sha256(p)
    for path in site['compose_files']:
        p=absolute(path,no_symlinks=True) if Path(path).is_absolute() else absolute(root/relative(path),no_symlinks=True)
        require(p.is_file(),'COMPOSE_LAYER_MISSING','등록된 Compose 계층 파일이 없습니다. 생략하지 않습니다.')
        args+=['-f',str(p)]; hashes[str(p)]=sha256(p)
    if extra is not None: args+=['-f',str(extra)]
    return args,hashes


def validate_rendered(doc, site, images):
    services=doc.get('services',{})
    require(set(services)==set(site['services']), 'COMPOSE_SERVICE_SCOPE', '최종 Compose 서비스 목록이 승인된 site 목록과 다릅니다.')
    for name,cfg in services.items():
        require(cfg.get('image')==images[name],'IMAGE_RENDER_MISMATCH','최종 image가 승인된 immutable ID와 다릅니다.')
        require(not cfg.get('privileged') and cfg.get('network_mode')!='host' and cfg.get('pid')!='host',
                'COMPOSE_ISOLATION','host network/PID 또는 privileged profile은 별도 검토 대상입니다.')
        require(not any(x in cfg for x in ('build','develop','include')), 'OFFLINE_COMPOSE','최종 배포에 build/develop/include를 남기지 않습니다.')
        for v in cfg.get('volumes',[]):
            require(isinstance(v,dict),'COMPOSE_NORMALIZATION','Compose JSON 정규화가 필요합니다.')
            if v.get('type')=='bind':
                src=absolute(v['source'],no_symlinks=True); dst=v['target']
                require(not (dst in ('/','/opt','/opt/contextra','/opt/contextra/app','/usr/local/bin/entrypoint.sh')
                                 or dst.startswith('/opt/contextra/app/')),
                        'CODE_BIND_OVERRIDE','실행 코드는 image가 소유합니다. host source로 가리지 마세요.')
                require(src.exists(),'BIND_MISSING','host mount 원본이 없습니다. 빈 폴더를 자동 생성하지 않습니다.')
                require(dst not in ('/var/run/docker.sock','/run/docker.sock') and str(src)!='/var/run/docker.sock',
                        'DOCKER_SOCKET_EXPOSED','Runtime에 Docker socket을 넣지 않습니다.')
                if src.is_relative_to(Path(site['install_root'])/'releases'):
                    require(v.get('read_only') is True,'MUTABLE_RELEASE_MOUNT','release 파일 mount는 읽기 전용이어야 합니다.')
        # Preserve env values and auth/media policy. Only path correctness is checked here.
    mounts={v['target']:v for v in services['analytics'].get('volumes',[]) if v.get('type')=='bind'}
    for key in ('runtime','artifacts'):
        e=site['storage'][key]; v=mounts.get(e['container'])
        require(v is not None and Path(v['source'])==Path(e['physical']),
                'STORAGE_RENDER_MISMATCH',f'{key}: storage-map과 private Compose의 host source를 일치시키세요.')
    model_root=Path(site['storage']['models']['physical'])
    repository = mounts.get('/opt/contextra/models/triton')
    require(repository is not None and repository.get('read_only') is True,
            'MODEL_REPOSITORY_SCOPE', '완전한 활성 Triton repository를 읽기 전용 mount하세요.')
    require(not any(t.startswith('/opt/contextra/models/triton/') for t in mounts),
            'MODEL_REPOSITORY_SCOPE', '개별 모델 mount로 활성 repository를 덮어쓰지 마세요.')
    repo_source = absolute(repository['source'], no_symlinks=True)
    require(repo_source.is_relative_to(model_root), 'MODEL_REPOSITORY_SCOPE', '승인된 모델 저장소만 사용하세요.')
    visible = {p.name for p in repo_source.iterdir() if p.is_dir() or p.is_symlink()}
    require(visible == set(site['required_models']), 'MODEL_EXPOSURE_MISMATCH',
            '활성 repository의 모델 목록과 required_models가 다릅니다. 후보는 별도 경로에 보관하세요.')
    for name in site['required_models']:
        target='/opt/contextra/models/triton/'+name
        match=mounts.get(target)
        if match is None:
            parent=mounts.get('/opt/contextra/models/triton') or mounts.get('/opt/contextra/models')
            require(parent is not None,'MODEL_MOUNT_MISSING',f'{name}: 모델 mount가 없습니다.')
            suffix=Path(target).relative_to(parent['target'])
            p=Path(parent['source'])/suffix
        else: p=Path(match['source'])
        require(p.is_relative_to(model_root) and (p/'config.pbtxt').is_file(),
                'MODEL_MOUNT_MISSING',f'{name}: 승인된 model storage와 config를 확인하세요.')
        absolute(p,no_symlinks=True)
    artifact_target=site['storage']['artifacts']['container']
    require(not any(t.startswith(artifact_target+'/') for t in mounts),
            'ARTIFACT_SPLIT_MOUNT','Artifact .staging과 공개 경로를 별도 mount로 나누지 않습니다.')
    return doc


def model_receipts(doc, site, inv, requirements=None):
    mounts={v['target']:v for v in doc['services']['analytics'].get('volumes',[]) if v.get('type')=='bind'}
    approved={}
    for name in site['required_models']:
        target='/opt/contextra/models/triton/'+name
        parent=mounts.get(target) or mounts.get('/opt/contextra/models/triton') or mounts.get('/opt/contextra/models')
        p=absolute(Path(parent['source'])/Path(target).relative_to(parent['target']),no_symlinks=True)
        receipt=read_json(p/'deployment-manifest.json')
        require(receipt.get('status')=='CONTRACT_PASS' and isinstance(receipt.get('smoke'),dict)
                and receipt['smoke'].get('shape_dtype_finite')=='PASS',
                'MODEL_RECEIPT_REQUIRED','대상 GPU에서 검증한 model install receipt가 필요합니다.')
        require(receipt['profile']['name']==name and sha256(p/'1/model.plan')==receipt['engine_sha256']
                and sha256(p/'config.pbtxt')==receipt['config_sha256'],'MODEL_HASH','model 파일이 검증 receipt와 다릅니다.')
        require(receipt.get('gpu','').split(',')[0].strip() in [g['name'] for g in inv['gpu']],
                'MODEL_GPU_MISMATCH','A6000 등 다른 GPU용 엔진을 자동 재사용하지 않습니다. 대상 GPU에서 변환/검증하세요.')
        if requirements is not None:
            version=requirements.get('tensorrt_version')
            require(version and receipt.get('engine',{}).get('tensorrt_version')==version,
                    'MODEL_TRT_MISMATCH','engine 검증 TensorRT 버전과 승인된 runtime 버전이 다릅니다.')
        approved[name]=dict(engine_sha256=receipt['engine_sha256'],config_sha256=receipt['config_sha256'],
                            receipt_sha256=sha256(p/'deployment-manifest.json'))
    return approved


def render(site, root, images, tx):
    extra=tx/'image-pins.json'
    write_json(extra,{'services':{k:{'image':v,'pull_policy':'never'} for k,v in images.items()}},new=True)
    args,hashes=compose_args(site,root,extra)
    _,raw=docker(*args,'config','--format','json',*site['services'],timeout=30,limit=8*1024**2,
                  environment=compose_snapshot.command_environment())
    doc=json.loads(raw)
    # Build contexts may be present in source Compose; --no-build is also enforced at apply.
    for c in doc.get('services',{}).values(): c.pop('build',None)
    for c in doc.get('services',{}).values():
        c.setdefault('labels', {})[TRANSACTION_LABEL] = tx.name
    validate_rendered(doc,site,images)
    for p,h in hashes.items(): require(sha256(p)==h,'STATE_CHANGED','Compose/env가 render 도중 바뀌었습니다.')
    path=compose_snapshot.seal(doc,tx/'compose.json',site['project'])
    return path,hashes


def app_patch_command(base_tag, tag, dockerfile, context):
    # --network=none alone does NOT restrict BuildKit's frontend/base resolver.
    # Use the local legacy builder, a verified local tag and no external syntax directive.
    return ['env','DOCKER_BUILDKIT=0','docker','--host','unix:///var/run/docker.sock','build',
            '--pull=false','--network=none','--tag',tag,'--file',str(dockerfile),str(context)]


def prepare_images(m, root, tx, previous):
    images={}
    loaded=set()
    for spec in m['images']:
        actual=resolve_image(spec['reference'],spec['id'])
        if actual is None and spec.get('archive') and spec['archive'] not in loaded:
            docker('image','load','--input',str(root/spec['archive']),timeout=900,limit=32*1024**2)
            loaded.add(spec['archive'])
            actual=resolve_image(spec['reference'],spec['id'])
        require(actual is not None,'IMAGE_HASH','승인된 로컬 이미지가 없습니다.')
        images[spec['service']]=actual
    write_json(tx/'image-identities.json',{'release_config_ids':{s['service']:s['id'] for s in m['images']},
                                        'runtime_ids':images},new=True)
    if m['kind']=='APP':
        p=m['app_patch']
        require(previous is not None and previous['build_contract']==p['base_contract']==m['build_contract']
                and previous['images']['analytics']==p['base_id'],'FULL_BUNDLE_REQUIRED','APP base/의존성 계약이 다릅니다. FULL bundle이 필요합니다.')
        require(images['analytics']==p['base_id'],'APP_BASE','APP analytics image는 기존 base ID를 지정해야 합니다.')
        base_tag='contextra-offline-base:'+p['base_id'].split(':')[1]
        _, user=docker('image','inspect','--format','{{.Config.User}}',p['base_id'])
        require(user.strip() in ('','root','0','0:0'),'APP_BASE_USER','기존 image가 non-root입니다. USER를 임의로 바꾸지 않습니다.')
        docker('image','tag',p['base_id'],base_tag)
        require(image_id(base_tag)==p['base_id'],'APP_BASE','로컬 base tag 확인 실패')
        ctx=tx/'build'; ctx.mkdir()
        shutil.copytree(root/'source/app',ctx/'app')
        df=ctx/'Dockerfile'
        # Remove deleted modules too. This deletion is confined to a NEW build image.
        atomic_write(df,'FROM '+base_tag+'\nUSER root\nRUN ["python3", "-c", "import shutil; shutil.rmtree(\'/opt/contextra/app\', ignore_errors=True)"]\nCOPY app/ /opt/contextra/app/\n',new=True)
        tag='contextra-offline-app:'+m['release_id']
        run(app_patch_command(base_tag,tag,df,ctx),timeout=900,limit=32*1024**2)
        images['analytics']=image_id(tag)
    return images


def check_patch(m, previous, previous_manifest=None):
    if previous is None:
        require(m['kind']=='FULL' and not m.get('parent_release'),'FULL_REQUIRED','최초 설치에는 FULL bundle이 필요합니다.')
        return
    require(m.get('parent_release')==previous['release_id'],'PATCH_PARENT','이 서버의 활성 release와 patch 기준이 다릅니다.')
    require(m['compatibility_id']==previous['compatibility_id'],'DATA_COMPATIBILITY',
            'DB/설정 호환성 ID가 다릅니다. 자동 migration/rollback을 하지 않습니다.')
    if m['kind'] in ('CONFIG','APP'):
        require(m['build_contract']==previous['build_contract'],'FULL_BUNDLE_REQUIRED','native/의존성 계약이 달라 FULL bundle이 필요합니다.')
        expected={x['service']:x['id'] for x in m['images']}
        require(expected==previous['images'],'PATCH_IMAGES','CONFIG/APP은 현재 이미지 세트를 base로 사용해야 합니다.')
        if previous_manifest:
            prefixes=('source/app/','source/control_sidecar/') if m['kind']=='CONFIG' else ('source/control_sidecar/',)
            def subset(v): return {k:s['sha256'] for k,s in v['files'].items() if k.startswith(prefixes)}
            require(subset(m)==subset(previous_manifest),'PATCH_SCOPE','선택한 patch 종류 밖의 code 변경이 있습니다.')


def health(site, compose_path, images, timeout=90):
    end=time.monotonic()+timeout
    while True:
        rows=containers(site['project']); by={r['service']:r for r in rows}
        okay=all(n in by and by[n]['running'] and by[n]['image_id']==images[n]
                 and by[n]['health'] not in ('unhealthy','starting') for n in site['services'])
        if okay: break
        require(time.monotonic()<end,'CONTAINER_NOT_READY','container 상태/이미지가 수용 조건을 만족하지 못했습니다.')
        time.sleep(1)
    cid=by['analytics']['id']
    paths=['http://127.0.0.1:5000/ready']+['http://127.0.0.1:8000/v2/models/'+m+'/ready' for m in site['required_models']]
    script="import urllib.request; urls="+repr(paths)+"; op=urllib.request.build_opener(urllib.request.ProxyHandler({})); [(lambda r: (r.close(),r.status==200 or (_ for _ in ()).throw(ValueError('NOT_READY'))))(op.open(u,timeout=3)) for u in urls]; print('RUNTIME_READY')"
    docker('exec',cid,'python3','-c',script,timeout=30)
    script = ("import json,urllib.request; op=urllib.request.build_opener(urllib.request.ProxyHandler({})); "
              "req=urllib.request.Request('http://127.0.0.1:8000/v2/repository/index',data=b'{}',headers={'Content-Type':'application/json'}); "
              "rows=json.load(op.open(req,timeout=3)); expected=" + repr(set(site['required_models'])) + "; "
              "assert {r['name'] for r in rows}==expected and all(r.get('state')=='READY' for r in rows), 'MODEL_EXPOSURE_MISMATCH'; print('MODEL_SCOPE_READY')")
    docker('exec',cid,'python3','-c',script,timeout=10)
    return {'HOST_READY':'PASS','DEPLOYED':'PASS','RUNTIME_READY':'PASS','EXTERNAL_READY':'NOT_RUN',
            'first_frame':'NOT_RUN','accuracy':'NOT_RUN','note':'모델별 readiness이며 카메라/관제 시험 성공이 아닙니다.'}


def summary(m, site, previous):
    return {'release_id':m['release_id'],'kind':m['kind'],'previous':previous['release_id'] if previous else None,
            'project':site['project'],'services':site['services'],'models':site['required_models'],
            'scope':'PLAN_ONLY','network':'NO_PULL_NO_DOWNLOAD','auth_media_policy':'PRESERVE_CURRENT_COMPONENTS',
            'next':'검증 뒤 sudo deployctl ... --apply. 기존 live 대상은 먼저 승인된 Stop/reap/정지 필요'}


def deploy(site_path, archive, digest, *, apply=False):
    from .site import load
    site=load(site_path); site_hash=sha256(site_path)
    m=bundle.verify(archive,expected_hash=digest); previous=active(site)
    require(not previous or not previous.get('incremental_patch'),'INCREMENTAL_REVIEW_REQUIRED',
            '현장 증분 패치가 활성 상태입니다. 해당 패치 원장을 검토한 후 다음 배포를 준비하세요.')
    pm=None if previous is None else bundle.verify_directory(Path(site['install_root'])/'releases'/previous['release_id'])
    check_patch(m,previous,pm)
    require(set(x['service'] for x in m['images'])==set(site['services']),'IMAGE_SPEC','모든 승인 서비스의 image ID를 기록하세요.')
    if not apply: return summary(m,site,previous)
    require(os.geteuid()==0,'SUDO_REQUIRED','배포 적용에는 sudo가 필요합니다.')
    with lock(state_dir(site)):
        require(sha256(site_path)==site_hash and active(site)==previous,'STATE_CHANGED','계획 후 site/current가 바뀌었습니다.')
        inflight=state_dir(site)/'inflight.json'
        prior_record=None
        if inflight.exists():
            prior_record=read_json(inflight)
            require(prior_record.get('status') in ('DONE','REVIEWED'),'INTERRUPTED_DEPLOYMENT','미완료 배포 기록이 있습니다. 먼저 recover로 읽고 수동 상태를 확인하세요.')
        inv=host.inventory(); host.enforce_requirements(inv,m.get('requirements',{})); host.network_check(site,inv)
        storage.checks(site,writable=True)
        rows=containers(site['project'])
        if previous is None and rows:
            require(prior_record and prior_record.get('status')=='REVIEWED',
                    'EXISTING_PROJECT', '최초 설치 실패 자원의 검토 원장이 필요합니다.')
            verify_failed_ownership(rows, prior_record)
        require(set(r['service'] for r in rows)<=set(site['services']),'PROJECT_SCOPE','같은 project에 관리하지 않는 서비스가 있습니다.')
        # Rendered settings may change even with the same image: require a full maintenance window.
        check_quiescence(rows,site['services'])
        require(isinstance(inv.get('ports'),list),'PORT_QUERY','호스트 port 점유 상태를 읽지 못했습니다.')
        collisions=set(inv['ports'])
        txid='deploy-'+uuid.uuid4().hex; tx=state_dir(site)/'transactions'/txid; tx.mkdir(parents=True,mode=0o700)
        record={'transaction':txid,'release_id':m['release_id'],'previous':previous,'status':'INTENT','stage':'VERIFY','time':now()}
        write_json(inflight,record)
        def stage(name):
            record['stage']=name; write_json(inflight,record); write_json(tx/'journal.json',record)
        try:
            stage('PREPARE_RELEASE')
            releases=Path(site['install_root'])/'releases'; releases.mkdir(exist_ok=True)
            root=releases/m['release_id']
            require(not root.exists(),'RELEASE_EXISTS','동일 release 폴더가 이미 있습니다. 중단 기록을 검토하세요.')
            require(shutil.disk_usage(releases).free >= sum(x['size'] for x in m['files'].values())+2*1024**3,
                    'RELEASE_DISK','압축 해제와 상태 저장을 위한 여유 공간이 부족합니다.')
            staging=releases/('.staging-'+txid)
            bundle.verify(archive,expected_hash=digest,extract_to=staging); os.rename(staging,root)
            stage('STORAGE'); storage.prepare(site,bind_views=True)
            stage('IMAGES'); images=prepare_images(m,root,tx,previous)
            stage('COMPOSE'); path,inputs=render(site,root,images,tx)
            rendered=compose_snapshot.document(path)
            if previous:
                old_doc=compose_snapshot.document(snapshot_path(site,previous),previous['normalized_sha256'])
                require(persistent_bindings(rendered)==persistent_bindings(old_doc),
                        'PERSISTENT_MAPPING_CHANGED','DB/volume 저장소 변경은 일반 patch가 아닙니다. 별도 이전이 필요합니다.')
            receipts=model_receipts(rendered,site,inv,m.get('requirements',{}))
            bindings=compose_snapshot.file_bindings(rendered)
            ports={int(p['published']) for c in rendered['services'].values() for p in c.get('ports',[]) if p.get('published')}
            require(not ports & collisions,'PORT_OCCUPIED','필요한 host port를 다른 서비스가 사용 중입니다. 자동 변경하지 않습니다.')
            for p,h in inputs.items(): require(sha256(p)==h,'STATE_CHANGED','render 이후 설정 파일이 변경됐습니다.')
            require(sha256(site_path)==site_hash,'STATE_CHANGED','site 설정이 바뀌었습니다.')
            storage.checks(site,writable=True)
            compose_snapshot.verify_bindings(bindings)
            check_quiescence(containers(site['project']),site['services'])
            record['intended_images']=images
            stage('APPLY')
            docker('compose','--project-name',site['project'],'-f',str(path),'up','-d','--no-build','--pull','never',*site['services'],timeout=180,limit=8*1024**2,environment=compose_snapshot.command_environment())
            stage('HEALTH'); result=health(site,path,images)
            bundle.verify_directory(root)
            compose_snapshot.verify_bindings(bindings)
            stage('ACTIVATE')
            new=dict(release_id=m['release_id'],transaction=txid,compose_sha256=sha256(path),
                normalized_sha256=sha256(path.with_name('compose.normalized.json')),
                bound_files=bindings,config_inputs=inputs,
                site_sha256=site_hash,images=images,model_receipts=receipts,compatibility_id=m['compatibility_id'],build_contract=m['build_contract'],
                archive_sha256=digest,time=now(),acceptance=result)
            write_json(tx/'activation-proposal.json',new,new=True)
            switch_current(Path(site['install_root']),m['release_id'],previous=previous['release_id'] if previous else None)
            write_json(state_dir(site)/'active.json',new)
            write_json(tx/'applied.json',new,new=True)
            launcher=Path(site['install_root'])/'deployctl'
            if not launcher.exists():
                atomic_write(launcher,'#!/bin/sh\nset -eu\nROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)\nexec "$ROOT/current/deployctl" --site "$ROOT/state/site.json" "$@"\n',new=True,mode=0o755)
            record.update(status='DONE',stage='DONE'); write_json(inflight,record); write_json(tx/'journal.json',record)
            return dict(result,release_id=m['release_id'],transaction=txid)
        except BaseException as exc:
            record['status']='FAILED'
            record['failure']={'code':exc.code if isinstance(exc,KitError) else 'UNEXPECTED_FAILURE',
                               'exception_type':type(exc).__name__,
                               'diagnostic':exc.diagnostic if isinstance(exc,KitError) else {}}
            write_json(inflight,record); write_json(tx/'journal.json',record)
            raise


def rollback(site_path, transaction, *, apply=False):
    from .site import load
    site=load(site_path); identifier(transaction); current=active(site)
    require(current is not None,'NO_ACTIVE','활성 release가 없습니다.')
    require(not current.get('incremental_patch'),'INCREMENTAL_REVIEW_REQUIRED',
            '증분 패치의 image/model/config 원장을 검토한 뒤 명시적 복구를 준비하세요.')
    tx=state_dir(site)/'transactions'/transaction; target=read_json(tx/'applied.json')
    path=snapshot_path(site,target); root=Path(site['install_root'])/'releases'/target['release_id']
    m=bundle.verify_directory(root)
    require(target['compatibility_id']==current['compatibility_id'],'DATA_COMPATIBILITY','DB/설정 호환성이 달라 자동 rollback 대상이 아닙니다.')
    require(target['site_sha256']==sha256(site_path),'SITE_CHANGED','site 경로/정책이 달라 이전 snapshot을 그대로 적용할 수 없습니다.')
    if not apply: return {'scope':'ROLLBACK_PLAN','target':target['release_id'],'data_rollback':False,'transaction':transaction}
    require(os.geteuid()==0,'SUDO_REQUIRED','rollback에는 sudo가 필요합니다.')
    with lock(state_dir(site)):
        require(active(site)==current,'STATE_CHANGED','활성 release가 변경됐습니다.')
        pending=state_dir(site)/'inflight.json'
        if pending.exists():
            require(read_json(pending).get('status') in ('DONE','REVIEWED'),'INTERRUPTED_DEPLOYMENT','미완료 작업을 먼저 recover로 검토하세요.')
        check_quiescence(containers(site['project']),site['services'])
        storage.checks(site,writable=True); inv=host.inventory(); host.enforce_requirements(inv,m['requirements']); host.network_check(site,inv)
        for p,h in target.get('config_inputs',{}).items():
            require(Path(p).is_file() and sha256(p)==h,'CONFIG_CHANGED','이전 private env/overlay가 변경됐습니다. 옛 secret을 자동 복원하지 않습니다.')
        compose_snapshot.verify_bindings(target.get('bound_files',{}))
        require(model_receipts(compose_snapshot.document(path,target['normalized_sha256']),site,inv,m.get('requirements',{}))==target.get('model_receipts'),
                'MODEL_CHANGED','rollback 대상 모델이 바뀌었습니다. 모델 원본을 덮어쓰지 않습니다.')
        for iid in target['images'].values(): require(image_id(iid)==iid,'IMAGE_MISSING','이전 image가 없어 rollback할 수 없습니다.')
        rec={'status':'INTENT','stage':'ROLLBACK','transaction':'rollback-'+uuid.uuid4().hex,'target':transaction,'previous':current}
        write_json(state_dir(site)/'inflight.json',rec)
        docker('compose','--project-name',site['project'],'-f',str(path),'up','-d','--no-build','--pull','never',*site['services'],timeout=180,limit=8*1024**2,environment=compose_snapshot.command_environment())
        result=health(site,path,target['images'])
        switch_current(Path(site['install_root']),target['release_id'],previous=current['release_id'])
        write_json(state_dir(site)/'active.json',target)
        rec['status']='DONE'; write_json(state_dir(site)/'inflight.json',rec)
        return dict(result,release_id=target['release_id'],data_rollback=False)


def recover(site, *, expected=None, apply=False):
    p=state_dir(site)/'inflight.json'; r=read_json(p)
    if not apply: return {'record':r,'automatic_replay':False,'next':'현장 상태/원장 확인 후 recover --expected <파일SHA> --apply. 자동 재실행/rollback 없음','sha256':sha256(p)}
    with lock(state_dir(site)):
        require(expected and sha256(p)==expected,'STATE_CHANGED','검토한 inflight SHA가 필요합니다.')
        rows=containers(site['project'])
        check_quiescence(rows,site['services'])
        # Refuse ambiguous activation; repairing current/active is a separate reviewed operation.
        current=active(site)
        if current is None and rows:
            verify_failed_ownership(rows,r)
        r['reviewed_containers']=sorted(row['id'] for row in rows)
        r['status']='REVIEWED'; r['reviewed_at']=now(); write_json(p,r,expected=expected)
    return {'status':'REVIEWED','note':'원본/실패 release는 보존. 새 release ID로 다시 준비하세요.'}


def history(site):
    """Operator-safe release IDs; no secrets or full transaction snapshots."""
    result=[]
    parent=state_dir(site)/'transactions'
    if parent.exists():
        for p in sorted(parent.glob('*/applied.json')):
            a=read_json(p)
            result.append({k:a[k] for k in ('release_id','transaction','time')})
    return result


def persistent_bindings(doc):
    """Only persistent locations, never database contents or credentials."""
    result={}
    for name,cfg in doc['services'].items():
        for v in cfg.get('volumes',[]):
            if v.get('type')=='volume':
                definition=doc.get('volumes',{}).get(v['source'],{})
                result[name+':'+v['target']]=('volume',definition.get('name'),definition.get('driver'),definition.get('driver_opts'))
            elif v.get('type')=='bind' and not v.get('read_only'):
                result[name+':'+v['target']]=('bind',v['source'])
    return result
