"""TRACE-01: local diagnostics only; never starts containers or touches business rows."""
import argparse
import csv
from datetime import datetime, timezone, timedelta
import hashlib
import json
import os
from pathlib import Path
import pwd
import re
import shutil
import time
from uuid import uuid4

import compose_backend as backend
import field_profiles as fp
import server_context as server
from field_ui import print, input
from maintenance_process import run
from contextra_trace.writer import policy

KST = timezone(timedelta(hours=9))
FILES = {'summary.ko.txt','timeline.csv','request-response.jsonl','manifest.json'}
WORKER = r'''
import json,os,sys,urllib.request,urllib.error
from pathlib import Path
try:
 d=json.load(sys.stdin)
 t=Path(os.environ['CONTEXTRA_CONTROL_TOKEN_FILE']).read_text().strip()
 req=urllib.request.Request('http://127.0.0.1:5000/internal/trace/'+d['action'],
     json.dumps(d['body']).encode(),{'Authorization':'Bearer '+t,'Content-Type':'application/json'})
 try:r=urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req,timeout=8)
 except urllib.error.HTTPError as e:r=e
 with r:
  raw=r.read(2*1024**2+1)
  if len(raw)>2*1024**2:raise ValueError()
  value=json.loads(raw)
 if d['action']=='status':
  try:
   req=urllib.request.Request('http://ai-control-sidecar:8080/internal/trace-status',headers={'Authorization':'Bearer '+t})
   with urllib.request.build_opener(urllib.request.ProxyHandler({})).open(req,timeout=1) as r:
    value['sidecar']=json.loads(r.read(16384))
  except Exception:value['sidecar']={'error':'TRACE_SIDECAR_STATUS_UNAVAILABLE'}
 print(json.dumps(value,ensure_ascii=False))
except Exception:
 print(json.dumps({'error':'TRACE_CONTROL_UNAVAILABLE'}))
'''


def safe(path):
    p = Path(path)
    if not p.is_absolute() or '..' in p.parts or any(x.is_symlink() for x in (p,*p.parents)):
        raise ValueError('TRACE_PATH_UNSAFE')
    return p


def sha(path):
    h = hashlib.sha256()
    with safe(path).open('rb') as f:
        for chunk in iter(lambda:f.read(65536),b''):
            h.update(chunk)
    return h.hexdigest()


def context():
    if not server.portable():
        raise ValueError('TRACE_PORTABLE_SERVER_REQUIRED')
    server.guard_disks(); server.verify_tools()
    _,_,_,approved,live,_ = backend.inspect()
    if approved != live:
        raise ValueError('REVIEW_PENDING_CONFIG_FIRST')
    meta = fp.read_json(safe(fp.STATE/'trace-installation.json'))
    if meta['server'] != server.CURRENT['server']:
        raise ValueError('TRACE_SERVER_MISMATCH')
    for key in ('policy','exports'):
        safe(meta[key])
    if Path(meta['exports']).stat().st_dev != meta['device']:
        raise ValueError('TRACE_EXPORT_DEVICE_CHANGED')
    rows = [r for r in backend.containers() if r['Config']['Labels']['com.docker.compose.service'] == server.service('analytics') and r['State']['Running']]
    return meta, rows[0]['Id'] if len(rows)==1 else None


def call(container, action, body):
    if not container:
        raise ValueError('Runtime 정지 상태입니다. 자동 Start하지 않았습니다.')
    r = run(['docker','exec','-i',container,'python3','-B','-c',WORKER],
        input=json.dumps(dict(action=action,body=body)).encode(),capture_output=True,timeout=12,check=True)
    value = json.loads(r.stdout)
    if value.get('error') and action!='status':
        raise ValueError(value['error'])
    return value


def owner_tree(path):
    user = pwd.getpwnam(server.operator())
    for p in (path,*path.iterdir()):
        os.chown(p,user.pw_uid,user.pw_gid)
        p.chmod(0o700 if p.is_dir() else 0o600)


def export(meta, container, body):
    root = safe(meta['exports'])
    plan = call(container,'plan',body)
    stamp = datetime.now(KST)
    f = body['filters']
    camera = re.sub('[^A-Za-z0-9_-]','_',f.get('camera') or 'all')[:64]
    folder = f"camera-{camera}_{f.get('from','begin')}-{f.get('to','now')}_export-{stamp:%Y%m%dT%H%M%S}+0900-{uuid4().hex[:8]}"
    parent = safe(root/meta['server']/stamp.strftime('%Y-%m'))
    parent.mkdir(parents=True,exist_ok=True,mode=0o700)
    dest = safe(parent/folder); dest.mkdir(mode=0o700)
    count, after, deadline, written = 0, 0, time.monotonic()+90, 0
    manifest = dict(version=1,server=meta['server'],filters=f,cutoff=plan['cutoff'],
                    exportedAt=stamp.isoformat(),complete=False,count=0,files={})
    fp.atomic(dest/'manifest.json',manifest)
    with (dest/'timeline.csv').open('x',encoding='utf-8-sig',newline='') as csvfile, (dest/'request-response.jsonl').open('x',encoding='utf-8') as raw:
        writer = csv.writer(csvfile)
        columns = ['recordedAtMs','direction','component','phase','cameraId','requestId','operationId','eventId','runId','httpStatus','error']
        writer.writerow(['seq',*columns])
        while time.monotonic()<deadline:
            if written>=64*1024**2 or shutil.disk_usage(root).free<1024**3:
                break
            result = call(container,'list',dict(body,cutoff=plan['cutoff'],after=after,limit=5,detail=True))
            if not result['rows']:
                manifest['complete'] = True
                break
            for row in result['rows']:
                line=json.dumps(row,ensure_ascii=False)+'\n'
                raw.write(line);written+=len(line.encode())
                values = [str(row['doc'].get(k,'')) for k in columns]
                # Export is often opened in a spreadsheet; untrusted values are never formulas.
                writer.writerow([row['seq'],*[("'"+v if v.startswith(('=','+','-','@','\t','\r')) else v) for v in values]])
                count += 1
            after = result['next']
    manifest.update(count=count,lastSeq=after,closed=True)
    summary = f"서버: {meta['server']}\n조건(KST 날짜 포함): {json.dumps(f,ensure_ascii=False)}\n건수: {count}\n완료: {manifest['complete']}\n원본 DB 변경 없음. 인증수단은 마스킹됨.\n"
    fp.atomic(dest/'summary.ko.txt',summary.encode())
    manifest['files'] = {name:sha(dest/name) for name in FILES-{'manifest.json'}}
    fp.atomic(dest/'manifest.json',manifest)
    owner_tree(dest)
    # si can traverse the generated hierarchy without access to DB/private files.
    user = pwd.getpwnam(server.operator())
    for p in (root,root/meta['server'],parent):
        os.chown(p,user.pw_uid,user.pw_gid);p.chmod(0o700)
    print('반출:',str(dest),'건수:',count,'완료:',manifest['complete'])
    return dest


def export_candidates(meta, filters):
    root = safe(Path(meta['exports'])/meta['server'])
    if not root.exists():
        return []
    out = []
    for month in sorted(root.iterdir()):
        safe(month)
        if not month.is_dir():
            continue
        for folder in sorted(month.iterdir()):
            safe(folder)
            if not folder.is_dir():
                continue
            marker = safe(folder/'manifest.json')
            if not marker.is_file():
                continue
            m = fp.read_json(marker)
            if m.get('server') != meta['server'] or m.get('version') != 1:
                raise ValueError('TRACE_EXPORT_OWNER')
            if filters.get('camera') and m.get('filters',{}).get('camera') != filters['camera']:
                continue
            # For file cleanup the date selects export creation, not its contents.
            day = m['exportedAt'][:10]
            if filters.get('from') and day<filters['from'] or filters.get('to') and day>filters['to']:
                continue
            if {p.name for p in folder.iterdir()} != FILES or not (m.get('complete') or m.get('closed')):
                raise ValueError('TRACE_EXPORT_INCOMPLETE_OR_EXTRA_FILES')
            hashes = {name:sha(safe(folder/name)) for name in FILES}
            if any(hashes[k]!=v for k,v in m['files'].items()):
                raise ValueError('TRACE_EXPORT_MODIFIED')
            out.append((folder,hashes))
            if len(out)>1000:
                raise ValueError('TRACE_NARROW_EXPORT_FILTER')
    return out


def clean_exports(rows):
    # dir_fd + O_NOFOLLOW fences symlink replacement between preview and delete.
    for folder,hashes in rows:
        safe(folder)
        if {p.name for p in folder.iterdir()} != FILES or any(sha(folder/k)!=v for k,v in hashes.items()):
            raise ValueError('TRACE_EXPORT_CHANGED')
        parent = open_directory(folder.parent)
        fd = os.open(folder.name,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parent)
        try:
            for name in FILES:
                st = os.stat(name,dir_fd=fd,follow_symlinks=False)
                import stat
                if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
                    raise ValueError('TRACE_EXPORT_LINK')
                filefd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=fd)
                with os.fdopen(filefd,'rb') as f:
                    h=hashlib.sha256()
                    for chunk in iter(lambda:f.read(65536),b''):h.update(chunk)
                if h.hexdigest()!=hashes[name]:raise ValueError('TRACE_EXPORT_CHANGED')
            if os.fstat(fd).st_ino!=os.stat(folder.name,dir_fd=parent,follow_symlinks=False).st_ino:
                raise ValueError('TRACE_EXPORT_MOVED')
            for name in FILES:
                os.unlink(name,dir_fd=fd)
            os.rmdir(folder.name,dir_fd=parent)
        finally:
            os.close(fd)
            os.close(parent)


def open_directory(path):
    p=safe(path)
    fd=os.open('/',os.O_RDONLY|os.O_DIRECTORY)
    try:
        for component in p.parts[1:]:
            new=os.open(component,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
            os.close(fd);fd=new
        return fd
    except BaseException:
        os.close(fd)
        raise


def main(args):
    p = argparse.ArgumentParser(description='ct trace: 로컬 연동 이력 / local integration diagnostics')
    p.add_argument('action',nargs='?',default='status',choices=('status','list','show','export','clean','edit','apply'))
    p.add_argument('seq',nargs='?',type=int)
    for key in ('camera','request','event','from','to'):
        p.add_argument('--'+key)
    p.add_argument('--after',type=int,default=0);p.add_argument('--limit',type=int,default=30)
    p.add_argument('--plan',action='store_true');p.add_argument('--exports',action='store_true')
    p.add_argument('--resume')
    a = p.parse_args(args)
    meta, container = context();server.banner()
    source = safe(fp.SETUP/'trace/trace-policy.json')
    print('일반 편집 원본:',str(source),'적용본:',meta['policy'])
    print('반출 경로:',meta['exports'],'자동 삭제 Off. 기존 Event 원장/미디어는 대상 아님.')
    body = dict(server=meta['server'],filters={k:getattr(a,k) for k in ('camera','request','event','from','to') if getattr(a,k) is not None})
    # Validate locally, even when only filesystem exports are being selected.
    from trace_filters import validate
    validate(body['filters'])
    if a.action=='edit':
        d = policy(source)
        for key in ('enabled','queue_limit','batch_size','min_free_bytes'):
            raw = input(f'{key} [{d[key]}; Enter=현재값 유지]: ').strip()
            if raw:
                if key=='enabled':
                    if raw.lower() not in ('true','false'):raise ValueError('true/false')
                    d[key] = raw.lower()=='true'
                else:d[key] = int(raw)
        fp.atomic(source,d,user=True)
        print('원본 저장 완료. 적용: ct trace apply. queue_limit 변경은 서비스 재기동 필요.')
        return
    if a.action=='apply':
        d = policy(source);old=policy(meta['policy'])
        if d['queue_limit']!=old['queue_limit']:
            raise ValueError('TRACE_QUEUE_RESTART_REQUIRED: 별도 서비스 전환이 필요합니다. 적용하지 않았습니다.')
        if input('진단 설정만 적용하려면 APPLY 입력: ')!='APPLY':return
        fp.atomic(safe(meta['policy']),d,mode=0o644)
        print('저장 완료. 약5초 이내 반영되며 ct trace status로 확인하세요.')
        return
    if a.action=='status':
        print('설정값:',policy(source),'적용파일:',policy(meta['policy']))
        print(call(container,'status',body) if container else 'Runtime stopped; 실행값 미조회. 자동 Start 없음.')
    elif a.action=='list':
        out=call(container,'list',dict(body,after=a.after,limit=a.limit))
        for row in out['rows']:
            d=row['doc'];stamp=datetime.fromtimestamp(d['recordedAtMs']/1000,KST).isoformat()
            print(row['seq'],stamp,d.get('component'),d.get('phase'),'camera=',d.get('cameraId'),
                  'HTTP=',d.get('httpStatus'),'error=',str(d.get('error'))[:160])
        print('다음 페이지 --after',out['next'],'/ 상세: ct trace show 번호')
    elif a.action=='show':print(json.dumps(call(container,'show',dict(body,seq=a.seq)),ensure_ascii=False,indent=2))
    elif a.action=='export':return export(meta,container,body)
    elif a.action=='clean':
        if a.exports:
            if a.request or a.event or a.resume:raise ValueError('TRACE_EXPORT_FILTER_UNSUPPORTED')
            rows=export_candidates(meta,body['filters'])
            print('반출 파일 정리: 생성일(KST) 기준 / 폴더',len(rows),'개. DB 기록은 유지합니다.')
            for path,_ in rows[:20]:print(str(path))
            if not a.plan and input('반출본 복구 불가. 삭제하려면 DELETE TRACE EXPORTS 입력: ')=='DELETE TRACE EXPORTS':clean_exports(rows)
            return
        jobs=safe(fp.STATE/'trace-jobs');jobs.mkdir(mode=0o700,exist_ok=True)
        if a.resume:
            if not re.fullmatch('[0-9a-f]{32}',a.resume):raise ValueError('TRACE_JOB_INVALID')
            job=jobs/(a.resume+'.json');plan=fp.read_json(job)
            if plan['server']!=meta['server']:raise ValueError('TRACE_SERVER_MISMATCH')
        else:
            plan=call(container,'plan',body);job=jobs/(uuid4().hex+'.json')
        print('진단 DB 삭제 대상:',plan,'/ 실제 디스크 회수량과 삭제 건수는 다릅니다.')
        if a.plan or input('기존 Event 원장 유지. 진단 삭제는 복구 불가. DELETE TRACE 입력: ')!='DELETE TRACE':return
        fp.atomic(job,plan);count=0;deadline=time.monotonic()+60
        while time.monotonic()<deadline:
            result=call(container,'clean',dict(plan,confirm='DELETE TRACE'))
            count+=result['deleted']
            if result['deleted']==0:break
            time.sleep(.05)
        print('이번 삭제 건수:',count,'/ 잔여 확인·재개: ct trace clean --resume',job.stem)
