#!/usr/bin/env python3
"""devin-run 0.1.3. Python 3.9+ 표준 라이브러리. 자동 업데이트 없음."""
import argparse
import hashlib
import json
import math
import os
from pathlib import Path
import platform
import re
import shutil
import ssl
import stat
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request

VERSION = '0.1.3'
MAX_RESPONSE = 2 * 1024 * 1024
MAX_OUTPUT = 1024 * 1024
MINIMUM = {'go': (1, 21, 0), 'rust': (1, 70, 0)}
LINKS = {'go': 'https://go.dev/dl/', 'rust': 'https://rustup.rs/'}
HELLO = {'go': 'package main\nimport "fmt"\nfunc main(){fmt.Println("devin-ready")}\n',
         'rust': 'fn main(){println!("devin-ready");}\n'}


class Failure(Exception):
    def __init__(self, message, code=1):
        super().__init__(message)
        self.code = code


def executable_name(windows=None):
    return 'main.exe' if (os.name == 'nt' if windows is None else windows) else 'main'


def compiler_hint(lang, windows=None):
    win = os.name == 'nt' if windows is None else windows
    home = Path.home()
    candidates = ([Path(os.environ.get('ProgramFiles', r'C:\Program Files')) / 'Go/bin/go.exe'] if lang == 'go'
                  else [home / '.cargo/bin/rustc.exe']) if win else []
    return next((p for p in candidates if p.is_file()), None)


def compiler(lang):
    name = 'go' if lang == 'go' else 'rustc'
    path = shutil.which(name)
    guidance = '%s 설치 후 터미널을 새로 열고 %s 확인: %s' % (
        LINKS[lang], name + (' version' if lang == 'go' else ' --version'),
        'PowerShell에서 PATH를 확인하세요.' if os.name == 'nt' else 'zsh/bash에서 PATH를 확인하세요.')
    if not path:
        if compiler_hint(lang):
            raise Failure('설치 파일은 있지만 PATH에 없습니다. ' + guidance, 11)
        raise Failure('컴파일러가 설치되지 않았거나 PATH에 없습니다. ' + guidance, 10)
    result = run_process([path, 'version' if lang == 'go' else '--version'], '', 10)
    match = re.search(r'(?:go|rustc )([0-9]+)\.([0-9]+)(?:\.([0-9]+))?', result[1])
    if result[0] != 'OK' or not match:
        raise Failure('컴파일러 버전을 확인하지 못했습니다. ' + guidance, 13)
    version = tuple(int(x or 0) for x in match.groups())
    if version < MINIMUM[lang]:
        raise Failure('%s 최소 버전 %s 이상이 필요합니다. %s' % (lang, '.'.join(map(str, MINIMUM[lang])), guidance), 12)
    return path, '.'.join(map(str, version))


def run_process(command, stdin, timeout, cwd=None):
    """고정 인자 배열만 실행. 양쪽 출력을 동시에 제한하여 디스크·메모리 누적을 막는다."""
    proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                            cwd=cwd, shell=False)
    output, errors, exceeded = bytearray(), bytearray(), threading.Event()

    def drain(stream, buffer):
        while True:
            block = stream.read(8192)
            if not block:
                break
            if len(buffer) + len(block) > MAX_OUTPUT:
                exceeded.set()
                proc.kill()
                break
            buffer.extend(block)

    def feed():
        try:
            proc.stdin.write(stdin.encode('utf-8'))
            proc.stdin.close()
        except (BrokenPipeError, OSError):
            pass

    threads = [threading.Thread(target=drain, args=(proc.stdout, output), daemon=True),
               threading.Thread(target=drain, args=(proc.stderr, errors), daemon=True),
               threading.Thread(target=feed, daemon=True)]
    for thread in threads:
        thread.start()
    status = 'OK'
    try:
        proc.wait(timeout=timeout)
    except subprocess.TimeoutExpired:
        status = 'TLE'
        proc.kill()
        proc.wait()
    for thread in threads:
        thread.join(timeout=1)
    proc.stdout.close()
    proc.stderr.close()
    if exceeded.is_set():
        status = 'OLE'
    elif status == 'OK' and proc.returncode:
        status = 'RE'
    return status, output.decode('utf-8', 'replace'), errors.decode('utf-8', 'replace')


def build(lang, tool, source, directory):
    binary = Path(directory) / executable_name()
    command = ([tool, 'build', '-o', str(binary), str(source)] if lang == 'go' else
               [tool, '-O', '--edition', '2021', '-o', str(binary), str(source)])
    status, _, error = run_process(command, '', 120, directory)
    if status != 'OK':
        raise Failure('컴파일 오류: ' + error[:4000], 20)
    return binary


def windows_private_file(stream):
    """NTFS에서 POSIX 600에 해당하는 소유자 전용 DACL을 열린 핸들로 확인한다."""
    import ctypes
    from ctypes import wintypes
    import msvcrt
    advapi = ctypes.WinDLL('advapi32', use_last_error=True)
    kernel = ctypes.WinDLL('kernel32', use_last_error=True)
    owner, dacl, descriptor = ctypes.c_void_p(), ctypes.c_void_p(), ctypes.c_void_p()
    advapi.GetSecurityInfo.argtypes = [wintypes.HANDLE, ctypes.c_int, wintypes.DWORD,
                                       ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p,
                                       ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p,
                                       ctypes.POINTER(ctypes.c_void_p)]
    advapi.GetSecurityInfo.restype = wintypes.DWORD
    advapi.GetAclInformation.argtypes = [ctypes.c_void_p, ctypes.c_void_p, wintypes.DWORD, ctypes.c_int]
    advapi.GetAce.argtypes = [ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)]
    advapi.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
    kernel.LocalFree.argtypes = [ctypes.c_void_p]

    class AclSize(ctypes.Structure):
        _fields_ = [('count', wintypes.DWORD), ('used', wintypes.DWORD), ('free', wintypes.DWORD)]

    handle = wintypes.HANDLE(msvcrt.get_osfhandle(stream.fileno()))
    if advapi.GetSecurityInfo(handle, 1, 5, ctypes.byref(owner), None, ctypes.byref(dacl), None, ctypes.byref(descriptor)):
        return False
    try:
        if not owner.value or not dacl.value:
            return False
        size = AclSize()
        if not advapi.GetAclInformation(dacl, ctypes.byref(size), ctypes.sizeof(size), 2):
            return False
        readable = False
        for index in range(size.count):
            ace = ctypes.c_void_p()
            if not advapi.GetAce(dacl, index, ctypes.byref(ace)):
                return False
            kind = ctypes.c_ubyte.from_address(ace.value).value
            if kind == 1:  # ACCESS_DENIED_ACE: 권한을 추가하지 않는다.
                continue
            if kind != 0:  # 알 수 없는 ACE는 허용하지 않는다.
                return False
            mask = wintypes.DWORD.from_address(ace.value + 4).value
            if not advapi.EqualSid(owner, ctypes.c_void_p(ace.value + 8)):
                return False
            readable = readable or bool(mask & 1)
        return readable
    finally:
        kernel.LocalFree(descriptor)


def token_from(path):
    if not path:
        return None
    p = Path(path)
    if p.is_symlink():
        raise Failure('토큰 파일은 심볼릭 링크일 수 없습니다.', 14)
    fd = os.open(str(p), os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_BINARY', 0))
    with os.fdopen(fd, 'rb') as stream:
        info = os.fstat(stream.fileno())
        private = windows_private_file(stream) if os.name == 'nt' else stat.S_IMODE(info.st_mode) == 0o600
        if not stat.S_ISREG(info.st_mode) or not private:
            raise Failure('토큰 파일 권한은 600이어야 합니다. chmod 600 <파일>. Windows는 소유자 전용 NTFS ACL을 설정하세요(사이트 도움말).', 14)
        value = stream.read(256).decode('ascii').strip()
    if not re.fullmatch(r'dvr_[A-Za-z0-9_-]{43}', value):
        raise Failure('토큰 파일 형식이 잘못되었습니다.', 14)
    return value


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise Failure('리다이렉트 응답을 거부했습니다.', 15)


class Client:
    def __init__(self, base, token=None):
        url = urllib.parse.urlsplit(base)
        if url.scheme != 'https' or not url.hostname or url.username or url.password or url.query or url.fragment:
            raise Failure('기본 주소는 사용자 정보 없는 HTTPS 주소여야 합니다.', 15)
        self.base, self.token = base.rstrip('/'), token
        self.opener = urllib.request.build_opener(NoRedirect())

    def request(self, path, body=None, authenticated=False):
        if not re.fullmatch(r'/(?:health|token|ready|problems/[0-9]+/(?:practice|challenge|submit))', path):
            raise Failure('허용되지 않은 요청 경로입니다.', 15)
        headers = {'Accept': 'application/json', 'User-Agent': 'devin-run/' + VERSION}
        if authenticated:
            if not self.token:
                raise Failure('--token-file로 CLI 토큰 파일을 지정하세요.', 14)
            headers['Authorization'] = 'Bearer ' + self.token
        data = None if body is None else json.dumps(body).encode('utf-8')
        if data is not None:
            headers['Content-Type'] = 'application/json'
        req = urllib.request.Request(self.base + '/cli/x' + path, data=data, headers=headers)
        try:
            with self.opener.open(req, timeout=30) as response:
                raw = response.read(MAX_RESPONSE + 1)
            if len(raw) > MAX_RESPONSE:
                raise Failure('서버 응답이 너무 큽니다.', 15)
            result = json.loads(raw)
            if not isinstance(result, dict):
                raise ValueError()
            return result
        except urllib.error.URLError as error:
            if isinstance(getattr(error, 'reason', None), ssl.SSLCertVerificationError):
                raise Failure(certificate_help(), 15) from None
            raise Failure('서버 연결/JSON 오류: ' + type(error).__name__, 15) from None
        except (ValueError, OSError) as error:
            raise Failure('서버 연결/JSON 오류: ' + type(error).__name__, 15) from None


def bundle(data, submit=False):
    # 서버가 보낸 명령·경로·파일명은 사용하지 않으며 스키마 밖 필드는 거부한다.
    allowed = {'inputs', 'timeLimitMs', 'token', 'id', 'expiresAt'} if submit else {'inputs', 'outputs', 'checker', 'timeLimitMs'}
    if set(data) - allowed:
        raise Failure('허용되지 않은 테스트 필드입니다.', 15)
    inputs = data.get('inputs')
    limit = data.get('timeLimitMs')
    if not isinstance(inputs, list) or not 1 <= len(inputs) <= 60 or any(not isinstance(v, str) or len(v.encode()) > MAX_OUTPUT for v in inputs):
        raise Failure('입력 묶음 형식이 잘못되었습니다.', 15)
    if type(limit) is not int or not 1 <= limit <= 30000:
        raise Failure('시간 제한 형식이 잘못되었습니다.', 15)
    if submit:
        if len(inputs) != 10 or not isinstance(data.get('token'), str) or len(data['token']) > 4096:
            raise Failure('도전 형식이 잘못되었습니다.', 15)
    else:
        outputs = data.get('outputs')
        if not isinstance(outputs, list) or len(outputs) != len(inputs) or any(not isinstance(v, str) for v in outputs):
            raise Failure('출력 묶음 형식이 잘못되었습니다.', 15)
        checker = data.get('checker')
        if not isinstance(checker, str) or not re.fullmatch(r'tokens|exact|float:[0-9.eE+-]{1,24}', checker):
            raise Failure('대조 방식이 잘못되었습니다.', 15)
        if checker.startswith('float:'):
            try:
                epsilon = float(checker[6:])
            except ValueError:
                raise Failure('허용 오차가 잘못되었습니다.', 15) from None
            if not math.isfinite(epsilon) or epsilon <= 0:
                raise Failure('허용 오차가 잘못되었습니다.', 15)
    return data


SPACE = re.compile(r'[\u0009-\u000d\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+')
NUMBER = re.compile(r'[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?')


def compare(actual, expected, checker):
    if checker == 'exact':
        return 'AC' if actual == expected else 'PE' if SPACE.sub('', actual) == SPACE.sub('', expected) else 'WA'
    a, b = [v for v in SPACE.split(actual) if v], [v for v in SPACE.split(expected) if v]
    if checker == 'tokens':
        return 'AC' if a == b else 'WA'
    if len(a) != len(b):
        return 'WA'
    eps = float(checker[6:])
    for x, y in zip(a, b):
        if NUMBER.fullmatch(y):
            if not NUMBER.fullmatch(x) or not math.isfinite(float(x)) or not math.isfinite(float(y)) or abs(float(x) - float(y)) > eps:
                return 'WA'
        elif x != y:
            return 'WA'
    return 'AC'


def check(client):
    print('항목\t결과\t최소 버전 / 고칠 방법')
    print('Python\t' + platform.python_version() + '\t3.9+')
    versions, good = {}, True
    for lang in ('go', 'rust'):
        try:
            tool, version = compiler(lang)
            with tempfile.TemporaryDirectory(prefix='devin-check-') as directory:
                source = Path(directory) / ('hello.go' if lang == 'go' else 'hello.rs')
                source.write_bytes(HELLO[lang].encode('utf-8'))
                binary = build(lang, tool, source, directory)
                status, out, _ = run_process([str(binary)], '', 5, directory)
                if status != 'OK' or out.strip() != 'devin-ready':
                    raise Failure('내장 hello 실행 실패. 컴파일러 설치를 확인하세요.')
            versions[lang] = version
            print('%s\t%s · hello 통과\t%s' % (lang, version, '.'.join(map(str, MINIMUM[lang]))))
        except (Failure, OSError) as error:
            good = False
            print('%s\t실패\t%s' % (lang, error))
    try:
        if client.request('/health').get('status') != 'ok':
            raise Failure('연결 응답을 확인하세요.')
        print('HTTPS\t통과\t' + client.base)
        if client.token:
            client.request('/token', authenticated=True)
            print('토큰\t유효\t제출·준비 기록 전용')
        else:
            print('토큰\t미설정\t연습 가능 · 인증 제출에는 --token-file 필요')
    except Failure as error:
        good = False
        print('연결/토큰\t실패\t' + str(error))
    if good:
        if client.token:
            client.request('/ready', {'go': versions['go'], 'rust': versions['rust'], 'os': os_kind()}, True)
        print('준비 완료 — 이제 Go·Rust 문제를 풀 수 있습니다. 예) devin-run 3001 main.go')
        return 0
    return 16


def certificate_help():
    """HTTPS 인증서를 확인하지 못했을 때 고치는 방법. 검사를 끄는 옵션은 두지 않는다."""
    lines = ['HTTPS 인증서를 확인하지 못했습니다(검사를 끄는 옵션은 없습니다).']
    if platform.system() == 'Darwin' and '/Library/Frameworks/Python.framework/' in sys.executable:
        lines.append('macOS python.org 설치본은 인증서를 따로 설치해야 합니다. Finder에서 "/Applications/Python %d.%d/Install Certificates.command" 를 한 번 실행한 뒤 다시 시도하세요.' % sys.version_info[:2])
    lines.append('회사·학교 네트워크의 보안 프록시가 인증서를 바꾸는 경우 네트워크 관리자에게 문의하세요.')
    return ' '.join(lines)


def install_kind():
    """어떻게 설치됐는지 추정만 한다(지우지 않는다)."""
    here = Path(__file__).resolve()
    if 'pipx' in str(Path(sys.prefix)).lower():
        return 'pipx'
    if 'site-packages' in here.parts or 'dist-packages' in here.parts:
        return 'pip'
    return 'file'


def cleanup(args):
    """정리 방법만 안내한다. 파일을 지우거나 네트워크에 접속하지 않는다."""
    win = os.name == 'nt'
    py = 'py' if win else 'python3'
    kind = install_kind()
    print('devin-run 정리 안내 — 이 명령은 아무것도 지우지 않습니다. 아래를 직접 실행하세요.')
    print('운영체제\t%s' % {'Windows': 'Windows', 'Darwin': 'macOS', 'Linux': 'Linux'}.get(platform.system(), platform.system() or '알 수 없음'))
    if kind == 'pipx':
        print('1. 도구 제거\tpipx uninstall devinkr-run')
    elif kind == 'pip':
        print('1. 도구 제거\t%s -m pip uninstall devinkr-run' % py)
    else:
        print('1. 도구 제거\t받은 파일을 지웁니다: %s' % ('del ' if win else 'rm ') + '"' + str(Path(__file__).resolve()) + '"')
    token = args.token_file or 'devin-token.txt'
    print('2. 토큰 파일\t%s' % (('del "%s"' if win else 'rm "%s"') % token))
    print('3. 토큰 폐기\t파일을 지워도 토큰은 폐기 전까지 유효합니다. %s/settings 의 CLI 토큰에서 폐기하세요.' % args.base_url.rstrip('/'))
    print('4. 임시 폴더\t실행할 때 만든 devin-run-* 폴더는 끝나면 자동으로 지워집니다. 남길 것이 없습니다.')
    print('5. 언어 도구\tPython·Go·Rust 는 코드패스 전용이 아닙니다. 다른 곳에 쓰지 않을 때만 각 공식 제거 방법을 따르세요.')
    return 0


def os_kind():
    name = platform.system()
    return name if name in ('Windows', 'Darwin', 'Linux', 'FreeBSD') else 'Other'


def execute(args, client):
    source = Path(args.source).resolve()
    lang = args.lang or {'.go': 'go', '.rs': 'rust'}.get(source.suffix.lower())
    if not source.is_file():
        raise Failure('소스 파일이 없습니다. 파일 경로를 확인하세요.', 17)
    if lang is None or source.suffix.lower() != ('.go' if lang == 'go' else '.rs'):
        raise Failure('Go는 .go, Rust는 .rs 파일을 지정하세요. --lang과 확장자가 같아야 합니다.', 18)
    raw = source.read_bytes()
    if len(raw) > 65536:
        raise Failure('소스는 UTF-8 64KB 이하여야 합니다.', 17)
    code = raw.decode('utf-8')
    tool, version = compiler(lang)
    path = '/problems/%s/' % args.problem
    request = {'lang': lang, 'codeHash': hashlib.sha256(raw).hexdigest()} if args.submit else None
    data = bundle(client.request(path + ('challenge' if args.submit else 'practice'), request, args.submit), args.submit)
    with tempfile.TemporaryDirectory(prefix='devin-run-') as directory:
        # 해시한 바이트를 그대로 컴파일. 서버 파일명이나 소스 경로는 받지 않는다.
        local = Path(directory) / ('main.go' if lang == 'go' else 'main.rs')
        local.write_bytes(raw)
        try:
            binary = build(lang, tool, local, directory)
            results = []
            for index, value in enumerate(data['inputs']):
                status, out, _ = run_process([str(binary)], value, data['timeLimitMs'] / 1000, directory)
                results.append(out if status == 'OK' else {'status': status})
                verdict = status if status != 'OK' else ('출력 수집' if args.submit else compare(out, data['outputs'][index], data['checker']))
                print('%d\t%s' % (index + 1, verdict))
        except Failure as error:
            if error.code != 20:
                raise
            print(str(error))
            if not args.submit:
                return 20
            results = [{'status': 'CE'} for _ in data['inputs']]
    if args.submit:
        response = client.request(path + 'submit', {'submission': {'token': data['token'], 'lang': lang, 'code': code,
                                  'results': results, 'aiAssisted': False, 'clientTimeMs': None}, 'version': version}, True)
        verdict = response.get('verdict')
        if verdict not in ('AC', 'WA', 'PE', 'CE', 'RE', 'TLE', 'OLE'):
            raise Failure('판정 응답을 확인하세요.', 15)
        print('인증 제출: ' + verdict + ' · 실행 시간은 사용자 PC 기준 참고입니다. 내 제출에서 확인하세요.')
        return 0 if verdict == 'AC' else 21
    return 0 if all(isinstance(v, str) and compare(v, e, data['checker']) == 'AC' for v, e in zip(results, data['outputs'])) else 21


def main(argv=None):
    parser = argparse.ArgumentParser(description='devin-run ' + VERSION + ' · Go/Rust를 내 터미널에서 실행합니다. 자동 업데이트 없음.', epilog='이 파일 sha256: ' + hashlib.sha256(Path(__file__).read_bytes()).hexdigest() + ' · 사이트와 GitHub 릴리스의 sha256이 같은지 확인하세요.')
    parser.add_argument('problem', help='문제 번호, check(준비 확인) 또는 cleanup(정리 방법 안내)')
    parser.add_argument('source', nargs='?')
    parser.add_argument('--lang', choices=['go', 'rust'])
    parser.add_argument('--submit', action='store_true')
    parser.add_argument('--token-file')
    parser.add_argument('--base-url', default='https://www.devin.kr/judge', help='HTTPS 코드패스 기본 주소')
    parser.add_argument('--version', action='version', version=VERSION)
    args = parser.parse_args(argv)
    try:
        if sys.version_info < (3, 9):
            raise Failure('Python 3.9 이상을 설치하세요. https://www.python.org/downloads/', 9)
        if args.problem == 'cleanup':
            return cleanup(args)
        client = Client(args.base_url, token_from(args.token_file))
        if args.problem == 'check':
            return check(client)
        if not re.fullmatch(r'[1-9][0-9]{0,8}', args.problem) or not args.source:
            raise Failure('사용: devin-run <문제번호> <소스파일> [--submit --token-file 파일]', 17)
        return execute(args, client)
    except (Failure, OSError, UnicodeError, ValueError) as error:
        print(str(error), file=sys.stderr)
        return error.code if isinstance(error, Failure) else 1


if __name__ == '__main__':
    sys.exit(main())
