#!/usr/bin/env python3
"""Repair FinBot strategy binding, without executing strategy or trading code."""
import argparse
import ast
from datetime import datetime,timezone
import hashlib
import json
import os
from pathlib import Path
import shutil
import tempfile

DEFAULT=Path('/Users/bpa/Desktop/ForesixaQuant/FinBot_Century_Gauntlet')
CALLBACKS=('decide','capital_floor')
ENTRY='finbot_strategy_entry'

class RepairError(ValueError):pass

def read_object(path):
    try:value=json.loads(path.read_text())
    except (OSError,ValueError):raise RepairError('CONFIG_UNREADABLE:'+path.name) from None
    if not isinstance(value,dict):raise RepairError('CONFIG_OBJECT_REQUIRED:'+path.name)
    return value

def project_root(explicit=None):
    if explicit:
        candidates=[Path(explicit).expanduser()]
    else:
        here=Path(__file__).resolve().parent
        candidates=[DEFAULT,here,here.parent,Path.cwd(),Path.cwd().parent]
    for path in candidates:
        path=path.resolve()
        if (path/'host_replay_adapter.py').is_file() and (path/'replay_config.json').is_file() and (path/'gauntlet.py').is_file():return path
    raise RepairError('PROJECT_NOT_FOUND: expected '+str(DEFAULT))

def inspect_source(path):
    if path.is_symlink():raise RepairError('STRATEGY_SYMLINK_REQUIRES_REVIEW')
    raw=path.read_bytes()
    if len(raw)>2_000_000:raise RepairError('STRATEGY_SOURCE_TOO_LARGE')
    try:tree=ast.parse(raw,filename=path.name)
    except (SyntaxError,ValueError):raise RepairError('STRATEGY_SYNTAX_INVALID:'+path.name) from None
    declarations={};classes=[]
    for node in tree.body:
        if isinstance(node,ast.ClassDef):classes.append(node.name)
        if isinstance(node,(ast.FunctionDef,ast.AsyncFunctionDef)):
            if node.name not in CALLBACKS:continue
            if isinstance(node,ast.AsyncFunctionDef):raise RepairError('SYNC_CALLBACK_REQUIRED:'+node.name)
            args=node.args;pos=args.posonlyargs+args.args
            required=len(pos)-len(args.defaults)
            if required>1 or not pos and args.vararg is None or any(default is None for default in args.kw_defaults):
                raise RepairError('CALLBACK_MUST_ACCEPT_ONE_CONTEXT:'+node.name)
            declarations[node.name]='DECLARED_FUNCTION_NOT_EXECUTED'
        if isinstance(node,ast.ImportFrom):
            for alias in node.names:
                name=alias.asname or alias.name
                if name in CALLBACKS:declarations[name]='IMPORTED_CALLBACK_RUNTIME_CHECK_REQUIRED'
    missing=[name for name in CALLBACKS if name not in declarations]
    if missing:
        raise RepairError('STRATEGY_INTERFACE_MISSING:'+','.join(missing)+('; class methods are not module callbacks' if classes else ''))
    return {'source_sha256':hashlib.sha256(raw).hexdigest(),'callbacks':declarations}

def source_from_config(root,config):
    # Repeated repairs must resolve the original file, never our bridge itself.
    if config.get('strategy_module')==ENTRY:
        mapfile=root/'strategy_mapping.json'
        if mapfile.is_file():
            value=read_object(mapfile);source=Path(value['source_file'])
            if source.is_file():return source
    explicit=config.get('strategy_file')
    if isinstance(explicit,str) and explicit.strip():
        source=Path(explicit).expanduser()
        if not source.is_absolute():source=root/source
        if source.is_file():return source
        raise RepairError('CONFIGURED_STRATEGY_FILE_NOT_FOUND')
    name=config.get('strategy_module')
    if isinstance(name,str) and name.strip().lower() not in ('','none','null'):
        name=name.strip()
        if name.endswith('.py') or '/' in name:
            source=Path(name).expanduser()
            if not source.is_absolute():source=root/source
        else:source=root.joinpath(*name.split('.')).with_suffix('.py')
        if source.is_file():return source
        raise RepairError('CONFIGURED_STRATEGY_MODULE_NOT_FOUND:'+name)
    return None

def choose_source(root,config):
    source=source_from_config(root,config)
    if source is not None:return source,inspect_source(source)
    # Search the named project first; parent is the master's explicit ForesixaQuant folder.
    for folder in (root,root.parent):
        existing=[folder/name for name in ('host_strategy.py','strategy.py') if (folder/name).is_file()]
        if not existing:continue
        valid=[];errors=[]
        for path in existing:
            try:valid.append((path,inspect_source(path)))
            except RepairError as e:errors.append(str(e))
        if len(valid)==1:return valid[0]
        if len(valid)>1:
            if len({item[1]['source_sha256'] for item in valid})==1:return valid[0]
            raise RepairError('AMBIGUOUS_STRATEGY: host_strategy.py and strategy.py differ; existing strategy selection required')
        raise RepairError('; '.join(errors))
    raise RepairError('NO_STRATEGY_SOURCE_FOUND: host_strategy.py or strategy.py must contain the real implementation')

BRIDGE='''"""Binding to the selected real strategy; import itself does not execute it."""
import hashlib
import importlib.util
import json
from pathlib import Path
import sys
_mapping=json.loads(Path(__file__).with_name('strategy_mapping.json').read_text())
_source=Path(_mapping['source_file'])
_loaded=None

def _load():
    global _loaded
    if _loaded is not None:return _loaded
    if not _source.is_file():raise ImportError('MAPPED_STRATEGY_SOURCE_MISSING')
    if hashlib.sha256(_source.read_bytes()).hexdigest()!=_mapping['source_sha256']:
        raise ImportError('STRATEGY_SOURCE_CHANGED_REPAIR_REQUIRED')
    if str(_source.parent) not in sys.path:sys.path.insert(0,str(_source.parent))
    spec=importlib.util.spec_from_file_location('_finbot_selected_host_strategy',_source)
    if spec is None or spec.loader is None:raise ImportError('STRATEGY_LOAD_SPEC_MISSING')
    strategy=importlib.util.module_from_spec(spec);sys.modules[spec.name]=strategy
    spec.loader.exec_module(strategy)
    for name in ('decide','capital_floor'):
        if not callable(getattr(strategy,name,None)):raise ImportError('STRATEGY_CALLBACK_NOT_CALLABLE:'+name)
    _loaded=strategy
    return strategy

def decide(context):return _load().decide(context)
def capital_floor(context):return _load().capital_floor(context)
# Preserve the original adapter's strategy-file hash provenance.
__file__=str(_source)
'''


def atomic_write(path,content):
    if path.is_symlink():raise RepairError('CONFIG_SYMLINK_REQUIRES_REVIEW:'+path.name)
    fd,temp=tempfile.mkstemp(prefix='.mapping-',dir=path.parent)
    try:
        with os.fdopen(fd,'w') as f:f.write(content);f.flush();os.fsync(f.fileno())
        os.chmod(temp,0o600);os.replace(temp,path)
    finally:
        if os.path.exists(temp):os.unlink(temp)

def repair(root,check=False):
    config_path=root/'replay_config.json';config=read_object(config_path)
    source,details=choose_source(root,config);source=source.absolute()
    report={'project':str(root),'selected_source':str(source),**details,'strategy_executed_by_repair':False,'replay_started':False,'attempt_history_changed':False}
    targets={root/'strategy_mapping.json':json.dumps({'source_file':str(source),'source_sha256':details['source_sha256']},indent=2)+'\n',root/(ENTRY+'.py'):BRIDGE}
    new=dict(config);new.update(strategy_module=ENTRY,strategy_file=str(source));targets[config_path]=json.dumps(new,indent=2)+'\n'
    isolation=root/'replay_isolation.json'
    if isolation.is_file():
        isolated=read_object(isolation);isolated['strategy_file']=str(source);targets[isolation]=json.dumps(isolated,indent=2)+'\n';report['existing_container_mapping_updated']=True
    else:report['existing_container_mapping_updated']=False
    report['hardened_runner_detected']=(root/'isolated_replay.py').is_file() and 'execute_isolated' in (root/'gauntlet.py').read_text()
    existing_bridge=root/(ENTRY+'.py')
    if existing_bridge.is_file() and existing_bridge.read_text()!=BRIDGE:raise RepairError('RESERVED_BRIDGE_FILE_ALREADY_EXISTS_REVIEW_REQUIRED')
    changed=[p for p,text in targets.items() if not p.is_file() or p.read_text()!=text]
    for path in targets:
        if path.is_symlink():raise RepairError('CONFIG_SYMLINK_REQUIRES_REVIEW:'+path.name)
    if check:report.update(status='CHECK_ONLY',mapping_change_required=bool(changed));return report
    if not changed:report.update(status='ALREADY_MAPPED',restart_required=False);return report
    stamp=datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S%fZ');backup=root/'backups'/('strategy_mapping_'+stamp);backup.mkdir(parents=True,mode=0o700)
    originals={path:path.read_bytes() if path.exists() else None for path in changed}
    for path,raw in originals.items():
        if raw is not None:
            out=backup/path.name;out.write_bytes(raw);os.chmod(out,0o600)
    try:
        # Mapping/config writes are atomic individually; restore the group on an exception.
        for path in changed:atomic_write(path,targets[path])
    except Exception:
        for path,raw in originals.items():
            if raw is None:path.unlink(missing_ok=True)
            else:atomic_write(path,raw.decode('utf-8'))
        raise
    report.update(status='MAPPING_REPAIRED',backup=str(backup),restart_required=True)
    atomic_write(root/'strategy_mapping_report.json',json.dumps(report,indent=2)+'\n')
    return report

def main():
    p=argparse.ArgumentParser(description=__doc__);p.add_argument('--project',type=Path);p.add_argument('--check',action='store_true');a=p.parse_args()
    try:
        result=repair(project_root(a.project),a.check);print(json.dumps(result,indent=2));return 0
    except (RepairError,OSError,KeyError) as e:
        # Do not print private source text, imports, passwords or provider error bodies.
        message=str(e) if isinstance(e,RepairError) else type(e).__name__
        print(json.dumps({'status':'REPAIR_BLOCKED','reason':message,'replay_started':False}));return 2
if __name__=='__main__':raise SystemExit(main())
