from dataclasses import replace
import json
import math
from pathlib import Path
import tempfile
import unittest
import hashlib

import numpy as np

from iclbench.cases import make_cases, write_result
from iclbench.evaluate import compare, evaluate, load_result
from iclbench.optics import ARCSEC_PER_RAD, Config, metrics, pupil_opd, simulate


class PhysicsTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        # Native FFT angular sampling avoids testing an interpolation artifact.
        cls.config = Config(grid_n=256, padding=4, output_n=512,
                            angular_pixel_arcsec=550e-9/(4*10.24e-3)*ARCSEC_PER_RAD)
        cls.ideal = simulate(cls.config)

    def test_airy_first_zero_against_analytic_law(self):
        psf = self.ideal["psf"]
        row = psf[psf.shape[0]//2, psf.shape[0]//2:]
        expected = 1.22*550e-9/.004*ARCSEC_PER_RAD
        candidates = np.arange(math.ceil(.8*expected/self.config.angular_pixel_arcsec), math.floor(1.25*expected/self.config.angular_pixel_arcsec)+1)
        observed = candidates[np.argmin(row[candidates])] * self.config.angular_pixel_arcsec
        self.assertLess(abs(observed/expected-1), .1)

    def test_ideal_mtf_against_independent_circular_aperture_formula(self):
        psf = self.ideal["psf"]
        otf = np.abs(np.fft.fft2(np.fft.ifftshift(psf)))
        mtf = otf[0]/otf[0,0]
        frequencies = np.fft.fftfreq(psf.shape[0], d=self.config.angular_pixel_arcsec)
        nu = frequencies / (.004/550e-9/ARCSEC_PER_RAD)
        selected = (nu > .05) & (nu < .8)
        v = nu[selected]
        analytic = 2/math.pi*(np.arccos(v)-v*np.sqrt(1-v*v))
        self.assertLess(float(np.max(np.abs(mtf[selected]-analytic))), .025)

    def test_zero_power_port_is_optically_absent(self):
        base = replace(self.config, eye_power_d=0., icl_power_d=0.)
        a, b = simulate(base), simulate(replace(base,port_mm=.8))
        np.testing.assert_array_equal(a["psf"], b["psf"])

    def test_transparent_port_preserves_pupil_energy(self):
        amplitude, _, count = pupil_opd(replace(self.config, port_mm=.5))
        self.assertAlmostEqual(float((amplitude**2).sum()/count), 1.)

    def test_port_changes_phase_without_black_obstruction(self):
        result = simulate(replace(self.config, port_mm=.6))
        self.assertEqual(result["throughput"], 1.)
        self.assertGreater(float(np.abs(result["psf"]-self.ideal["psf"]).sum()), .01)

    def test_amplitude_squared_energy_law(self):
        result = simulate(replace(self.config,material_amplitude=.7))
        self.assertAlmostEqual(result["throughput"], .49, places=12)
        self.assertAlmostEqual(result["captured_energy"]/self.ideal["captured_energy"], .49, places=10)
        np.testing.assert_allclose(result["psf"],self.ideal["psf"],atol=1e-12)

    def test_perfect_compensation_matches_zero_power_model(self):
        other = simulate(replace(self.config, eye_power_d=0.,icl_power_d=0.))
        np.testing.assert_allclose(self.ideal["psf"],other["psf"],atol=1e-12)

    def test_decenter_prism_against_paraxial_law(self):
        d = .15e-3
        result = simulate(replace(self.config,decenter_x_mm=d*1e3))
        centroid = metrics(result["psf"],self.config.angular_pixel_arcsec)["centroid_arcsec"]
        expected = self.config.icl_power_d*d*ARCSEC_PER_RAD
        self.assertLess(abs(centroid[0]-expected), 5.)
        self.assertLess(abs(centroid[1]), 1.)

    def test_cylinder_rotates_directional_image(self):
        a = simulate(replace(self.config,cylinder_d=.5,cylinder_axis_deg=0.))["psf"]
        b = simulate(replace(self.config,cylinder_d=.5,cylinder_axis_deg=90.))["psf"]
        np.testing.assert_allclose(a.T,b,atol=1e-12)

    def test_spectral_incoherent_sum_on_common_coordinates(self):
        a=simulate(replace(self.config,wavelengths_nm=(450.,)))
        b=simulate(replace(self.config,wavelengths_nm=(650.,)))
        poly=simulate(replace(self.config,wavelengths_nm=(450.,650.),spectral_weights=(.3,.7)))
        expected=.3*a["psf"]*a["captured_energy"]+.7*b["psf"]*b["captured_energy"]
        np.testing.assert_allclose(poly["psf"]*poly["captured_energy"],expected,atol=1e-12)

    def test_grid_refinement_for_nontrivial_port(self):
        base=replace(self.config,grid_n=256,port_mm=.6,padding=4)
        a,b=simulate(base),simulate(replace(base,grid_n=512,padding=4))
        self.assertLess(float(np.abs(a["psf"]-b["psf"]).sum()), .05)
        self.assertLess(abs(a["captured_energy"]-b["captured_energy"]), .01)

    def test_finite_fov_loss_not_silently_renormalized_away(self):
        narrow=simulate(replace(self.config,output_n=32))
        self.assertLess(narrow["captured_energy"],self.ideal["captured_energy"])
        self.assertAlmostEqual(float(narrow["psf"].sum()),1.)

    def test_non_native_multiwavelength_grid_cannot_create_energy(self):
        for step in (4., 8., 13.):
            result=simulate(replace(self.config,angular_pixel_arcsec=step,wavelengths_nm=(450.,550.,650.),spectral_weights=(.2,.5,.3)))
            self.assertLessEqual(result["captured_energy"],result["throughput"]+1e-12)

    def test_bulk_phase_aliasing_rejected_even_with_more_padding(self):
        for padding in (1,4):
            with self.assertRaisesRegex(ValueError,"undersampled"):
                simulate(replace(self.config,pupil_mm=6.5,optic_mm=5.5,padding=padding))

    def test_invalid_configuration_rejected(self):
        for config in [replace(self.config,port_mm=7.),replace(self.config,pupil_mm=20.),
                       replace(self.config,material_amplitude=2.),replace(self.config,eye_power_d=float("nan")),
                       replace(self.config,spectral_weights=(-1.,)),replace(self.config,grid_n=256.5),
                       replace(self.config,output_n=100000)]:
            with self.assertRaises(ValueError):config.validate()


class ProtocolTests(unittest.TestCase):
    def setUp(self):
        self.config=Config(grid_n=128,padding=2,output_n=128)
        self.result=simulate(self.config)

    def test_valid_submission_roundtrip(self):
        with tempfile.TemporaryDirectory() as folder:
            write_result(folder,"test",self.result)
            loaded=load_result(Path(folder)/"test.npz",self.config.to_dict())
            score,_=compare(loaded,loaded)
            self.assertAlmostEqual(score,100.)

    def test_nan_negative_and_wrong_energy_fail(self):
        invalid=[]
        a=dict(self.result);a["psf"]=self.result["psf"].copy();a["psf"][0,0]=np.nan;invalid.append(a)
        a=dict(self.result);a["psf"]=self.result["psf"].copy();a["psf"][0,0]=-1.;invalid.append(a)
        a=dict(self.result);a["throughput"]=1.2;invalid.append(a)
        a=dict(self.result);a["captured_energy"]=2.;invalid.append(a)
        a=dict(self.result);a["angular_pixel_arcsec"]=9.;invalid.append(a)
        with tempfile.TemporaryDirectory() as folder:
            for index,result in enumerate(invalid):
                write_result(folder,str(index),result)
                with self.assertRaises(ValueError):load_result(Path(folder)/f"{index}.npz",self.config.to_dict())

    def test_constant_psf_penalized_for_changed_physics(self):
        changed=simulate(replace(self.config,icl_power_d=-7.5))
        score,_=compare(self.result,changed)
        self.assertLess(score,60.)

    def test_seeded_case_diversity_and_pairs(self):
        cases,pairs=make_cases(123)
        self.assertEqual(len(cases),24)
        self.assertEqual(len(pairs),12)
        self.assertEqual(len({c["id"] for c in cases}),24)
        self.assertEqual(cases,make_cases(123)[0])
        self.assertNotEqual(cases,make_cases(124)[0])

    def test_professional_catalog_coverage(self):
        data=json.loads((Path(__file__).parents[1]/"tasks/catalog.json").read_text())
        self.assertEqual(sum(s["weight"] for s in data["suites"]),100)
        self.assertEqual(sum(len(s["tasks"]) for s in data["suites"]),36)
        self.assertEqual(len({t["id"] for s in data["suites"] for t in s["tasks"]}),36)

    def test_missing_cases_zero_score_and_tampered_release_rejected(self):
        with tempfile.TemporaryDirectory() as folder:
            root=Path(folder)
            (root/"public").mkdir();(root/"private/reference").mkdir(parents=True)
            (root/"submission").mkdir()
            cases=[];pairs=[]
            for family in ["diffraction","defocus","astigmatism","transparent_port","decentration","spectrum"]:
                for branch in ("a","b"):
                    cid=family+branch
                    cases.append({"id":cid,"family":family,"config":self.config.to_dict()})
                    write_result(root/"private/reference",cid,self.result)
                pairs.append({"family":family,"control":family+"a","intervention":family+"b"})
            file=root/"public/cases.json"
            file.write_text(json.dumps({"cases":cases}))
            (root/"private/manifest.json").write_text(json.dumps({"pairs":pairs,"public_sha256":hashlib.sha256(file.read_bytes()).hexdigest()}))
            result=evaluate(root,root/"submission")
            self.assertEqual(result["core_score"],0.)
            self.assertEqual(result["invalid_count"],12)
            self.assertIsNone(result["professional_score"])
            file.write_text(file.read_text()+"\n")
            with self.assertRaises(ValueError):evaluate(root,root/"submission")


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