import copy
import json
import math
import os
import subprocess
import sys
import unittest
from fractions import Fraction
from unittest import mock

import handoff


class TaskCardTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.stage = handoff.resolve_stage()
        cls.recipe, cls.members, cls.locks = handoff.load(cls.stage)
        cls.layers = {layer["id"]: layer for layer, _ in handoff.flatten(cls.recipe["layers"])}

    def rows(self, frame, recipe=None):
        recipe = recipe or self.recipe
        return {row["id"]: row for row in handoff.pose_rows(recipe, recipe["defaults"], frame)}

    def test_controls_are_exact_and_detect_mutation(self):
        compact = json.loads((handoff.HERE / "controls.json").read_text())
        decoded = handoff.unpack(compact["layers"], compact["tracks"])
        self.assertEqual(decoded, self.recipe["layers"])
        compact["tracks"]["t001"][0]["value"] += 1
        self.assertNotEqual(handoff.unpack(compact["layers"], compact["tracks"]), self.recipe["layers"])
        self.assertEqual(len(self.layers), 41)

    def test_native_clock_and_recorded_pts(self):
        self.assertEqual(self.recipe["source"]["fps"], {"numerator": 60, "denominator": 1})
        self.assertEqual(Fraction(1450 - 1156, 60), Fraction(49, 10))
        comparison = json.loads((self.stage / handoff.LIBRARY / f"evidence/{handoff.RECIPE}/review01/comparison.json").read_text())
        self.assertEqual(len(comparison["sourcePts"]), 294)
        for frame, value in enumerate(comparison["sourcePts"]):
            difference = abs(Fraction(value["best_effort_timestamp_time"]) - Fraction(1156 + frame, 60))
            self.assertLessEqual(difference, Fraction(1, 1000000))
        self.assertTrue(all(not row["active"] for row in self.rows(294).values()))

    def test_card_growth_keeps_center_without_scaling_labels(self):
        for frame in range(294):
            card = self.rows(frame)["growing-card"]["values"]
            self.assertAlmostEqual(card["y"] + card["height"] / 2, 540)
            self.assertEqual(card["width"], 898)
        for frame, height in ((0, 524), (13, 732), (18, 762), (24, 774), (293, 774)):
            self.assertEqual(self.rows(frame)["growing-card"]["values"]["height"], height)
        self.assertEqual(self.layers["first-task-label"]["fontSize"], 34)
        self.assertEqual(self.rows(0)["first-task-label"]["values"]["scaleY"], 1)

    def test_smooth_is_not_generic_linear(self):
        track = self.layers["moving-base"]["x"]
        expected = -192 + 19 * (0.2 ** 2 * (3 - 2 * 0.2))
        self.assertAlmostEqual(handoff.sample(track, 20), expected)
        self.assertNotAlmostEqual(handoff.sample(track, 20), -192 + 19 * 0.2)

    def test_bezier_handles_equal_smoothstep(self):
        for name in ("moving-base", "moving-folds", "moving-mist", "task-pointer"):
            for segment in handoff.curve(self.layers[name]["x"])["segments"]:
                if segment["mode"] != "smooth":
                    continue
                left, right = segment["left"], segment["right"]
                first, second = segment["bezierHandlesFrameValue"]
                for progress in (0.1, 0.25, 0.5, 0.9):
                    weights = [(1-progress)**3, 3*(1-progress)**2*progress, 3*(1-progress)*progress**2, progress**3]
                    points = [[left["frame"], left["value"]], first, second, [right["frame"], right["value"]]]
                    position = [sum(weight*point[axis] for weight, point in zip(weights, points)) for axis in (0, 1)]
                    self.assertAlmostEqual(position[1], handoff.sample(self.layers[name]["x"], position[0]))

    def test_expression_arithmetic_matches_tables_with_start_offset(self):
        cases = []
        for name, property_name in (("moving-folds", "x"), ("attached-explanation", "width"), ("task-pointer", "y"), ("growing-card", "x")):
            track = self.layers[name][property_name]
            for frame in (0, 43, 73.75, 74, 74.75, 75, 80, 99.25, 112, 112.75, 113, 159, 280, 293):
                cases.append({"expression": handoff.fusion_expression(track, 1000), "time": frame + 1000, "expected": handoff.sample(track, frame)})
        code = "import {runInNewContext} from 'node:vm'; let input=''; for await(const chunk of process.stdin)input+=chunk; for(const item of JSON.parse(input)){const value=runInNewContext(item.expression,{time:item.time,iif:(condition,yes,no)=>condition?yes:no}); if(Math.abs(value-item.expected)>1e-9)throw Error('Expression mismatch');}"
        subprocess.run(["node", "--input-type=module", "-e", code], input=json.dumps(cases), text=True, check=True)

    def test_attachment_is_sibling_fit_not_rigid_pointer_parent(self):
        for frame, expected in ((74, (38, 36)), (99, (38, 35)), (270, (38, 35)), (280, (38, 33))):
            rows = self.rows(frame)
            bubble, pointer = rows["attached-explanation"], rows["task-pointer"]
            self.assertIsNone(bubble["parent"])
            delta = tuple(bubble["anchorPixels"][axis] - pointer["anchorPixels"][axis] for axis in (0, 1))
            self.assertEqual(delta, expected)
            self.assertEqual(rows["typed-explanation"]["anchorPixels"], [bubble["anchorPixels"][0]+36, bubble["anchorPixels"][1]+31])
        rows = self.rows(76)
        self.assertNotEqual(rows["attached-explanation"]["anchorPixels"][0] - rows["task-pointer"]["anchorPixels"][0], 38)

    def test_attached_children_follow_parent_during_motion(self):
        changed = copy.deepcopy(self.recipe)
        bubble = next(layer for layer in changed["layers"] if layer["id"] == "attached-explanation")
        for key in bubble["x"]:
            key["value"] += 17
        for key in bubble["y"]:
            key["value"] -= 9
        for frame in (74, 76, 83, 99, 270, 274, 279, 280):
            original, modified = self.rows(frame), self.rows(frame, changed)
            for name in ("attached-explanation", "explanation-shell", "explanation-text-clip", "typed-explanation"):
                self.assertEqual(modified[name]["anchorPixels"], [original[name]["anchorPixels"][0]+17, original[name]["anchorPixels"][1]-9])
            self.assertEqual(modified["task-pointer"], original["task-pointer"])

    def test_typing_shell_and_no_invented_caret(self):
        text = self.layers["typed-explanation"]
        for variant in ("defaults", "adaptation"):
            content = self.recipe[variant]
            self.assertEqual(list(map(len, content["explanation"].split("\n"))), [32, 35])
            for frame, length in ((73, 0), (74, 1), (75, 2), (79, 6), (80, 6), (112, 32), (113, 34), (114, 36), (159, 68)):
                self.assertEqual(len(handoff.text_at(text, content, frame)), length)
            self.assertEqual(handoff.text_at(text, content, 159), content["explanation"])
        self.assertEqual(self.rows(112)["explanation-shell"]["values"]["height"], 110)
        self.assertEqual(self.rows(113)["explanation-shell"]["values"]["height"], 160)
        self.assertEqual(handoff.sample(self.layers["attached-explanation"]["height"], 112.75), 110)
        self.assertFalse(any("caret" in name for name in self.layers))
        self.assertNotIn("suffix", text)
        self.assertEqual(self.rows(74)["typed-explanation"]["effectiveOpacity"], 0.25)

    def test_departure_and_label_icon_switch_are_one_boundary(self):
        before, after = self.rows(280), self.rows(281)
        for name in ("attached-explanation", "typed-explanation", "first-task-label", "notes-icon-handoff", "notes-art"):
            self.assertTrue(before[name]["active"])
            self.assertFalse(after[name]["active"])
        for name in ("second-task-label", "agenda-icon-handoff", "agenda-art"):
            self.assertFalse(before[name]["active"])
            self.assertTrue(after[name]["active"])
        self.assertTrue(after["task-pointer"]["active"])
        self.assertFalse(self.rows(42)["task-pointer"]["active"])
        self.assertTrue(self.rows(43)["task-pointer"]["active"])

    def test_shared_shimmer_not_nine_independent_phases(self):
        for frame in (0, 13, 24, 99, 159, 220, 270, 293):
            origins = []
            for index in range(1, 10):
                mask = self.layers[f"shimmer-mask-row-{index}"]
                image = self.layers[f"moving-shimmer-{index}"]
                origins.append((mask["x"] + handoff.sample(image["x"], frame), mask["y"] + image["y"]))
                self.assertEqual(mask["overflow"], "hidden")
                self.assertLessEqual(handoff.sample(image["x"], frame), 0)
                self.assertGreaterEqual(handoff.sample(image["x"], frame)+image["width"], mask["width"])
            self.assertEqual(len(set(origins)), 1)

    def test_projected_icon_corners_include_perspective(self):
        row = self.rows(280)["notes-icon-handoff"]
        angle = math.radians(-69)
        points = []
        for horizontal, vertical in ((-104, -104), (104, -104), (104, 104), (-104, 104)):
            denominator = 1 + math.sin(angle)*horizontal/1100
            points.append([960 + math.cos(angle)*horizontal/denominator, 576 + vertical/denominator])
        for actual, expected in zip(row["quadPixelsTLTRBRBL"], points):
            for axis in (0, 1):
                self.assertAlmostEqual(actual[axis], expected[axis])
        self.assertNotEqual(points[0][1], points[1][1])

    def test_svg_only_rejects_raster_and_external_resources(self):
        for name, data in self.members.items():
            if name.endswith(".svg"):
                handoff.validate_svg(data)
        for body in ('<image href="capture.png"/>', '<script/>', '<text>replacement</text>', '<path fill="url(https://invalid.example/paint)"/>', '<path onclick="go()"/>'):
            with self.assertRaises(ValueError):
                handoff.validate_svg(f'<svg width="1" height="1">{body}</svg>'.encode())

    def test_manifest_wrapper_gap_is_retained_not_hidden(self):
        result = handoff.binding_report(self.recipe, self.members, self.locks, self.stage)
        for variant in result.values():
            self.assertTrue(variant["selectedFileHashesMatch"])
            self.assertTrue(variant["inputPropsAndBindingEqual"])
            self.assertFalse(variant["wrapperHashMatchesIndex"])

    def test_invalid_frames_rejected(self):
        for value in ("-1", "295", "nan", "inf"):
            with self.assertRaises(ValueError):
                handoff.parse_frames(value)


class StageConfigurationTests(unittest.TestCase):
    def test_missing_configuration_fails_before_source_reads(self):
        with mock.patch.dict(os.environ, {}, clear=True), mock.patch.object(handoff.Path, "read_text") as read_text:
            with self.assertRaisesRegex(ValueError, "Pass --stage or set FIELDNOTES_STAGE"):
                handoff.load()
            read_text.assert_not_called()
        environment = dict(os.environ)
        environment.pop("FIELDNOTES_STAGE", None)
        result = subprocess.run([sys.executable, str(handoff.HERE / "handoff.py"), "verify"], env=environment, text=True, capture_output=True)
        self.assertEqual(result.returncode, 2)
        self.assertEqual(result.stdout, "")
        self.assertIn("no verification was performed", result.stderr)

    def test_missing_stage_argument_value_fails_clearly(self):
        result = subprocess.run([sys.executable, str(handoff.HERE / "handoff.py"), "verify", "--stage"], text=True, capture_output=True)
        self.assertEqual(result.returncode, 2)
        self.assertEqual(result.stdout, "")
        self.assertIn("expected one argument", result.stderr)

    def test_explicit_stage_precedes_environment(self):
        with mock.patch.dict(os.environ, {"FIELDNOTES_STAGE": "environment-subset"}):
            self.assertEqual(handoff.resolve_stage("argument-subset"), handoff.Path("argument-subset"))
            self.assertEqual(handoff.resolve_stage(handoff.Path("argument-subset")), handoff.Path("argument-subset"))

    def test_environment_fallback_and_empty_configuration(self):
        with mock.patch.dict(os.environ, {"FIELDNOTES_STAGE": "environment-subset"}):
            self.assertEqual(handoff.resolve_stage(), handoff.Path("environment-subset"))
            for empty in ("", "  "):
                with self.assertRaises(ValueError):
                    handoff.resolve_stage(empty)
        with mock.patch.dict(os.environ, {"FIELDNOTES_STAGE": "  "}):
            with self.assertRaises(ValueError):
                handoff.resolve_stage()


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