import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from demo_model import decide
from seo_agent import Lab, encode


class LabTests(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.addCleanup(self.temp.cleanup)
        self.path = str(Path(self.temp.name) / 'seo.db')
        self.now = 1_800_000_000
        self.lab = Lab(self.path, clock=lambda: self.now)
        self.addCleanup(self.lab.db.close)
        self.lab.seed()
        self.source = 'https://example.com/guia/'
        self.target = 'https://example.com/observabilidade/'

    def proposal(self):
        return self.lab.run('test', decide)['proposal_id']

    def mutate(self, url, change):
        page = self.lab.page(url)
        change(page)
        page['revision'] += 1
        with self.lab.db:
            self.lab.db.execute('UPDATE pages SET body=? WHERE url=?', (encode(page), url))

    def test_approval_apply_replay_and_rollback(self):
        proposal = self.proposal()
        with self.assertRaisesRegex(ValueError, 'Aprovação'):
            self.lab.apply(proposal)
        self.lab.approve(proposal, 'editor-local')
        self.assertEqual(self.lab.apply(proposal), 'applied')
        self.assertEqual(self.lab.apply(proposal), 'already_applied')
        self.assertIn('<a href="https://example.com/observabilidade/">observabilidade de agentes</a>',
                      self.lab.render(self.source))
        self.assertEqual(self.lab.rollback(proposal), 'rolled_back')
        self.assertEqual(self.lab.rollback(proposal), 'already_rolled_back')
        self.assertNotIn('<a ', self.lab.render(self.source))
        with self.assertRaises(ValueError):
            self.lab.apply(proposal)

    def test_source_changed_after_approval(self):
        proposal = self.proposal()
        self.lab.approve(proposal, 'editor')
        self.mutate(self.source, lambda p: p.update(title='Revisado'))
        with self.assertRaisesRegex(ValueError, 'obsoleta'):
            self.lab.apply(proposal)
        self.assertEqual(self.lab.proposal(proposal)['state'], 'approved')

    def test_target_changed_after_approval(self):
        proposal = self.proposal()
        self.lab.approve(proposal, 'editor')
        self.mutate(self.target, lambda p: p.update(indexable=False))
        with self.assertRaisesRegex(ValueError, 'inelegível'):
            self.lab.apply(proposal)

    def test_rollback_preserves_later_edit(self):
        proposal = self.proposal()
        self.lab.approve(proposal, 'editor')
        self.lab.apply(proposal)
        self.mutate(self.source, lambda p: p.update(title='Edição posterior'))
        with self.assertRaisesRegex(ValueError, 'posterior'):
            self.lab.rollback(proposal)
        self.assertEqual(self.lab.page(self.source)['title'], 'Edição posterior')

    def test_invalid_anchor_and_self_link(self):
        proposal = self.proposal()
        payload = json.loads(self.lab.proposal(proposal)['payload'])
        for anchor in ('texto inexistente', 'de'):
            with self.subTest(anchor=anchor):
                with self.assertRaises(ValueError):
                    self.lab.propose(dict(payload, anchor=anchor))
        with self.assertRaisesRegex(ValueError, 'Auto-link'):
            self.lab.propose(dict(payload, target=self.source))
        self.mutate(self.source, lambda p: p['blocks'][0].update(
            text='observabilidade de agentes e observabilidade de agentes'))
        payload['source_evidence'] = self.lab.inspect(self.source)['evidence_id']
        with self.assertRaisesRegex(ValueError, 'ambígua'):
            self.lab.propose(payload)

    def test_unknown_url_and_missing_evidence(self):
        proposal = self.proposal()
        payload = json.loads(self.lab.proposal(proposal)['payload'])
        with self.assertRaisesRegex(ValueError, 'inventário'):
            self.lab.propose(dict(payload, target='https://evil.example/'))
        result = self.lab.run('blind', lambda _: dict(tool='propose_link', args=payload), budget=1)
        self.assertEqual(result['status'], 'budget_exhausted')
        self.assertIn('Leia as duas', self.lab.history('blind')[0]['result']['error'])

    def test_expired_snapshot(self):
        proposal = self.proposal()
        self.lab.approve(proposal, 'editor')
        self.now += 86401
        with self.assertRaisesRegex(ValueError, 'expirado'):
            self.lab.apply(proposal)

    def test_resume_after_process_restart(self):
        self.assertEqual(self.lab.run('resume', decide, budget=2)['status'], 'budget_exhausted')
        other = Lab(self.path, clock=lambda: self.now)
        try:
            result = other.run('resume', decide)
            self.assertEqual(result['status'], 'pending')
            self.assertEqual(len(other.history('resume')), 4)
            self.assertEqual(other.run('resume', decide), result)
        finally:
            other.db.close()

    def test_forbidden_tool_is_bounded(self):
        result = self.lab.run('rogue', lambda _: dict(tool='apply', args={}), budget=3)
        self.assertEqual(result['status'], 'budget_exhausted')
        self.assertEqual(len(self.lab.history('rogue')), 3)
        self.assertNotIn('<a ', self.lab.render(self.source))

    def test_transaction_rolls_back_if_ledger_write_fails(self):
        proposal = self.proposal()
        self.lab.approve(proposal, 'editor')
        self.lab.db.executescript('''CREATE TRIGGER fail_ledger BEFORE UPDATE ON proposals
            WHEN NEW.state = 'applied' BEGIN SELECT RAISE(ABORT, 'injected crash'); END;''')
        with self.assertRaises(Exception):
            self.lab.apply(proposal)
        self.assertNotIn('<a ', self.lab.render(self.source))
        self.assertEqual(self.lab.proposal(proposal)['state'], 'approved')

    def test_renderer_escapes_untrusted_text(self):
        self.mutate(self.source, lambda p: p['blocks'][0].update(text='<script>alert(1)</script>'))
        self.assertNotIn('<script>', self.lab.render(self.source))
        self.assertIn('&lt;script&gt;', self.lab.render(self.source))

    def test_cli_accepts_model_flags_and_completes_cycle(self):
        folder = Path(__file__).resolve().parent
        # CLI uses wall-clock time, so create its own fresh inventory.
        base = [sys.executable, '-B', str(folder / 'seo_agent.py'),
                '--db', str(Path(self.temp.name) / 'cli.db')]
        def run(*args):
            return subprocess.check_output(base + list(args), text=True)
        run('init')
        result = json.loads(run('run', '--run-id', 'cli', '--model-command',
                                sys.executable, '-B', str(folder / 'demo_model.py')))
        proposal = result['proposal_id']
        self.assertEqual(json.loads(run('review', proposal))['state'], 'pending')
        run('approve', proposal, '--reviewer', 'qa-local')
        self.assertEqual(json.loads(run('apply', proposal))['status'], 'applied')
        self.assertIn('<a href=', run('render', self.source))
        self.assertEqual(json.loads(run('apply', proposal))['status'], 'already_applied')
        run('rollback', proposal)
        self.assertNotIn('<a href=', run('render', self.source))


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