import json
import pathlib
import subprocess
import unittest


ROOT = pathlib.Path(__file__).parents[1]


def run_php(source):
    result = subprocess.run(["php", "-r", source], capture_output=True, text=True)
    if result.returncode != 0:
        raise AssertionError(result.stderr or result.stdout)
    return json.loads(result.stdout)


class ResultsStoreResilienceTests(unittest.TestCase):
    def test_store_retries_a_failed_upsert_after_reconnecting(self):
        source = (
            f"require {json.dumps(str(ROOT / 'classes' / 'ResultsV2.php'))}; "
            f"require {json.dumps(str(ROOT / 'classes' / 'MysqliHelper.php'))}; "
            f"require {json.dumps(str(ROOT / 'classes' / 'ResultsV2Store.php'))}; "
            "class FakeDb { public $ok; public $writes=0; public $points=[]; "
            "function __construct($ok){$this->ok=$ok;} "
            "function mysqli_update($sql,$params){$this->writes++;if(!$this->ok)return false;"
            "$v=$params['values'];$this->points[(string)$v[1]]=['timestamp'=>$v[1],'wallet_balance'=>$v[2],'unrealized_pnl'=>$v[3]];return true;} "
            "function mysqli_fetch($sql,$params){return false;} "
            "function mysqli_fetch_all($sql,$params){return array_values($this->points);} } "
            "$first=new FakeDb(false);$second=new FakeDb(true);"
            "$store=new ResultsV2Store($first,function()use($second){return $second;},2);"
            "$ok=$store->persistEquityPoints(392269,[['timestamp'=>10,'wallet_balance'=>100,'unrealized_pnl'=>0]]);"
            "echo json_encode([$ok,$first->writes,$second->writes,count($second->points)]);"
        )
        self.assertEqual([True, 1, 1, 1], run_php(source))

    def test_equity_reconciliation_is_idempotent_and_verifies_rows(self):
        source = (
            f"require {json.dumps(str(ROOT / 'classes' / 'ResultsV2.php'))}; "
            f"require {json.dumps(str(ROOT / 'classes' / 'MysqliHelper.php'))}; "
            f"require {json.dumps(str(ROOT / 'classes' / 'ResultsV2Store.php'))}; "
            "class FakeDb { public $points=[]; "
            "function mysqli_update($sql,$params){$v=$params['values'];for($i=0;$i<count($v);$i+=5){$this->points[(string)$v[$i+1]]=['timestamp'=>$v[$i+1],'wallet_balance'=>$v[$i+2],'unrealized_pnl'=>$v[$i+3]];}return true;} "
            "function mysqli_fetch($sql,$params){return false;} "
            "function mysqli_fetch_all($sql,$params){return array_values($this->points);} } "
            "$db=new FakeDb();$store=new ResultsV2Store($db);"
            "$points=[['timestamp'=>10,'wallet_balance'=>100,'unrealized_pnl'=>0],['timestamp'=>20,'wallet_balance'=>101,'unrealized_pnl'=>0.25]];"
            "$first=$store->reconcileEquityPoints(392269,$points);$second=$store->reconcileEquityPoints(392269,$points);"
            "echo json_encode([$first,$second,count($db->points),$store->getEquityPoints(392269,null)]);"
        )
        first, second, count, points = run_php(source)
        self.assertTrue(first)
        self.assertTrue(second)
        self.assertEqual(2, count)
        self.assertEqual([10, 20], [point["timestamp"] for point in points])

    def test_equity_coverage_proof_requires_the_complete_timestamp_grid(self):
        source = (
            f"require {json.dumps(str(ROOT / 'classes' / 'ResultsV2.php'))}; "
            f"require {json.dumps(str(ROOT / 'classes' / 'MysqliHelper.php'))}; "
            f"require {json.dumps(str(ROOT / 'classes' / 'ResultsV2Store.php'))}; "
            "class FakeDb { public $rows=[]; "
            "function mysqli_update($sql,$params){return true;} "
            "function mysqli_fetch($sql,$params){return array_shift($this->rows) ?: false;} "
            "function mysqli_fetch_all($sql,$params){return [];} } "
            "$db=new FakeDb();$store=new ResultsV2Store($db);"
            "$db->rows=["
            "['point_count'=>3,'first_timestamp'=>60,'last_timestamp'=>180,'invalid_count'=>0],"
            "['point_count'=>2,'first_timestamp'=>60,'last_timestamp'=>180,'invalid_count'=>0],"
            "['point_count'=>3,'first_timestamp'=>60,'last_timestamp'=>180,'invalid_count'=>1]"
            "];"
            "$complete=$store->hasCompleteEquityCoverage(392269,60,180,60);"
            "$gap=$store->hasCompleteEquityCoverage(392269,60,180,60);"
            "$offGrid=$store->hasCompleteEquityCoverage(392269,60,180,60);"
            "echo json_encode([$complete,$gap,$offGrid]);"
        )
        self.assertEqual([True, False, False], run_php(source))

    def test_buffered_equity_identity_and_reason_cleanup_are_idempotent(self):
        source = (
            f"require {json.dumps(str(ROOT / 'classes' / 'ResultsV2TelemetryBuffer.php'))}; "
            "class FakeRedis { public $keys=[]; public $sets=[]; public $hashes=[]; "
            "function hset($k,$f,$v){$new=!isset($this->hashes[$k][$f]);$this->hashes[$k][$f]=$v;return $new?1:0;} "
            "function hgetall($k){return $this->hashes[$k]??[];} "
            "function jsonset($k,$p,$v){$this->keys[$k]=$v;return 'OK';} "
            "function jsonget($k){return $this->keys[$k]??null;} "
            "function jsondel($k,$p){unset($this->keys[$k]);return 1;} "
            "function sadd($k,$v){$n=!isset($this->sets[$k][$v]);$this->sets[$k][$v]=true;return $n?1:0;} "
            "function smembers($k){return array_keys($this->sets[$k]??[]);} "
            "function srem($k,$v){unset($this->sets[$k][$v]);return 1;} "
            "function del($k){unset($this->keys[$k],$this->sets[$k],$this->hashes[$k]);return 1;} "
            "function exists($k){return isset($this->keys[$k])||isset($this->sets[$k]);} "
            "function set($k,$v){$this->keys[$k]=$v;return 'OK';} "
            "function get($k){return $this->keys[$k]??null;} } "
            "$redis=new FakeRedis();$buffer=new ResultsV2TelemetryBuffer(392269,$redis);"
            "$point=['test_id'=>392269,'timestamp'=>60,'wallet_balance'=>100,'unrealized_pnl'=>0,'equity'=>100];"
            "$first=$buffer->recordEquityPoint($point);$duplicate=$buffer->recordEquityPoint($point);"
            "$buffer->addIncompleteReason('equity_point_write_failed');$cleared=$buffer->clearIncompleteReason('equity_point_write_failed');"
            "echo json_encode([$first,$duplicate,$cleared,$buffer->equityPoints(),$buffer->incompleteReasons()]);"
        )
        first, duplicate, cleared, points, reasons = run_php(source)
        self.assertTrue(first)
        self.assertTrue(duplicate)
        self.assertTrue(cleared)
        self.assertEqual(1, len(points))
        self.assertEqual([], reasons)

    def test_observer_reconciles_exact_terminal_evidence_to_complete(self):
        snapshot = {"phases": []}
        source = f"""
        class ResultsV2TelemetryBuffer {{
            public static $points = [['test_id'=>392269,'timestamp'=>60,'wallet_balance'=>100,'unrealized_pnl'=>0]];
            public static $reasons = ['equity_point_write_failed','results_store_unavailable'];
            public static $evidence = [];
            function __construct($id) {{}}
            function mark() {{ return true; }}
            function recordEquityPoint($point) {{ self::$points[] = $point; return true; }}
            function equityPoints() {{ return self::$points; }}
            function markEvidence($kind) {{ self::$evidence[$kind] = true; return true; }}
            function hasEvidence($kind) {{ return true; }}
            function clearIncompleteReason($reason) {{ self::$reasons = array_values(array_diff(self::$reasons, [$reason])); return true; }}
            function addIncompleteReason($reason) {{ if (!in_array($reason, self::$reasons, true)) self::$reasons[]=$reason; return true; }}
            function incompleteReasons() {{ return self::$reasons; }}
            function transactions() {{ return [['id'=>'392269-1','test_id'=>392269,'timestamp'=>60,'instrument_id'=>1,'order_name'=>'close_position_order','side'=>2,'amount'=>1,'position'=>0,'price'=>100,'pnl'=>0,'equity'=>100,'average_price'=>0,'type'=>2,'fee'=>0]]; }}
            function ordersCount() {{ return 1; }}
            function terminalRuntimeSeconds() {{ return 1; }}
        }}
        class FakeStore {{
            public $summary;
            public $equityCalls = 0;
            function persistEquityPoints($id,$points) {{ return true; }}
            function reconcileEquityPoints($id,$points) {{ $this->equityCalls++; return true; }}
            function hasCompleteEquityCoverage($id,$start,$end,$step) {{ return true; }}
            function getTerminalEquityPoints($id,$initial=0) {{ return ['points'=>[],'truncated'=>false,'point_count'=>1,'final_equity'=>100,'terminal_timestamp'=>60,'max_drawdown'=>0,'peak_timestamp'=>null,'trough_timestamp'=>null]; }}
            function reconcileTransactions($id,$transactions) {{ return true; }}
            function getTerminalTransactions($id) {{ return ['transactions'=>[['timestamp'=>60,'order_name'=>'close_position_order','position'=>0,'pnl'=>0]],'truncated'=>false]; }}
            function persistSummary($summary) {{ $this->summary=$summary; return true; }}
            function getSummary($id) {{ return ['data_completeness'=>$this->summary['data_completeness'],'incomplete_reasons'=>json_encode($this->summary['incomplete_reasons'])]; }}
        }}
        require {json.dumps(str(ROOT / 'classes' / 'ResultsV2.php'))};
        require {json.dumps(str(ROOT / 'classes' / 'ResultsV2Observer.php'))};
        $snapshot=json_decode({json.dumps(json.dumps(snapshot))},true);
        $state=['id'=>392269,'start_equity'=>100,'initial_equity'=>100,'equity'=>100,'fee'=>0,'position'=>0,'average_price'=>0,'timestamp'=>60,'start_date'=>60,'end_date'=>60,'candle_size'=>1,'strategy_revision_id'=>465,'runtime_snapshot_json'=>json_encode($snapshot),'runtime_content_hash'=>ResultsV2::strategySnapshotHash($snapshot)];
        $observer=(new ReflectionClass(ResultsV2Observer::class))->newInstanceWithoutConstructor();
        $property=new ReflectionProperty(ResultsV2Observer::class,'store');$property->setAccessible(true);$store=new FakeStore();$property->setValue($observer,$store);
        $ok=$observer->reconcileBufferedTerminal($state,'end_reached',true);
        echo json_encode([$ok,$store->summary['data_completeness'],$store->summary['incomplete_reasons'],$store->equityCalls]);
        """
        ok, completeness, reasons, calls = run_php(source)
        self.assertTrue(ok)
        self.assertEqual("complete", completeness)
        self.assertEqual([], reasons)
        self.assertGreaterEqual(calls, 1)

    def test_empty_buffer_with_existing_rows_fails_closed_without_clearing_reasons(self):
        snapshot = {"phases": []}
        source = f"""
        class ResultsV2TelemetryBuffer {{
            public static $reasons = ['equity_point_write_failed','results_store_unavailable','equity_evidence_missing'];
            public static $marked = 0;
            public static $cleared = [];
            function __construct($id) {{}}
            function equityPoints() {{ return []; }}
            function markEvidence($kind) {{ self::$marked++; return true; }}
            function clearIncompleteReason($reason) {{ self::$cleared[] = $reason; return true; }}
            function addIncompleteReason($reason) {{ self::$reasons[] = $reason; return true; }}
            function incompleteReasons() {{ return self::$reasons; }}
        }}
        class FakeStore {{
            public $summaryWrites = 0;
            function getTerminalEquityPoints($id,$initial=0) {{ return ['points'=>[],'truncated'=>false,'point_count'=>1]; }}
            function getSummary($id) {{ return null; }}
            function persistSummary($summary) {{ $this->summaryWrites++; return true; }}
        }}
        require {json.dumps(str(ROOT / 'classes' / 'ResultsV2.php'))};
        require {json.dumps(str(ROOT / 'classes' / 'ResultsV2Observer.php'))};
        $snapshot=json_decode({json.dumps(json.dumps(snapshot))},true);
        $state=['id'=>392269,'start_equity'=>100,'initial_equity'=>100,'equity'=>100,'fee'=>0,'position'=>0,'average_price'=>0,'timestamp'=>60,'strategy_revision_id'=>465,'runtime_snapshot_json'=>json_encode($snapshot),'runtime_content_hash'=>ResultsV2::strategySnapshotHash($snapshot)];
        $observer=(new ReflectionClass(ResultsV2Observer::class))->newInstanceWithoutConstructor();
        $property=new ReflectionProperty(ResultsV2Observer::class,'store');$property->setAccessible(true);$store=new FakeStore();$property->setValue($observer,$store);
        $ok=$observer->reconcileBufferedTerminal($state,'end_reached',true);
        echo json_encode([$ok,$store->summaryWrites,ResultsV2TelemetryBuffer::$marked,ResultsV2TelemetryBuffer::$cleared,ResultsV2TelemetryBuffer::$reasons]);
        """
        ok, summary_writes, marked, cleared, reasons = run_php(source)
        self.assertFalse(ok)
        self.assertEqual(0, summary_writes)
        self.assertEqual(0, marked)
        self.assertEqual([], cleared)
        for reason in ('equity_point_write_failed', 'results_store_unavailable', 'equity_evidence_missing'):
            self.assertIn(reason, reasons)

    def test_contract_contains_bounded_retry_and_exact_id_maintenance_command(self):
        observer = (ROOT / "classes" / "ResultsV2Observer.php").read_text(encoding="utf-8")
        store = (ROOT / "classes" / "ResultsV2Store.php").read_text(encoding="utf-8")
        command = (ROOT / "deploy/maintenance/reconcile_results_v2.php").read_text(encoding="utf-8")
        for name in ("RESULTS_DB_CONNECT_ATTEMPTS", "RESULTS_DB_RETRY_DELAY_MS", "reconnectFactory"):
            self.assertIn(name, observer + store)
        self.assertIn("reconcileBufferedTerminal", observer)
        self.assertIn("reconcileEquityPoints", store)
        self.assertIn("hasCompleteEquityCoverage", observer + store)
        self.assertIn("if (!$points)", observer)
        self.assertIn("results-v2:equity-buffer:", (ROOT / "classes/ResultsV2TelemetryBuffer.php").read_text(encoding="utf-8"))
        for test_id in (392269, 392270, 392271, 392272, 392273, 392274):
            self.assertIn(str(test_id), command)
        self.assertIn("--apply", command)


if __name__ == "__main__":
    unittest.main()
