#!/usr/bin/env python3
"""Tests for tools/braniac.py — all five modes + edge cases. stdlib only."""

import ast
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest

# tools/braniac.py was refactored to class-based ScraperModes API.
# These tests target the old module-level function API (parse_html, match_selector, etc.)
# and need a full rewrite. Skip until then.
pytest.skip(
    "braniac module was refactored — tests need rewrite to match ScraperModes API",
    allow_module_level=True,
)

# Ensure the tools directory is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
import braniac


# ── Sample HTML fixtures ─────────────────────────────────────────────

SIMPLE_HTML = """
<html>
<head><title>Test Page</title><meta name="description" content="A test page"></head>
<body>
  <h1>Main Title</h1>
  <div id="content" class="main-area">
    <p class="intro">Hello world, this is a test paragraph with enough text.</p>
    <p class="detail">Details here with more text for auto-detection to find.</p>
    <a href="/about">About Us</a>
    <a href="https://example.com/ext">External Link</a>
    <a href="#anchor">Skip</a>
    <a href="javascript:void(0)">JS Link</a>
  </div>
  <form action="/search" method="get">
    <input type="text" name="q">
    <input type="submit" value="Search">
  </form>
  <h2 class="section">Section One</h2>
  <h3>Subsection</h3>
</body>
</html>
"""

EMPTY_HTML = "<html><body></body></html>"

MALFORMED_HTML = "<div><p>Unclosed paragraph<div>Nested bad</p></div>"

UNICODE_HTML = '<html><body><p class="uni">Ünïcödé ∑∏∫ テスト</p></body></html>'


class TestStructureParser(unittest.TestCase):
    """Unit tests for the HTML parser."""

    def test_basic_parsing(self):
        elements = braniac.parse_html(SIMPLE_HTML)
        tags = [e["tag"] for e in elements]
        self.assertIn("html", tags)
        self.assertIn("h1", tags)
        self.assertIn("p", tags)
        self.assertIn("a", tags)

    def test_element_attrs(self):
        elements = braniac.parse_html(SIMPLE_HTML)
        div = next(e for e in elements if e["tag"] == "div" and e["attrs"].get("id") == "content")
        self.assertEqual(div["attrs"]["class"], "main-area")

    def test_text_extraction(self):
        elements = braniac.parse_html(SIMPLE_HTML)
        h1 = next(e for e in elements if e["tag"] == "h1")
        self.assertEqual(h1["text"], "Main Title")

    def test_depth_tracking(self):
        elements = braniac.parse_html(SIMPLE_HTML)
        # body children should have depth > 0
        h1 = next(e for e in elements if e["tag"] == "h1")
        self.assertGreater(h1["depth"], 0)

    def test_empty_html(self):
        elements = braniac.parse_html(EMPTY_HTML)
        tags = [e["tag"] for e in elements]
        self.assertIn("html", tags)
        self.assertIn("body", tags)

    def test_malformed_html(self):
        # Should not raise
        elements = braniac.parse_html(MALFORMED_HTML)
        self.assertTrue(len(elements) > 0)

    def test_unicode_content(self):
        elements = braniac.parse_html(UNICODE_HTML)
        p = next(e for e in elements if e["tag"] == "p")
        self.assertIn("Ünïcödé", p["text"])
        self.assertIn("テスト", p["text"])

    def test_link_attrs(self):
        elements = braniac.parse_html(SIMPLE_HTML)
        links = [e for e in elements if e["tag"] == "a"]
        hrefs = [e["attrs"].get("href") for e in links]
        self.assertIn("/about", hrefs)
        self.assertIn("https://example.com/ext", hrefs)

    def test_no_elements_from_empty_string(self):
        elements = braniac.parse_html("")
        self.assertEqual(elements, [])


class TestMatchSelector(unittest.TestCase):
    """Unit tests for CSS selector matching."""

    def _el(self, tag, cls="", id_="", text=""):
        attrs = {}
        if cls:
            attrs["class"] = cls
        if id_:
            attrs["id"] = id_
        return {"tag": tag, "attrs": attrs, "text": text, "depth": 0}

    def test_tag_match(self):
        self.assertTrue(braniac.match_selector(self._el("p"), "p"))
        self.assertFalse(braniac.match_selector(self._el("div"), "p"))

    def test_class_match(self):
        self.assertTrue(braniac.match_selector(self._el("p", cls="intro"), ".intro"))
        self.assertFalse(braniac.match_selector(self._el("p", cls="other"), ".intro"))

    def test_id_match(self):
        self.assertTrue(braniac.match_selector(self._el("div", id_="main"), "#main"))
        self.assertFalse(braniac.match_selector(self._el("div", id_="other"), "#main"))

    def test_tag_class_match(self):
        self.assertTrue(braniac.match_selector(self._el("p", cls="intro"), "p.intro"))
        self.assertFalse(braniac.match_selector(self._el("div", cls="intro"), "p.intro"))

    def test_tag_id_match(self):
        self.assertTrue(braniac.match_selector(self._el("div", id_="content"), "div#content"))
        self.assertFalse(braniac.match_selector(self._el("p", id_="content"), "div#content"))

    def test_multi_class_match(self):
        # Element with multiple classes should match any one
        el = self._el("div", cls="foo bar baz")
        self.assertTrue(braniac.match_selector(el, ".bar"))
        self.assertFalse(braniac.match_selector(el, ".missing"))

    def test_whitespace_stripped(self):
        self.assertTrue(braniac.match_selector(self._el("p"), "  p  "))

    def test_empty_class(self):
        el = self._el("div")  # no class attr
        self.assertFalse(braniac.match_selector(el, ".anything"))

    def test_bare_id_no_tag(self):
        el = self._el("span", id_="x")
        self.assertTrue(braniac.match_selector(el, "#x"))


class TestMemory(unittest.TestCase):
    """Tests for pattern memory (evolve system)."""

    def setUp(self):
        self._tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
        self._tmp.close()
        self._orig = braniac.MEMORY_FILE
        braniac.MEMORY_FILE = Path(self._tmp.name)
        # Start clean
        braniac.MEMORY_FILE.write_text("{}")

    def tearDown(self):
        braniac.MEMORY_FILE = self._orig
        os.unlink(self._tmp.name)

    def test_load_empty(self):
        braniac.MEMORY_FILE.write_text("{}")
        mem = braniac.load_memory()
        self.assertEqual(mem, {"sites": {}})

    def test_load_corrupted(self):
        braniac.MEMORY_FILE.write_text("not json!!!")
        mem = braniac.load_memory()
        self.assertEqual(mem, {"sites": {}})

    def test_load_missing_file(self):
        braniac.MEMORY_FILE.unlink()
        mem = braniac.load_memory()
        self.assertEqual(mem, {"sites": {}})

    def test_record_and_retrieve(self):
        url = "https://example.com/page"
        braniac.record_pattern(url, "h2.title", 5)
        patterns = braniac.get_best_patterns(url)
        self.assertEqual(len(patterns), 1)
        self.assertEqual(patterns[0]["selector"], "h2.title")
        self.assertEqual(patterns[0]["hits"], 5)
        self.assertEqual(patterns[0]["uses"], 1)

    def test_record_increments(self):
        url = "https://example.com/page"
        braniac.record_pattern(url, "p", 3)
        braniac.record_pattern(url, "p", 7)
        patterns = braniac.get_best_patterns(url)
        self.assertEqual(patterns[0]["hits"], 10)
        self.assertEqual(patterns[0]["uses"], 2)

    def test_multiple_patterns_ranked(self):
        url = "https://test.org"
        braniac.record_pattern(url, "low", 1)
        braniac.record_pattern(url, "high", 100)
        braniac.record_pattern(url, "mid", 10)
        patterns = braniac.get_best_patterns(url, top_n=3)
        # high should be first (score = 100*1 > 10*1 > 1*1)
        self.assertEqual(patterns[0]["selector"], "high")

    def test_different_sites_isolated(self):
        braniac.record_pattern("https://a.com/x", "p", 5)
        braniac.record_pattern("https://b.com/x", "div", 3)
        p_a = braniac.get_best_patterns("https://a.com/y")
        p_b = braniac.get_best_patterns("https://b.com/y")
        self.assertEqual(p_a[0]["selector"], "p")
        self.assertEqual(p_b[0]["selector"], "div")

    def test_no_patterns_returns_empty(self):
        self.assertEqual(braniac.get_best_patterns("https://never.com"), [])

    def test_site_key_deterministic(self):
        k1 = braniac.site_key("https://example.com/a")
        k2 = braniac.site_key("https://example.com/b")
        self.assertEqual(k1, k2)  # Same host → same key

    def test_site_key_differs_by_host(self):
        k1 = braniac.site_key("https://a.com/x")
        k2 = braniac.site_key("https://b.com/x")
        self.assertNotEqual(k1, k2)

    def test_save_and_reload_roundtrip(self):
        mem = {"sites": {"abc": {"host": "x.com", "patterns": [{"selector": "p", "hits": 1, "uses": 1}]}}}
        braniac.save_memory(mem)
        loaded = braniac.load_memory()
        self.assertEqual(loaded, mem)


class TestScout(unittest.TestCase):
    """Tests for scout mode."""

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_scout_returns_links(self, _mock_fetch):
        result = braniac.scout("https://example.com")
        urls = [lnk["url"] for lnk in result["links"]]
        self.assertTrue(any("about" in u for u in urls))
        self.assertTrue(any("example.com/ext" in u for u in urls))

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_scout_filters_anchors_and_js(self, _mock_fetch):
        result = braniac.scout("https://example.com")
        urls = [lnk["url"] for lnk in result["links"]]
        for u in urls:
            self.assertFalse(u.startswith("#"))
            self.assertFalse(u.startswith("javascript:"))

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_scout_tag_counts(self, _mock_fetch):
        result = braniac.scout("https://example.com")
        self.assertIn("p", result["tag_counts"])
        self.assertIn("a", result["tag_counts"])
        self.assertGreater(result["total_elements"], 0)

    @patch("braniac.fetch", return_value=EMPTY_HTML)
    def test_scout_empty_page(self, _mock_fetch):
        result = braniac.scout("https://empty.com")
        self.assertEqual(result["links"], [])

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_scout_resolves_relative_urls(self, _mock_fetch):
        result = braniac.scout("https://example.com/page/")
        urls = [lnk["url"] for lnk in result["links"]]
        about_links = [u for u in urls if "about" in u.lower()]
        for u in about_links:
            self.assertTrue(u.startswith("http"))


class TestHarvest(unittest.TestCase):
    """Tests for harvest mode."""

    def setUp(self):
        self._tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
        self._tmp.close()
        self._orig = braniac.MEMORY_FILE
        braniac.MEMORY_FILE = Path(self._tmp.name)
        braniac.MEMORY_FILE.write_text("{}")

    def tearDown(self):
        braniac.MEMORY_FILE = self._orig
        os.unlink(self._tmp.name)

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_harvest_with_selector(self, _mock_fetch):
        results = braniac.harvest("https://example.com", ["h1"])
        self.assertEqual(len(results), 1)
        self.assertEqual(results[0]["text"], "Main Title")

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_harvest_class_selector(self, _mock_fetch):
        results = braniac.harvest("https://example.com", [".intro"])
        self.assertEqual(len(results), 1)
        self.assertIn("Hello world", results[0]["text"])

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_harvest_multiple_selectors(self, _mock_fetch):
        results = braniac.harvest("https://example.com", ["h1", "h2"])
        tags = [r["tag"] for r in results]
        self.assertIn("h1", tags)
        self.assertIn("h2", tags)

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_harvest_no_match(self, _mock_fetch):
        results = braniac.harvest("https://example.com", ["table"])
        self.assertEqual(results, [])

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_harvest_auto_detect(self, _mock_fetch):
        results = braniac.harvest("https://example.com", None)
        # Auto-detect should find text-heavy elements (>40 chars)
        self.assertTrue(len(results) > 0)

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_harvest_records_pattern(self, _mock_fetch):
        braniac.harvest("https://example.com", ["p"])
        patterns = braniac.get_best_patterns("https://example.com")
        self.assertTrue(any(p["selector"] == "p" for p in patterns))

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_harvest_result_structure(self, _mock_fetch):
        results = braniac.harvest("https://example.com", ["h1"])
        r = results[0]
        self.assertIn("tag", r)
        self.assertIn("text", r)
        self.assertIn("class", r)
        self.assertIn("id", r)

    @patch("braniac.fetch", return_value=EMPTY_HTML)
    def test_harvest_empty_page(self, _mock_fetch):
        results = braniac.harvest("https://example.com", ["p"])
        self.assertEqual(results, [])


class TestEvolve(unittest.TestCase):
    """Tests for evolve mode (pattern reuse)."""

    def setUp(self):
        self._tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
        self._tmp.close()
        self._orig = braniac.MEMORY_FILE
        braniac.MEMORY_FILE = Path(self._tmp.name)
        braniac.MEMORY_FILE.write_text("{}")

    def tearDown(self):
        braniac.MEMORY_FILE = self._orig
        os.unlink(self._tmp.name)

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_evolve_no_patterns_runs_scout_and_harvest(self, _mock_fetch):
        # Should not raise — falls back to scout + harvest
        braniac.evolve("https://new-site.com")

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_evolve_with_prior_patterns(self, _mock_fetch):
        # Seed a pattern
        braniac.record_pattern("https://example.com", "h1", 5)
        # Evolve should reuse it
        braniac.evolve("https://example.com/other-page")
        # Pattern uses should have incremented
        patterns = braniac.get_best_patterns("https://example.com")
        self.assertTrue(any(p["selector"] == "h1" for p in patterns))


class TestMutate(unittest.TestCase):
    """Tests for mutate mode (self-modification via AST)."""

    def test_self_analyze_parses_source(self):
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        pending, metrics = braniac._self_analyze(src)
        self.assertIsInstance(pending, list)
        self.assertIn("lines", metrics)
        self.assertIn("functions", metrics)
        self.assertIn("classes", metrics)
        self.assertGreater(metrics["functions"], 5)
        self.assertGreater(metrics["lines"], 100)

    def test_source_is_valid_ast(self):
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        # Should not raise
        tree = ast.parse(src)
        self.assertIsNotNone(tree)

    def test_mutation_fetch_cache_applies(self):
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        if "mut:fetch_cache" not in src:
            result = braniac._mut_fetch_cache(src)
            if result is not None:
                self.assertIn("_fetch_cache", result)
                self.assertIn("mut:fetch_cache", result)
                # Result should still be valid Python
                ast.parse(result)

    def test_mutation_selector_enhance_applies(self):
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        if "mut:selector_enhance" not in src:
            result = braniac._mut_selector_enhance(src)
            if result is not None:
                self.assertIn("mut:selector_enhance", result)
                ast.parse(result)

    def test_mutation_scout_enhance_applies(self):
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        if "mut:scout_enhance" not in src:
            result = braniac._mut_scout_enhance(src)
            if result is not None:
                self.assertIn("mut:scout_enhance", result)
                ast.parse(result)

    def test_mutation_harvest_stats_applies(self):
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        if "mut:harvest_stats" not in src:
            result = braniac._mut_harvest_stats(src)
            if result is not None:
                self.assertIn("mut:harvest_stats", result)
                ast.parse(result)

    def test_all_mutations_produce_valid_python(self):
        """Apply all mutations sequentially and verify the result is valid Python."""
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        for name, _cat, _desc, fn in braniac._MUT_REGISTRY:
            result = fn(src)
            if result is not None:
                try:
                    ast.parse(result)
                except SyntaxError as e:
                    self.fail(f"Mutation '{name}' produced invalid Python: {e}")
                src = result  # chain

    def test_mutations_idempotent(self):
        """Applying a mutation twice should return None the second time."""
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        for name, _cat, _desc, fn in braniac._MUT_REGISTRY:
            result = fn(src)
            if result is not None:
                second = fn(result)
                self.assertIsNone(second, f"Mutation '{name}' is not idempotent")
                src = result

    @patch("braniac._self_path")
    def test_mutate_dry_run_no_write(self, mock_path):
        """--dry-run should not modify any files."""
        tmp = tempfile.NamedTemporaryFile(suffix=".py", delete=False, mode="w", encoding="utf-8")
        src = Path(braniac.__file__).resolve().read_text(encoding="utf-8")
        tmp.write(src)
        tmp.close()
        mock_path.return_value = Path(tmp.name)

        # Patch mutation log too
        log_tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
        log_tmp.close()
        orig_log = braniac.MUTATION_LOG
        braniac.MUTATION_LOG = Path(log_tmp.name)

        try:
            braniac.mutate(dry_run=True)
            # File should be unchanged
            after = Path(tmp.name).read_text(encoding="utf-8")
            self.assertEqual(src, after)
        finally:
            braniac.MUTATION_LOG = orig_log
            os.unlink(tmp.name)
            os.unlink(log_tmp.name)


class TestCLI(unittest.TestCase):
    """Tests for argparse CLI construction and argument parsing."""

    def setUp(self):
        self.parser = braniac.build_cli()

    def test_scout_args(self):
        args = self.parser.parse_args(["scout", "https://example.com"])
        self.assertEqual(args.mode, "scout")
        self.assertEqual(args.url, "https://example.com")

    def test_harvest_args_basic(self):
        args = self.parser.parse_args(["harvest", "https://example.com"])
        self.assertEqual(args.mode, "harvest")
        self.assertIsNone(args.select)

    def test_harvest_args_with_selectors(self):
        args = self.parser.parse_args(["harvest", "https://example.com", "-s", "h1", "p.intro"])
        self.assertEqual(args.select, ["h1", "p.intro"])

    def test_harvest_json_flag(self):
        args = self.parser.parse_args(["harvest", "https://example.com", "--json"])
        self.assertTrue(args.as_json)

    def test_evolve_args(self):
        args = self.parser.parse_args(["evolve", "https://example.com"])
        self.assertEqual(args.mode, "evolve")

    def test_mutate_args(self):
        args = self.parser.parse_args(["mutate"])
        self.assertEqual(args.mode, "mutate")
        self.assertFalse(args.dry_run)

    def test_mutate_dry_run(self):
        args = self.parser.parse_args(["mutate", "--dry-run"])
        self.assertTrue(args.dry_run)

    def test_memory_args(self):
        args = self.parser.parse_args(["memory"])
        self.assertEqual(args.mode, "memory")

    def test_memory_clear(self):
        args = self.parser.parse_args(["memory", "--clear"])
        self.assertTrue(args.clear)

    def test_memory_show_host(self):
        args = self.parser.parse_args(["memory", "--show", "example.com"])
        self.assertEqual(args.show, "example.com")

    def test_no_mode_raises(self):
        with self.assertRaises(SystemExit):
            self.parser.parse_args([])

    def test_invalid_mode_raises(self):
        with self.assertRaises(SystemExit):
            self.parser.parse_args(["invalid"])


class TestMemoryCommand(unittest.TestCase):
    """Tests for the `memory` CLI subcommand logic."""

    def setUp(self):
        self._tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
        self._tmp.close()
        self._orig = braniac.MEMORY_FILE
        braniac.MEMORY_FILE = Path(self._tmp.name)

    def tearDown(self):
        braniac.MEMORY_FILE = self._orig
        if os.path.exists(self._tmp.name):
            os.unlink(self._tmp.name)

    def test_clear_removes_file(self):
        braniac.MEMORY_FILE.write_text('{"sites":{}}')
        args = MagicMock(clear=True, show=None)
        braniac.cmd_memory(args)
        self.assertFalse(braniac.MEMORY_FILE.exists())

    def test_show_empty(self):
        braniac.MEMORY_FILE.write_text('{"sites":{}}')
        args = MagicMock(clear=False, show=None)
        # Should not raise
        braniac.cmd_memory(args)


class TestFetchEncoding(unittest.TestCase):
    """Tests for the fetch function's encoding fallback logic."""

    @patch("braniac.urlopen")
    def test_utf8_decode(self, mock_urlopen):
        content = "Hello UTF-8 ñ".encode("utf-8")
        mock_resp = MagicMock()
        mock_resp.read.return_value = content
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        result = braniac.fetch("https://example.com")
        self.assertIn("Hello UTF-8", result)

    @patch("braniac.urlopen")
    def test_latin1_fallback(self, mock_urlopen):
        # Bytes that are valid latin-1 but not utf-8
        content = b"\xe9\xe8\xea"  # é è ê in latin-1
        mock_resp = MagicMock()
        mock_resp.read.return_value = content
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        result = braniac.fetch("https://example.com")
        self.assertEqual(result, "éèê")


class TestEdgeCases(unittest.TestCase):
    """Stress tests and edge cases."""

    def test_parse_html_with_self_closing_tags(self):
        html = '<html><body><br/><img src="x.jpg"/><hr></body></html>'
        elements = braniac.parse_html(html)
        tags = [e["tag"] for e in elements]
        self.assertIn("br", tags)
        self.assertIn("img", tags)

    def test_parse_html_nested_same_tags(self):
        html = "<div><div><div>Deep</div></div></div>"
        elements = braniac.parse_html(html)
        divs = [e for e in elements if e["tag"] == "div"]
        self.assertEqual(len(divs), 3)

    def test_match_selector_empty_string(self):
        el = {"tag": "", "attrs": {}, "text": "", "depth": 0}
        # Should not raise
        braniac.match_selector(el, "p")

    def test_very_long_text_truncated_in_harvest(self):
        long_text = "x" * 500
        html = f'<html><body><p class="t">{long_text}</p></body></html>'
        with patch("braniac.fetch", return_value=html):
            results = braniac.harvest("https://example.com", ["p"])
            # text should be truncated to 200
            self.assertLessEqual(len(results[0]["text"]), 200)

    def test_site_key_handles_ports(self):
        k1 = braniac.site_key("https://localhost:8080/path")
        k2 = braniac.site_key("https://localhost:8080/other")
        self.assertEqual(k1, k2)

    @patch("braniac.fetch", return_value=SIMPLE_HTML)
    def test_scout_link_text_truncation(self, _mock_fetch):
        result = braniac.scout("https://example.com")
        for lnk in result["links"]:
            self.assertLessEqual(len(lnk["text"]), 80)


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