"""Explicit, single-attempt AI login; private journaling and resumable local publication."""
import argparse
import asyncio
from dataclasses import replace
import fcntl
import getpass
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import urllib.request
from field_ui import print, input, text

ROOT = Path('/var/lib/contextra-maintenance/auth')
PENDING = ROOT / 'pending.json'
SIDE = 'contextra-icerti-ai-control-sidecar-1'
ACTIVE = Path('/workspace/contextra/runtime/state/active.json')
VENDOR = Path(__file__).resolve().parent / 'bridge_tools'


def digest(data):
    return hashlib.sha256(data).hexdigest()


def command(args, **kwargs):
    return subprocess.run(args, check=True, timeout=kwargs.pop('timeout', 20),
                          capture_output=True, **kwargs)


def inspected():
    return json.loads(command(['docker', 'inspect', SIDE]).stdout)[0]


def map_file(value, mounts):
    path = Path(value)
    for mount in sorted(mounts, key=lambda x: len(x['Destination']), reverse=True):
        target = Path(mount['Destination'])
        if mount['Type'] == 'bind' and path.is_relative_to(target):
            result = Path(mount['Source']) / path.relative_to(target)
            if any(p.is_symlink() for p in (result, *result.parents)):
                raise ValueError('Authentication symlink refused')
            return str(result)
    raise ValueError('Authentication file is not a known bind mount')


def context():
    sys.path.insert(0, str(VENDOR))
    from control_sidecar.config import Config
    row = inspected()
    env = dict(v.split('=', 1) for v in row['Config']['Env'])
    for key in ('AI_CONTROL_MANAGER_CA_FILE', 'AI_CONTROL_LOGIN_BODY_FILE', 'AI_CONTROL_PASSWORD_FILE',
                'AI_CONTROL_SESSION_FILE', 'AI_CONTROL_CERTIFICATE_FILE',
                'CONTEXTRA_CONTROL_TOKEN_FILE', 'CONTEXTRA_RELAY_TOKEN_FILE'):
        if env.get(key):
            env[key] = map_file(env[key], row['Mounts'])
    return row, env, Config.from_env(env)


def login_id(value):
    if not isinstance(value, str) or not value.strip() or len(value) > 256 or any(ord(c) < 32 for c in value):
        raise ValueError('Invalid login ID')
    return value


def read_credentials(path):
    path = Path(path)
    if not path.is_absolute() or any(p.is_symlink() for p in (path, *path.parents)):
        raise ValueError('Absolute private credential file required')
    info = path.stat()
    if info.st_mode & 0o077 or info.st_uid not in (0, int(os.environ.get('SUDO_UID', os.getuid()))) or info.st_size > 16384:
        raise ValueError('Credential file owner/mode/size refused')
    value = json.loads(path.read_bytes())
    if not isinstance(value, dict) or set(value) != {'loginId', 'password'}:
        raise ValueError('Credential file fields refused')
    login_id(value['loginId'])
    if not isinstance(value['password'], str) or not value['password'] or '\x00' in value['password'] or len(value['password'].encode()) > 8192:
        raise ValueError('Credential value refused')
    return value


def private_write(path, data, owner=None):
    path = Path(path)
    if any(p.is_symlink() for p in (path, *path.parents)):
        raise ValueError('Private path symlink refused')
    temporary = path.with_name(path.name + '.auth-next')
    if temporary.exists():
        if temporary.is_symlink() or temporary.read_bytes() != data:
            raise ValueError('Unexpected interrupted private write')
        temporary.unlink()
    fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, 'wb') as stream:
        if owner is not None:
            os.fchown(stream.fileno(), *owner)
        stream.write(data)
        stream.flush()
        os.fsync(stream.fileno())
    os.replace(temporary, path)
    parent = os.open(path.parent, os.O_DIRECTORY)
    try:
        os.fsync(parent)
    finally:
        os.close(parent)


def record(path, value):
    private_write(path, (json.dumps(value, indent=2) + '\n').encode())


def discard_unused_secrets(operation):
    for name in ('new-password', 'new-body.json', 'before-new-password',
                 'before-new-body.json', 'before-new-session.json'):
        (operation / name).unlink(missing_ok=True)


def idle():
    p = command(['docker', 'inspect', 'contextra-icerti-analytics-1'])
    if not json.loads(p.stdout)[0]['State']['Running']:
        return
    with urllib.request.urlopen('http://127.0.0.1:35000/api/v1/streams?detail=false', timeout=8) as response:
        rows = json.load(response)['streams']
    if any(x['state'] != 'stopped' or x.get('cleanup_pending') for x in rows):
        raise ValueError('카메라를 모두 Stop한 뒤 실행하세요. 자동 Stop하지 않습니다.')


def stop_sidecar():
    if inspected()['State']['Running']:
        command(['docker', 'stop', '--timeout', '30', SIDE], timeout=45)
    row = inspected()
    if row['State']['Running'] or row['State']['ExitCode'] == 137:
        raise RuntimeError('Sidecar 정상 종료 미확인; 로그인하지 않습니다.')


def wss():
    code = """import json,os,urllib.request
from pathlib import Path
r=urllib.request.Request('http://127.0.0.1:8080/internal/bridge-status',headers={'Authorization':'Bearer '+Path(os.environ['CONTEXTRA_CONTROL_TOKEN_FILE']).read_text().strip()})
d=json.load(urllib.request.urlopen(r,timeout=3));print(json.dumps({'auth_error':d.get('auth'),'connected':d.get('wss',{}).get('connected',False)}))
"""
    try:
        return json.loads(command(['docker', 'exec', SIDE, 'python3', '-B', '-c', code], timeout=6).stdout)
    except (subprocess.SubprocessError, ValueError):
        return dict(auth_error='STATUS_UNAVAILABLE', connected=False)


def publish(operation):
    row, env, cfg = context()
    data = json.loads((operation / 'operation.json').read_text())
    ready = data['phase'] in ('LOGIN_OK', 'APPLYING', 'APPLIED')
    ready = ready or (data['phase'] == 'LOGIN_INTENT' and (operation / 'new-session.json').is_file())
    if not ready:
        raise ValueError('확정된 새 세션이 없습니다. Web 결과를 확인하고 로그인 반복을 피하세요.')
    if digest(ACTIVE.read_bytes()) != data['active_sha256'] or row['Id'] != data['container_id']:
        raise ValueError('Deployment changed; no credentials overwritten')
    stop_sidecar()
    from control_sidecar.auth import Auth
    candidate = replace(cfg, password=(operation / 'new-password').read_text().rstrip('\n'),
                        login_body=json.loads((operation / 'new-body.json').read_text()),
                        session_file=str(operation / 'new-session.json'))
    if not Auth(candidate, None).restore():
        raise ValueError('Staged AI session invalid or expired')
    for item in data['files']:
        path = Path(item['path'])
        current = digest(path.read_bytes()) if path.exists() else None
        wanted = (operation / item['new']).read_bytes()
        if current not in (item['before_sha256'], digest(wanted)):
            raise ValueError('Authentication file changed; no overwrite')
    data['phase'] = 'APPLYING'
    record(operation / 'operation.json', data)
    for item in data['files']:
        private_write(item['path'], (operation / item['new']).read_bytes(), owner=tuple(item['owner']))
    data['phase'] = 'APPLIED'
    record(operation / 'operation.json', data)
    PENDING.unlink()
    if data['sidecar_was_running']:
        command(['docker', 'start', SIDE])
        deadline = time.monotonic() + 30
        while time.monotonic() < deadline:
            status = wss()
            if status['connected']:
                print('AI_LOGIN_PASS; WSS_CONNECTED')
                return
            time.sleep(2)
        print('AI_LOGIN_PASS; WSS_NOT_CONFIRMED. 재로그인하지 말고 Web 연결 상태를 확인하세요.')
    else:
        print('AI_LOGIN_PASS; SESSION_SAVED. ct start 후 WSS를 확인하세요. 자동 Start하지 않았습니다.')


def cancel(confirmed=False):
    operation = Path(json.loads(PENDING.read_text())['operation'])
    if operation.parent != ROOT or operation.is_symlink():
        raise ValueError('Invalid private operation')
    data = json.loads((operation / 'operation.json').read_text())
    if data['phase'] not in ('LOGIN_INTENT', 'UNCONFIRMED') or (operation / 'new-session.json').exists():
        raise ValueError('발급된 세션 또는 적용 단계는 취소할 수 없습니다. ct auth resume으로 확인하세요.')
    for item in data['files']:
        path = Path(item['path'])
        if (digest(path.read_bytes()) if path.exists() else None) != item['before_sha256']:
            raise ValueError('Authentication files changed; no cancellation')
    if not confirmed and input('원격 결과는 미확정으로 남기고 기존 파일을 유지하려면 CANCEL 입력: ') != 'CANCEL':
        return
    data['phase'] = 'CANCELLED_UNCONFIRMED'
    data['unused_secrets_discarded'] = True
    record(operation / 'operation.json', data)
    PENDING.unlink()
    discard_unused_secrets(operation)
    row, env, cfg = context()
    session = json.loads(Path(cfg.session_file).read_text()) if Path(cfg.session_file).exists() else {}
    expiry = session.get('expires')
    if data['sidecar_was_running'] and session.get('token') and (expiry is None or expiry > time.time() * 1000 + 60000):
        if not row['State']['Running']:
            command(['docker', 'start', SIDE])
        print('CANCELLED; ORIGINAL_FILES_PRESERVED; SIDECAR_STARTED_WITH_SAVED_SESSION; NO_LOGIN_RETRY')
    else:
        print('CANCELLED; ORIGINAL_FILES_PRESERVED; SIDECAR_NOT_STARTED_TO_AVOID_AUTOMATIC_LOGIN')


def attempt(action, credentials=None, confirmed=False):
    from control_sidecar.auth import Auth
    from control_sidecar.protocol import BoundaryError
    from tools.secure_bridge_enroll import Transport
    row, env, cfg = context()
    previous = None
    if PENDING.exists():
        operation = Path(json.loads(PENDING.read_text())['operation'])
        if operation.parent != ROOT or operation.is_symlink():
            raise ValueError('Invalid private operation')
        previous = json.loads((operation / 'operation.json').read_text())
        if action != 'retry' or (operation / 'new-session.json').exists():
            raise ValueError('ct auth resume으로 진행 중인 인증 작업부터 확인하세요.')
        if previous['phase'] not in ('LOGIN_INTENT', 'UNCONFIRMED'):
            raise ValueError('This operation is not an unresolved login')
        if digest(ACTIVE.read_bytes()) != previous['active_sha256']:
            raise ValueError('Deployment changed; no login retry')
        for item in previous['files']:
            path = Path(item['path'])
            if (digest(path.read_bytes()) if path.exists() else None) != item['before_sha256']:
                raise ValueError('Authentication file changed; no login retry')
    elif action == 'retry':
        raise ValueError('No pending login to explicitly retry')
    if credentials is None and not sys.stdin.isatty():
        raise ValueError('si 대화형 터미널에서 실행하세요. 비밀번호를 인자로 받지 않습니다.')
    idle()
    body = dict(cfg.login_body)
    password = cfg.password
    if credentials is not None:
        if action not in ('update', 'retry') or not confirmed:
            raise ValueError('Private-file input requires update and explicit confirmation')
        body['loginId'], password = credentials['loginId'], credentials['password']
    elif action in ('update', 'retry'):
        body['loginId'] = login_id(input('AI 연결용 login ID [Enter=기존 유지]: ').strip() or body['loginId'])
        typed = getpass.getpass(text('AI 연결용 비밀번호 [Enter=기존 유지, 화면에 표시 안 됨]: '))
        if typed:
            if typed != getpass.getpass(text('비밀번호 다시 입력: ')):
                raise ValueError('비밀번호 확인 불일치; 변경 없음')
            password = typed
    if not password or '\x00' in password or len(password.encode()) > 8192:
        raise ValueError('Invalid password')
    print('Web 화면 계정이 아닌 AI 연결용 계정이어야 합니다. 성공 시 이전 AI token이 폐기됩니다.')
    if not confirmed and input('기존 CA·주소로 로그인 1회를 실행하려면 LOGIN 입력: ') != 'LOGIN':
        print('취소; 변경 없음')
        return
    operation = ROOT / (time.strftime('%Y%m%dT%H%M%S') + '-' + str(os.getpid()))
    operation.mkdir(mode=0o700)
    private_write(operation / 'new-password', (password + '\n').encode())
    private_write(operation / 'new-body.json', json.dumps(body).encode())
    files = []
    for path, new in ((env['AI_CONTROL_PASSWORD_FILE'], 'new-password'),
                      (env['AI_CONTROL_LOGIN_BODY_FILE'], 'new-body.json'), (cfg.session_file, 'new-session.json')):
        p = Path(path)
        info = p.stat() if p.exists() else p.parent.stat()
        old = p.read_bytes() if p.exists() else None
        if old is not None:
            private_write(operation / ('before-' + new), old)
        files.append(dict(path=path, new=new, before_sha256=digest(old) if old is not None else None,
                          owner=[info.st_uid, info.st_gid]))
    data = dict(phase='PREPARED', container_id=row['Id'], sidecar_was_running=row['State']['Running'],
                active_sha256=digest(ACTIVE.read_bytes()), files=files)
    if previous is not None:
        data['sidecar_was_running'] = previous['sidecar_was_running']
        data['explicit_retry'] = True
    record(operation / 'operation.json', data)
    stop_sidecar()
    record(PENDING, dict(operation=str(operation)))
    data['phase'] = 'LOGIN_INTENT'
    record(operation / 'operation.json', data)
    candidate = replace(cfg, password=password, login_body=body, session_file=str(operation / 'new-session.json'))
    class ObservedTransport(Transport):
        async def request(self, *args, **kwargs):
            code, raw = await super().request(*args, **kwargs)
            summary = dict(http_status=code)
            try:
                doc = json.loads(raw)
                details = doc.get('data') if isinstance(doc.get('data'), dict) else {}
                fields = {'actorType','serverId','accountId','token','mustChangePassword',
                          'expiresAtWallMs','permission','replacedSession','revokedPreviousTokens'}
                summary.update(result=doc.get('result') is True, error_present=doc.get('error') is not None,
                               actor_type=details.get('actorType') if details.get('actorType') in ('AI','MANAGER') else 'OTHER',
                               server_matches=details.get('serverId') == cfg.server_id,
                               must_change_password=details.get('mustChangePassword') is True,
                               missing_data_fields=sorted(fields - details.keys()))
            except (ValueError, TypeError, AttributeError):
                summary['json_contract'] = False
            record(operation / 'response-summary.json', summary)
            return code, raw
    tx = ObservedTransport()
    tx.ca = cfg.manager_ca
    try:
        asyncio.run(Auth(candidate, tx).login())
    except BoundaryError as error:
        data['failure'] = dict(code=error.code, http_status=error.status)
        if error.code == 'LOGIN_REJECTED':
            data['phase'] = 'REJECTED'
            data['unused_secrets_discarded'] = True
            record(operation / 'operation.json', data)
            PENDING.unlink()
            discard_unused_secrets(operation)
            if data['sidecar_was_running']:
                command(['docker', 'start', SIDE])
            print('AI_LOGIN_REJECTED; HTTP', error.status, '; 기존 인증정보 보존, 재시도 없음')
            raise SystemExit(2)
        data['phase'] = 'UNCONFIRMED'
        record(operation / 'operation.json', data)
        raise RuntimeError('로그인 결과 미확정. Sidecar 정지 유지, 자동 재시도 없음; ct auth status로 확인') from None
    except Exception as error:
        data['phase'] = 'UNCONFIRMED'
        data['failure'] = dict(code=type(error).__name__, reason_type=type(getattr(error, 'reason', None)).__name__)
        record(operation / 'operation.json', data)
        raise RuntimeError('로그인 결과 미확정. Sidecar 정지 유지, 자동 재시도 없음; ct auth status로 확인') from None
    data['phase'] = 'LOGIN_OK'
    record(operation / 'operation.json', data)
    publish(operation)


def main(argv=None):
    p = argparse.ArgumentParser(description=text('AI 인증정보 숨김 입력·로그인1회·WSS 확인. Web 관리자 계정과 구분하세요.'))
    p.add_argument('action', choices=('status', 'update', 'reconnect', 'resume', 'retry', 'cancel'))
    p.add_argument('--credentials-file', type=Path)
    p.add_argument('--confirm-login', action='store_true')
    p.add_argument('--confirm-cancel', action='store_true')
    args = p.parse_args(argv)
    if os.geteuid() != 0:
        raise ValueError('ct auth 명령의 sudo 경로를 사용하세요.')
    if (args.credentials_file is not None or args.confirm_login) and not (args.action in ('update', 'retry') and args.credentials_file is not None and args.confirm_login):
        raise ValueError('Private-file input requires update and --confirm-login')
    if args.confirm_cancel and args.action != 'cancel':
        raise ValueError('--confirm-cancel requires cancel')
    row, env, cfg = context()
    if args.action == 'status':
        result = dict(server_id=cfg.server_id, login_id=cfg.login_body['loginId'],
                      sidecar_running=row['State']['Running'], pending=PENDING.exists(),
                      session_saved=Path(cfg.session_file).is_file(), wss=wss())
        if PENDING.exists():
            operation = Path(json.loads(PENDING.read_text())['operation'])
            if operation.parent != ROOT or operation.is_symlink():
                raise ValueError('Invalid private operation')
            data = json.loads((operation / 'operation.json').read_text())
            result['phase'] = data['phase']
            result['failure'] = data.get('failure')
            summary = operation / 'response-summary.json'
            result['response'] = json.loads(summary.read_text()) if summary.is_file() else None
        print(json.dumps(result, ensure_ascii=False))
        return
    os.umask(0o077)
    ROOT.mkdir(mode=0o700, exist_ok=True)
    with (ROOT / 'operation.lock').open('a') as lock:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        if args.action == 'cancel':
            cancel(args.confirm_cancel)
        elif args.action == 'resume':
            operation = Path(json.loads(PENDING.read_text())['operation'])
            if operation.parent != ROOT or operation.is_symlink():
                raise ValueError('Invalid private operation')
            idle()
            publish(operation)
        else:
            attempt(args.action, read_credentials(args.credentials_file) if args.credentials_file else None, args.confirm_login)


if __name__ == '__main__':
    try:
        main()
    except Exception as error:
        print('AUTH_OPERATION_STOPPED:', type(error).__name__, '; 비밀값은 출력하지 않습니다. ct auth status로 확인하세요.')
        raise SystemExit(2) from None
