import http.client
import json
import threading
import unittest
from unittest.mock import patch
from decimal import Decimal
import urllib.error
import finbot_client as client

def payload():
 return {'status':'SNAPSHOT_PROCESSED_SUCCESS','data':{'portfolio':{'balance':'1234.567890123456','currency':'USD'},'research':{'signals':[{'symbol':'AAPL','signal':'HOLD'}]},'ledger':[]}}

class NormalizeTests(unittest.TestCase):
 def test_gateway_envelope(self):
  s=client.normalize(payload());self.assertEqual(s['portfolio']['balance'],'1234.567890123456');self.assertIsNone(s['captured_at']);self.assertFalse(s['capabilities']['submit_orders'])
 def test_missing_balance_never_zero(self):
  p=payload();p['data']['portfolio']={};self.assertNotIn('balance',client.normalize(p)['portfolio'])
 def test_no_data_rejected(self):
  for p in [{},{'gateway_status':'ONLINE'},{'status':'PACKAGE_RECEIVED_ALERT'}]:
   with self.subTest(p=p),self.assertRaises(client.SnapshotError):client.normalize(p)
 def test_invalid_signals_rejected(self):
  p=payload();p['data']['research']['signals']=[{'token':'private'}]
  with self.assertRaises(client.SnapshotError):client.normalize(p)
 def test_unknown_fields_not_leaked(self):
  p=payload();p['secret']='private';p['data']['portfolio']['token']='private'
  self.assertNotIn('private',json.dumps(client.normalize(p)))
 def test_duplicates_nonfinite(self):
  for raw in [b'{"a":1,"a":2}',b'{"a":NaN}']:
   with self.assertRaises(client.SnapshotError):client.strict_json(raw)
 def test_decimal_numeric_json_precision(self):
  p=client.strict_json(b'{"data":{"portfolio":{"balance":1234.567890123456}}}')
  self.assertEqual(client.normalize(p)['portfolio']['balance'],'1234.567890123456')
 def test_duplicate_orders_and_future(self):
  p=payload();p['data']['ledger']=[{'id':'x','symbol':'AAPL'},{'id':'x','symbol':'AAPL'}]
  with self.assertRaises(client.SnapshotError):client.normalize(p)
  p['data']['captured_at']='2026-01-01T00:00:00Z';p['data']['ledger']=[{'id':'x','timestamp':'2026-01-02T00:00:00Z'}]
  with self.assertRaises(client.SnapshotError):client.normalize(p)
 def test_review_component_schema(self):
  p={'captured_at':'2026-01-01T00:00:00Z','account_mode':'LIVE','orders':[]}
  self.assertEqual(client.normalize(p)['account_mode'],'LIVE')
 def test_targets(self):
  for url in ['http://localhost:8080','https://evil.example/api/snapshot','https://user:secret@sotality.com/','https://sotality.com/?token=x','https://sotality.com:8080/']:
   with self.subTest(url=url),self.assertRaises(client.SnapshotError):client.validate_url(url)
 def test_negative_balance_valid_invalid_amount_blocked(self):
  self.assertEqual(client.money('-1.25',True),'-1.25')
  for v in ['NaN','1e99',True,1.5]:
   with self.assertRaises(client.SnapshotError):client.money(v)
 def test_redirect_denied(self):self.assertIsNone(client.NoRedirect().redirect_request(None,None,302,'',{},'https://example.com'))
 def test_transport_error_codes(self):
  for code,expected in [(401,'AUTHORIZATION_REQUIRED'),(403,'ACCESS_DENIED'),(302,'REDIRECT_REJECTED'),(502,'UPSTREAM_HTTP_ERROR')]:
   opener=unittest.mock.Mock();opener.open.side_effect=urllib.error.HTTPError(client.DEFAULT_URL,code,'',{},None)
   with patch('finbot_client.urllib.request.build_opener',return_value=opener):
    with self.assertRaises(client.SnapshotError) as error:client.fetch_snapshot(client.DEFAULT_URL)
    self.assertEqual(error.exception.code,expected)
 def test_html_rejected_and_credentials_absent(self):
  response=unittest.mock.MagicMock();response.__enter__.return_value=response;response.status=200;response.headers={'Content-Type':'text/html'}
  opener=unittest.mock.Mock();opener.open.return_value=response
  with patch('finbot_client.urllib.request.build_opener',return_value=opener):
   with self.assertRaises(client.SnapshotError):client.fetch_snapshot(client.DEFAULT_URL)
   request=opener.open.call_args.args[0];self.assertNotIn('Authorization',dict(request.header_items()));self.assertEqual(request.get_method(),'GET')

class LocalIntegrationTests(unittest.TestCase):
 @classmethod
 def setUpClass(cls):
  cls.server=client.create_server(port=0,fetcher=lambda url:client.normalize(payload()));cls.thread=threading.Thread(target=cls.server.serve_forever);cls.thread.start()
 @classmethod
 def tearDownClass(cls):cls.server.shutdown();cls.server.server_close();cls.thread.join()
 def request(self,path='/',method='GET',headers=None):
  conn=http.client.HTTPConnection(*self.server.server_address,timeout=2)
  try:
   conn.request(method,path,headers=headers or {});r=conn.getresponse();return r.status,r.read(),dict(r.getheaders())
  finally:conn.close()
 def test_dashboard_assets_and_snapshot(self):
  for path in ['/','/dashboard.js','/dashboard.css','/api/snapshot']:
   status,raw,headers=self.request(path);self.assertEqual(status,200);self.assertTrue(raw);self.assertEqual(headers['Cache-Control'],'no-store')
  self.assertEqual(json.loads(self.request('/api/snapshot')[1])['snapshot']['portfolio']['balance'],'1234.567890123456')
 def test_cross_origin_and_host_blocked(self):
  self.assertEqual(self.request('/api/snapshot',headers={'Origin':'https://evil.example'})[0],403)
  self.assertEqual(self.request('/api/snapshot',headers={'Host':'evil.example'})[0],403)
 def test_write_and_traversal_rejected(self):
  self.assertEqual(self.request('/api/orders','POST')[0],405);self.assertEqual(self.request('/../finbot_client.py')[0],404)
 def test_single_outbound_request(self):
  self.server.fetch_lock.acquire()
  try:self.assertEqual(self.request('/api/snapshot')[0],429)
  finally:self.server.fetch_lock.release()

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