# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.

import json
import os
import shutil
import unittest
from io import StringIO
from tempfile import NamedTemporaryFile

import buildconfig
import mozunit

from mozbuild.codecoverage import chrome_map, lcov_rewriter

here = os.path.dirname(__file__)

BUILDCONFIG = {
    "topobjdir": buildconfig.topobjdir,
    "MOZ_APP_NAME": buildconfig.substs.get("MOZ_APP_NAME", "nightly"),
    "OMNIJAR_NAME": buildconfig.substs.get("OMNIJAR_NAME", "omni.ja"),
    "MOZ_MACBUNDLE_NAME": buildconfig.substs.get("MOZ_MACBUNDLE_NAME", "Nightly.app"),
}

basic_file = """TN:Compartment_5f7f5c30251800
SF:resource://gre/modules/osfile.jsm
FN:1,top-level
FNDA:1,top-level
FNF:1
FNH:1
BRDA:9,0,61,1
BRF:1
BRH:1
DA:9,1
DA:24,1
LF:2
LH:2
end_of_record
"""

# These line numbers are (synthetically) sorted.
multiple_records = """SF:resource://gre/modules/workers/require.js
FN:1,top-level
FN:80,.get
FN:95,require
FNDA:1,top-level
FNF:3
FNH:1
BRDA:46,0,16,-
BRDA:135,225,446,-
BRF:2
BRH:0
DA:43,1
DA:46,1
DA:152,0
DA:163,1
LF:4
LH:3
end_of_record
SF:resource://gre/modules/osfile/osfile_async_worker.js
FN:12,top-level
FN:30,worker.dispatch
FN:34,worker.postMessage
FN:387,do_close
FN:392,exists
FN:394,do_exists
FN:400,unixSymLink
FNDA:1,do_exists
FNDA:1,exists
FNDA:1,top-level
FNDA:594,worker.dispatch
FNF:7
FNH:4
BRDA:6,0,30,1
BRDA:365,0,103,-
BRF:2
BRH:1
DA:6,1
DA:7,0
DA:12,1
DA:18,1
DA:19,1
DA:20,1
DA:23,1
DA:25,1
DA:401,0
DA:407,1
LF:10
LH:8
end_of_record
"""

fn_with_multiple_commas = """TN:Compartment_5f7f5c30251800
SF:resource://gre/modules/osfile.jsm
FN:1,function,name,with,commas
FNDA:1,function,name,with,commas
FNF:1
FNH:1
BRDA:9,0,61,1
BRF:1
BRH:1
DA:9,1
DA:24,1
LF:2
LH:2
end_of_record
"""


class TempFile:
    def __init__(self, content):
        self.file = NamedTemporaryFile(mode="w", delete=False, encoding="utf-8")
        self.file.write(content)
        self.file.close()

    def __enter__(self):
        return self.file.name

    def __exit__(self, *args):
        os.remove(self.file.name)


class TestLcovParser(unittest.TestCase):
    def parser_roundtrip(self, lcov_string):
        with TempFile(lcov_string) as fname:
            file_obj = lcov_rewriter.LcovFile([fname])
            out = StringIO()
            file_obj.print_file(out, lambda s: (s, None), lambda x, pp: x)

        return out.getvalue()

    def test_basic_parse(self):
        output = self.parser_roundtrip(basic_file)
        self.assertEqual(basic_file, output)

        output = self.parser_roundtrip(multiple_records)
        self.assertEqual(multiple_records, output)

    def test_multiple_commas(self):
        output = self.parser_roundtrip(fn_with_multiple_commas)
        self.assertEqual(fn_with_multiple_commas, output)


multiple_included_files = """//@line 1 "/src/dir/foo.js"
bazfoobar
//@line 2 "/src/dir/path/bar.js"
@foo@
//@line 3 "/src/dir/foo.js"
bazbarfoo
//@line 2 "/src/dir/path/bar.js"
foobarbaz
//@line 3 "/src/dir/path2/test.js"
barfoobaz
//@line 1 "/src/dir/path/baz.js"
baz
//@line 6 "/src/dir/f.js"
fin
"""

srcdir_prefix_files = """//@line 1 "/src/dir/foo.js"
bazfoobar
//@line 2 "$SRCDIR/path/file.js"
@foo@
//@line 3 "/src/dir/foo.js"
bazbarfoo
"""


class TestLineRemapping(unittest.TestCase):
    def setUp(self):
        chrome_map_file = os.path.join(buildconfig.topobjdir, "chrome-map.json")
        self._old_chrome_info_file = None
        if os.path.isfile(chrome_map_file):
            backup_file = os.path.join(buildconfig.topobjdir, "chrome-map-backup.json")
            self._old_chrome_info_file = backup_file
            self._chrome_map_file = chrome_map_file
            shutil.move(chrome_map_file, backup_file)

        empty_chrome_info = [
            {},
            {},
            {},
            BUILDCONFIG,
        ]
        with open(chrome_map_file, "w") as fh:
            json.dump(empty_chrome_info, fh)

        self.lcov_rewriter = lcov_rewriter.LcovFileRewriter(chrome_map_file, "", "", [])
        self.pp_rewriter = self.lcov_rewriter.pp_rewriter

    def tearDown(self):
        if self._old_chrome_info_file:
            shutil.move(self._old_chrome_info_file, self._chrome_map_file)

    def test_map_multiple_included(self):
        with TempFile(multiple_included_files) as fname:
            actual = chrome_map.generate_pp_info(fname, "/src/dir")
        expected = {
            "2,3": ("foo.js", 1),
            "4,5": ("path/bar.js", 2),
            "6,7": ("foo.js", 3),
            "8,9": ("path/bar.js", 2),
            "10,11": ("path2/test.js", 3),
            "12,13": ("path/baz.js", 1),
            "14,15": ("f.js", 6),
        }

        self.assertEqual(actual, expected)

    def test_map_srcdir_prefix(self):
        with TempFile(srcdir_prefix_files) as fname:
            actual = chrome_map.generate_pp_info(fname, "/src/dir")
        expected = {
            "2,3": ("foo.js", 1),
            "4,5": ("path/file.js", 2),
            "6,7": ("foo.js", 3),
        }

        self.assertEqual(actual, expected)

    def test_remap_lcov(self):
        pp_remap = {
            "1941,2158": ("dropPreview.js", 6),
            "2159,2331": ("updater.js", 6),
            "2584,2674": ("intro.js", 6),
            "2332,2443": ("undo.js", 6),
            "864,985": ("cells.js", 6),
            "2444,2454": ("search.js", 6),
            "1567,1712": ("drop.js", 6),
            "2455,2583": ("customize.js", 6),
            "1713,1940": ("dropTargetShim.js", 6),
            "1402,1548": ("drag.js", 6),
            "1549,1566": ("dragDataHelper.js", 6),
            "453,602": ("page.js", 141),
            "2675,2678": ("newTab.js", 70),
            "56,321": ("transformations.js", 6),
            "603,863": ("grid.js", 6),
            "322,452": ("page.js", 6),
            "986,1401": ("sites.js", 6),
        }

        fpath = os.path.join(here, "sample_lcov.info")

        # Read original records
        lcov_file = lcov_rewriter.LcovFile([fpath])
        records = [lcov_file.parse_record(r) for _, _, r in lcov_file.iterate_records()]

        # This summarization changes values due multiple reports per line coming
        # from the JS engine (bug 1198356).
        for r in records:
            r.resummarize()
            original_line_count = r.line_count
            original_covered_line_count = r.covered_line_count
            original_function_count = r.function_count
            original_covered_function_count = r.covered_function_count

        self.assertEqual(len(records), 1)

        # Rewrite preprocessed entries.
        lcov_file = lcov_rewriter.LcovFile([fpath])
        r_num = []

        def rewrite_source(s):
            r_num.append(1)
            return s, pp_remap

        out = StringIO()
        lcov_file.print_file(out, rewrite_source, self.pp_rewriter.rewrite_record)
        self.assertEqual(len(r_num), 1)

        # Read rewritten lcov.
        with TempFile(out.getvalue()) as fname:
            lcov_file = lcov_rewriter.LcovFile([fname])
            records = [
                lcov_file.parse_record(r) for _, _, r in lcov_file.iterate_records()
            ]

        self.assertEqual(len(records), 17)

        # Lines/functions are only "moved" between records, not duplicated or omited.
        self.assertEqual(original_line_count, sum(r.line_count for r in records))
        self.assertEqual(
            original_covered_line_count, sum(r.covered_line_count for r in records)
        )
        self.assertEqual(
            original_function_count, sum(r.function_count for r in records)
        )
        self.assertEqual(
            original_covered_function_count,
            sum(r.covered_function_count for r in records),
        )


class TestUrlFinder(unittest.TestCase):
    def setUp(self):
        chrome_map_file = os.path.join(buildconfig.topobjdir, "chrome-map.json")
        self._old_chrome_info_file = None
        if os.path.isfile(chrome_map_file):
            backup_file = os.path.join(buildconfig.topobjdir, "chrome-map-backup.json")
            self._old_chrome_info_file = backup_file
            self._chrome_map_file = chrome_map_file
            shutil.move(chrome_map_file, backup_file)

        dummy_chrome_info = [
            {
                "resource://newtab/": [
                    "dist/bin/browser/chrome/browser/res/newtab",
                ],
                "chrome://browser/content/": [
                    "dist/bin/browser/chrome/browser/content/browser",
                ],
            },
            {
                "chrome://global/content/license.html": "chrome://browser/content/license.html",
            },
            {
                "dist/bin/components/MainProcessSingleton.js": ["path1", None],
                "dist/bin/browser/features/firefox@getpocket.com/bootstrap.js": [
                    "path4",
                    None,
                ],
                "dist/bin/modules/osfile/osfile_async_worker.js": [
                    "toolkit/components/osfile/modules/osfile_async_worker.js",
                    None,
                ],
                "dist/bin/browser/chrome/browser/res/newtab/lib/": [
                    "browser/components/newtab/lib/*",
                    None,
                ],
                "dist/bin/browser/chrome/browser/content/browser/license.html": [
                    "browser/base/content/license.html",
                    None,
                ],
                "dist/bin/modules/AppConstants.sys.mjs": [
                    "toolkit/modules/AppConstants.sys.mjs",
                    {
                        "101,102": ["toolkit/modules/AppConstants.sys.mjs", 135],
                    },
                ],
            },
            BUILDCONFIG,
        ]
        with open(chrome_map_file, "w") as fh:
            json.dump(dummy_chrome_info, fh)

    def tearDown(self):
        if self._old_chrome_info_file:
            shutil.move(self._old_chrome_info_file, self._chrome_map_file)

    def test_jar_paths(self):
        app_name = BUILDCONFIG["MOZ_APP_NAME"]
        omnijar_name = BUILDCONFIG["OMNIJAR_NAME"]

        paths = [
            (
                "jar:file:///home/worker/workspace/build/application/"
                + app_name
                + "/"
                + omnijar_name
                + "!/components/MainProcessSingleton.js",
                "path1",
            ),
            (
                "jar:file:///home/worker/workspace/build/application/"
                + app_name
                + "/browser/features/firefox@getpocket.com.xpi!/bootstrap.js",
                "path4",
            ),
        ]

        url_finder = lcov_rewriter.UrlFinder(self._chrome_map_file, "", "", [])
        for path, expected in paths:
            self.assertEqual(url_finder.rewrite_url(path)[0], expected)

    def test_wrong_scheme_paths(self):
        paths = [
            "http://www.mozilla.org/aFile.js",
            "https://www.mozilla.org/aFile.js",
            "data:something",
            "about:newtab",
            "javascript:something",
        ]

        url_finder = lcov_rewriter.UrlFinder(self._chrome_map_file, "", "", [])
        for path in paths:
            self.assertIsNone(url_finder.rewrite_url(path))

    def test_chrome_resource_paths(self):
        paths = [
            # Path with default url prefix
            (
                "resource://gre/modules/osfile/osfile_async_worker.js",
                ("toolkit/components/osfile/modules/osfile_async_worker.js", None),
            ),
            # Path with url prefix that is in chrome map
            (
                "resource://newtab/lib/PrefsFeed.sys.mjs",
                ("browser/components/newtab/lib/PrefsFeed.sys.mjs", None),
            ),
            # Path which is in url overrides
            (
                "chrome://global/content/license.html",
                ("browser/base/content/license.html", None),
            ),
            # Path which ends with > eval
            (
                "resource://gre/modules/osfile/osfile_async_worker.js line 3 > eval",
                None,
            ),
            # Path which ends with > Function
            (
                "resource://gre/modules/osfile/osfile_async_worker.js line 3 > Function",
                None,
            ),
            # Path which contains "->"
            (
                "resource://gre/modules/addons/XPIProvider.sys.mjs -> resource://gre/modules/osfile/osfile_async_worker.js",  # noqa
                ("toolkit/components/osfile/modules/osfile_async_worker.js", None),
            ),
            # Path with pp_info
            (
                "resource://gre/modules/AppConstants.sys.mjs",
                (
                    "toolkit/modules/AppConstants.sys.mjs",
                    {
                        "101,102": ["toolkit/modules/AppConstants.sys.mjs", 135],
                    },
                ),
            ),
            # Path with query
            (
                "resource://newtab/lib/PrefsFeed.jsm?q=0.9098419174803978",
                ("browser/components/newtab/lib/PrefsFeed.jsm", None),
            ),
        ]

        url_finder = lcov_rewriter.UrlFinder(self._chrome_map_file, "", "dist/bin/", [])
        for path, expected in paths:
            self.assertEqual(url_finder.rewrite_url(path), expected)


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