#!/usr/bin/env python3
"""Continuously mine SAAVECORD using an existing node and a bounded helper.

The JSON configuration contains rpc_datadir (the root data directory), rpc_port,
chain, expected_genesis, output_script (hex), grinder, interval_seconds, threads,
and batch_max_nonces. Relative paths resolve beside the configuration file.
The service and its helper inherit their priority from the service manager.
"""
import argparse
import base64
import hashlib
import importlib.util
import http.client
import json
from pathlib import Path
import secrets
import signal
import struct
import subprocess
import threading
import time


EXPECTED_GENESIS = '000000d5834ff77f43e6d8b2e5f1811bd47e0ed5ac486e49b57dbf9803eb0550'
RPC_TIMEOUT_SECONDS = 10
RETRY_SECONDS = 5
POLL_SECONDS = 5
HELPER_TIMEOUT_SECONDS = 15
MAX_RPC_BYTES = 32 * 1024 * 1024
ALLOWED_RPC = frozenset(('getblockchaininfo', 'getblockhash', 'getbestblockhash',
                         'getblockheader', 'getblocktemplate', 'submitblock'))


class MinerError(RuntimeError):
    """A local invariant or configuration error, with a credential-free message."""


class RpcUnavailable(RuntimeError):
    def __init__(self, method, reason, code=None):
        self.method = method
        self.reason = reason
        self.code = code
        super().__init__('Local RPC is unavailable')


class HelperTimeout(RuntimeError):
    pass


def emit(event, **fields):
    print(json.dumps({'time_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
                      'event': event, **fields}, sort_keys=True), flush=True)


def integer(value, name, minimum, maximum):
    if type(value) is not int or not minimum <= value <= maximum:
        raise MinerError(name + ' is outside its allowed integer range')
    return value


def load_config(path):
    path = path.expanduser().resolve()
    try:
        config = json.loads(path.read_text(encoding='utf-8'))
    except (OSError, UnicodeError, ValueError):
        raise MinerError('Cannot read the JSON configuration') from None
    if not isinstance(config, dict):
        raise MinerError('Configuration must be a JSON object')
    if config.get('chain', 'saavecord') != 'saavecord':
        raise MinerError('This service requires chain saavecord')
    if config.get('expected_genesis', EXPECTED_GENESIS) != EXPECTED_GENESIS:
        raise MinerError('expected_genesis does not match the current SAAVECORD chain')
    config['chain'] = 'saavecord'
    config['expected_genesis'] = EXPECTED_GENESIS
    for field in ('rpc_datadir', 'grinder'):
        value = config.get(field)
        if not isinstance(value, str) or not value:
            raise MinerError(field + ' must be a nonempty path')
        value = Path(value).expanduser()
        config[field] = (path.parent / value).resolve() if not value.is_absolute() else value.resolve()
    if not config['grinder'].is_file():
        raise MinerError('The configured grinder file does not exist')
    config['rpc_port'] = integer(config.get('rpc_port', 19334), 'rpc_port', 1, 65535)
    config['interval_seconds'] = integer(config.get('interval_seconds', 600),
                                         'interval_seconds', 1, 86400)
    config['threads'] = integer(config.get('threads', 2), 'threads', 1, 2)
    config['batch_max_nonces'] = integer(config.get('batch_max_nonces', 1000000),
                                         'batch_max_nonces', 1, 1000000)
    script = config.get('output_script')
    if not isinstance(script, str) or len(script) % 2:
        raise MinerError('output_script must be hexadecimal bytes')
    try:
        script = bytes.fromhex(script)
    except ValueError:
        raise MinerError('output_script must be hexadecimal bytes') from None
    if not 1 <= len(script) <= 10000:
        raise MinerError('output_script must contain between 1 and 10000 bytes')
    config['output_script'] = script
    return config


def load_template_library():
    directory = Path(__file__).resolve().parent
    for filename in ('verify_lib.py', 'private_network_verify.py'):
        path = directory / filename
        if not path.is_file():
            continue
        specification = importlib.util.spec_from_file_location('saavecord_verify_lib', path)
        module = importlib.util.module_from_spec(specification)
        specification.loader.exec_module(module)
        if not callable(getattr(module, 'mining_template', None)) or not callable(getattr(module, 'hash256', None)):
            raise MinerError('The template library lacks mining_template or hash256')
        return module
    raise MinerError('A sibling verify_lib.py template library is required')


class Rpc:
    def __init__(self, config):
        # The template library needs an explicit chain context to enforce the
        # SAAVECORD-specific founder payment without changing other networks.
        self.chain = config['chain']
        self.cookie = config['rpc_datadir'] / config['chain'] / '.cookie'
        self.port = config['rpc_port']
        self.sequence = 0
        self.cookie_generation = 0
        self.identity_generation = None
        self.cookie_fingerprint = None

    def call(self, method, params=None):
        if method not in ALLOWED_RPC:
            raise MinerError('An unsupported RPC method was requested')
        self.sequence += 1
        request_id = self.sequence
        connection = None
        try:
            # Read for every request, including immediately after a node restart.
            cookie = self.cookie.read_text(encoding='utf-8').strip()
            if ':' not in cookie or len(cookie) > 1024 or any(ord(c) < 32 for c in cookie):
                raise RpcUnavailable(method, 'cookie_unavailable')
            fingerprint = hashlib.sha256(cookie.encode('utf-8')).digest()
            if fingerprint != self.cookie_fingerprint:
                self.cookie_fingerprint = fingerprint
                self.cookie_generation += 1
            identity_call = method == 'getblockchaininfo' or (method == 'getblockhash' and params == [0])
            if not identity_call and self.identity_generation != self.cookie_generation:
                raise RpcUnavailable(method, 'identity_check_required')
            authorization = 'Basic ' + base64.b64encode(cookie.encode('utf-8')).decode('ascii')
            connection = http.client.HTTPConnection('127.0.0.1', self.port,
                                                     timeout=RPC_TIMEOUT_SECONDS)
            body = json.dumps({'jsonrpc': '2.0', 'id': request_id, 'method': method,
                               'params': [] if params is None else params})
            connection.request('POST', '/', body, {'Authorization': authorization,
                                                  'Content-Type': 'application/json'})
            response = connection.getresponse()
            payload = response.read(MAX_RPC_BYTES + 1)
            if response.status != 200:
                raise RpcUnavailable(method, 'http_status', response.status)
            if len(payload) > MAX_RPC_BYTES:
                raise RpcUnavailable(method, 'response_too_large')
            data = json.loads(payload)
            if not isinstance(data, dict) or data.get('id') != request_id:
                raise RpcUnavailable(method, 'invalid_response')
            if data.get('error') is not None:
                error = data['error']
                code = error.get('code') if isinstance(error, dict) else None
                if type(code) is not int:
                    code = None
                # RPC messages are deliberately omitted from logs and exceptions.
                raise RpcUnavailable(method, 'rpc_error', code)
            if 'result' not in data:
                raise RpcUnavailable(method, 'invalid_response')
            return data['result']
        except RpcUnavailable:
            raise
        except (OSError, UnicodeError, ValueError, http.client.HTTPException):
            # In particular, never stringify an HTTP exception carrying headers.
            raise RpcUnavailable(method, 'transport_or_cookie_error') from None
        finally:
            if connection is not None:
                connection.close()


class Miner:
    def __init__(self, config, library, stop_event, once=False):
        self.config = config
        self.library = library
        self.stop_event = stop_event
        self.once = once
        self.rpc = Rpc(config)
        self.verified = False
        self.tip = None
        self.waiting_tip = None
        self.extra_nonce = secrets.randbits(64)
        self.pending = None
        self.total_hashes = 0
        self.last_progress = time.monotonic()

    def verify_identity(self):
        info = self.rpc.call('getblockchaininfo')
        generation = self.rpc.cookie_generation
        genesis = self.rpc.call('getblockhash', [0])
        if self.rpc.cookie_generation != generation:
            raise RpcUnavailable('getblockchaininfo', 'cookie_rotated_during_identity_check')
        if info.get('chain') != self.config['chain'] or genesis != EXPECTED_GENESIS:
            raise MinerError('The RPC node has a different chain or genesis')
        self.rpc.identity_generation = generation
        self.verified = True
        self.tip = None
        emit('node_verified', chain=info['chain'], genesis=genesis, height=info['blocks'])

    def read_tip(self):
        block_hash = self.rpc.call('getbestblockhash')
        if self.tip is None or self.tip['hash'] != block_hash:
            header = self.rpc.call('getblockheader', [block_hash, True])
            if header.get('hash') != block_hash:
                raise MinerError('The node returned an inconsistent tip header')
            self.tip = {'hash': block_hash,
                        'height': integer(header['height'], 'tip height', 0, 2147483647),
                        'time': integer(header['time'], 'tip time', 0, 4294967295)}
        return self.tip

    @staticmethod
    def kill_child(child):
        if child.poll() is None:
            child.kill()
        try:
            child.communicate(timeout=1)
        except subprocess.TimeoutExpired:
            raise MinerError('The miner could not reap its own helper process') from None

    def grind(self, header, target):
        limit = self.config['batch_max_nonces']
        command = [str(self.config['grinder']), '--solve', header.hex(), target,
                   str(limit), str(self.config['threads'])]
        child = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                 text=True)
        deadline = time.monotonic() + HELPER_TIMEOUT_SECONDS
        try:
            while True:
                if self.stop_event.is_set():
                    self.kill_child(child)
                    return None
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    self.kill_child(child)
                    raise HelperTimeout()
                try:
                    stdout, _stderr = child.communicate(timeout=min(0.25, remaining))
                    break
                except subprocess.TimeoutExpired:
                    continue
        finally:
            if child.poll() is None:
                self.kill_child(child)
        if child.returncode not in (0, 2):
            raise MinerError('The header grinder returned an error')
        try:
            result = json.loads(stdout)
        except ValueError:
            raise MinerError('The header grinder returned invalid JSON') from None
        if not isinstance(result, dict) or type(result.get('found')) is not bool:
            raise MinerError('The header grinder returned an invalid result')
        hashes = integer(result.get('hashes'), 'grinder hash count', 1, limit)
        if (child.returncode == 0) != result['found']:
            raise MinerError('The grinder exit status disagrees with its result')
        if not result['found'] and hashes != limit:
            raise MinerError('The grinder stopped before completing its batch')
        self.total_hashes += hashes
        return result

    def checked_solution(self, result, header, template):
        try:
            solved = bytes.fromhex(result['header'])
            target = int(template['target'], 16)
        except (KeyError, TypeError, ValueError):
            raise MinerError('The grinder returned an invalid solved header') from None
        if len(solved) != 80 or solved[:76] != header[:76]:
            raise MinerError('The grinder changed fields outside the header nonce')
        block_hash = self.library.hash256(solved)[::-1].hex()
        if block_hash != result.get('hash') or not 0 < target < 2**256 or int(block_hash, 16) > target:
            raise MinerError('Independent proof-of-work verification failed')
        nonce = struct.unpack('<I', solved[76:80])[0]
        if type(result.get('nonce')) is not int or result['nonce'] != nonce:
            raise MinerError('The grinder returned inconsistent nonce information')
        if nonce >= self.config['batch_max_nonces']:
            raise MinerError('The solved nonce is outside the configured batch')
        return solved, block_hash

    def finish_pending(self):
        pending = self.pending
        tip = self.read_tip()
        # An RPC response can be lost after acceptance. Recognize that block first.
        if tip['height'] >= pending['height']:
            if self.rpc.call('getblockhash', [pending['height']]) == pending['hash']:
                return self.record_accepted()
            self.pending = None
            emit('solution_stale', height=pending['height'], hash=pending['hash'])
            return False
        if tip['hash'] != pending['previous']:
            self.pending = None
            emit('solution_stale', height=pending['height'], hash=pending['hash'])
            return False
        if self.stop_event.is_set():
            return False
        rejection = self.rpc.call('submitblock', [pending['block']])
        if rejection is not None and rejection not in ('duplicate', 'duplicate-inconclusive'):
            self.pending = None
            emit('block_rejected', height=pending['height'], hash=pending['hash'])
            return False
        self.tip = None
        tip = self.read_tip()
        if tip['height'] >= pending['height'] and self.rpc.call('getblockhash', [pending['height']]) == pending['hash']:
            return self.record_accepted()
        self.pending = None
        emit('block_not_active', height=pending['height'], hash=pending['hash'])
        return False

    def record_accepted(self):
        pending = self.pending
        self.pending = None
        emit('block_accepted', height=pending['height'], hash=pending['hash'],
             hashes_in_batch=pending['hashes'], total_hashes=self.total_hashes)
        return True

    def run(self):
        emit('miner_started', chain=self.config['chain'], rpc_host='127.0.0.1',
             rpc_port=self.config['rpc_port'], threads=self.config['threads'],
             interval_seconds=self.config['interval_seconds'],
             batch_max_nonces=self.config['batch_max_nonces'], once=self.once)
        while not self.stop_event.is_set():
            try:
                if not self.verified:
                    self.verify_identity()
                if self.pending is not None and self.finish_pending() and self.once:
                    return 0
                if self.stop_event.is_set():
                    break
                tip = self.read_tip()
                remaining = tip['time'] + self.config['interval_seconds'] - time.time()
                if remaining > 0:
                    if self.waiting_tip != tip['hash']:
                        self.waiting_tip = tip['hash']
                        emit('waiting_for_interval', height=tip['height'], tip=tip['hash'],
                             mine_after_unix=tip['time'] + self.config['interval_seconds'])
                    self.stop_event.wait(min(POLL_SECONDS, remaining))
                    continue
                self.extra_nonce = (self.extra_nonce + 1) & 0xffffffffffffffff
                header, body, template = self.library.mining_template(
                    self.rpc, self.config['output_script'], self.extra_nonce)
                if template['previousblockhash'] != tip['hash']:
                    continue
                # Check immediately before starting, and again before submission.
                if self.rpc.call('getbestblockhash') != template['previousblockhash']:
                    continue
                if self.stop_event.is_set():
                    break
                result = self.grind(header, template['target'])
                if result is None or self.stop_event.is_set():
                    break
                if not result['found']:
                    if time.monotonic() - self.last_progress >= 60:
                        self.last_progress = time.monotonic()
                        emit('mining_progress', height=template['height'],
                             total_hashes=self.total_hashes)
                    continue
                solved, block_hash = self.checked_solution(result, header, template)
                if self.rpc.call('getbestblockhash') != template['previousblockhash']:
                    emit('solution_stale', height=template['height'], hash=block_hash)
                    continue
                self.pending = {'height': template['height'], 'hash': block_hash,
                                'previous': template['previousblockhash'],
                                'block': (solved + body).hex(), 'hashes': result['hashes']}
                if self.finish_pending() and self.once:
                    return 0
            except RpcUnavailable as error:
                self.verified = False
                self.tip = None
                emit('rpc_retry', method=error.method, reason=error.reason,
                     code=error.code, retry_seconds=RETRY_SECONDS)
                self.stop_event.wait(RETRY_SECONDS)
            except HelperTimeout:
                emit('grinder_timeout', maximum_seconds=HELPER_TIMEOUT_SECONDS,
                     retry_seconds=RETRY_SECONDS)
                self.stop_event.wait(RETRY_SECONDS)
        return 0


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--config', type=Path, required=True)
    parser.add_argument('--once', action='store_true', help='Exit after the first accepted block')
    args = parser.parse_args()
    stop_event = threading.Event()

    def request_stop(_signum, _frame):
        stop_event.set()

    signal.signal(signal.SIGTERM, request_stop)
    signal.signal(signal.SIGINT, request_stop)
    exit_code = 1
    try:
        config = load_config(args.config)
        library = load_template_library()
        exit_code = Miner(config, library, stop_event, once=args.once).run()
    except MinerError as error:
        emit('fatal_error', reason=str(error))
    except Exception as error:
        # Do not dump exception text, RPC payloads, headers, or credentials.
        emit('fatal_error', reason='unexpected_local_error', error_type=type(error).__name__)
    finally:
        emit('miner_stopped', exit_code=exit_code, requested=stop_event.is_set())
    return exit_code


if __name__ == '__main__':
    raise SystemExit(main())
