/**
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.netbeans.html.json.impl;

import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.Reference;
import java.lang.ref.WeakReference;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.java.html.BrwsrCtx;
import net.java.html.json.ComputedProperty;
import net.java.html.json.Model;
import net.java.html.json.Models;
import net.java.html.json.Property;
import org.netbeans.html.context.spi.Contexts;
import org.netbeans.html.json.spi.FunctionBinding;
import org.netbeans.html.json.spi.JSONCall;
import org.netbeans.html.json.spi.PropertyBinding;
import org.netbeans.html.json.spi.Technology;
import org.netbeans.html.json.spi.Transfer;
import static org.testng.Assert.*;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

/**
 *
 * @author Jaroslav Tulach
 */
public class DeepChangeTest {
    private MapTechnology t;
    private BrwsrCtx c;

    @BeforeMethod public void initTechnology() {
        t = new MapTechnology();
        c = Contexts.newBuilder().register(Technology.class, t, 1).
            register(Transfer.class, t, 1).build();
    }
    
    @Model(className = "MyX", targetId = "anythingX", properties = {
        @Property(name = "one", type = MyY.class),
        @Property(name = "all", type = MyY.class, array = true)
    })
    static class X {
        @ComputedProperty @Transitive(deep = true)
        static MyY oneCopy(MyY one) {
            return Models.bind(one, BrwsrCtx.findDefault(X.class));
        }
        @ComputedProperty @Transitive(deep = true) 
        static String oneName(MyY one) {
            return one.getValue();
        }
        @ComputedProperty
        static String sndName(MyY one) {
            if (one == null || one.getValue() == null) {
                return null;
            } else {
                return one.getValue().toUpperCase();
            }
        }
        @ComputedProperty @Transitive(deep = false) 
        static String noName(MyY one) {
            if (one == null || one.getValue() == null) {
                return null;
            } else {
                return one.getValue().toUpperCase();
            }
        }
        @ComputedProperty @Transitive(deep = true) 
        static String thrdName(MyY one) {
            return "X" + one.getCount();
        }
        
        @ComputedProperty
        static String allNames(List<MyY> all) {
            StringBuilder sb = new StringBuilder();
            for (MyY y : all) {
                sb.append(y.getValue());
            }
            return sb.toString();
        }

        @ComputedProperty @Transitive(deep = true)
        static String firstFromNames(List<MyY> all) {
            for (MyY y : all) {
                if (y != null && y.getValue() != null) {
                    return y.getValue();
                }
            }
            return null;
        }
    }
    @Model(className = "MyY", properties = {
        @Property(name = "value", type = String.class),
        @Property(name = "count", type = int.class)
    })
    static class Y {
    }
    @Model(className = "MyOverall", properties = {
        @Property(name = "x", type = MyX.class)
    })
    static class Overall {
        @ComputedProperty @Transitive(deep = true) 
        static String valueAccross(MyX x) {
            return x.getFirstFromNames();
        }
    }
    
    @Test public void isTransitiveChangeNotifiedProperly() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("oneName");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Ahoj");

        p.getOne().setValue("Nazdar");
        
        assertEquals(o.get(), "Nazdar");
        assertEquals(o.changes, 1, "One change so far");
    }
    
    @Test public void isTransitiveChangeInArrayNotifiedProperly() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("allNames");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "HiHello");

        p.getAll().get(0).setValue("Nazdar");
        
        assertEquals(o.get(), "NazdarHello");
        assertEquals(o.changes, 1, "One change so far");
    }

    @Test public void changingModelClass() throws Exception {
        final MyY myY = new MyY("Ahoj", 0);
        MyX p = Models.bind(
            new MyX(myY, new MyY("Hi", 333), new MyY("Hello", 999)),
            c)
        .applyBindings();
        MyY realY = p.getOne();

        Map m = (Map)Models.toRaw(p);
        Object v = m.get("one");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertFalse(o.pb.isReadOnly(), "Normal property");

        final MyY newY = new MyY("Hi", 1);
        p.setOne(newY);

        assertSame(p.getOne(), newY);
        assertEquals(o.changes, 1, "One change");
    }

    @Test public void addingIntoArray() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("allNames");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "HiHello");

        MyY y = new MyY("Cus", 1);
        p.getAll().add(y);
        
        assertEquals(o.changes, 1, "One change so far");
        assertEquals(o.get(), "HiHelloCus");
        
        y.setValue("Nazdar");
        
        assertEquals(o.changes, 2, "2nd change so far");
        assertEquals(o.get(), "HiHelloNazdar");
        
        y.setValue("Zdravim");

        assertEquals(o.changes, 3, "3rd change so far");
        assertEquals(o.get(), "HiHelloZdravim");
    }
    
    @Test public void firstChangeInArrayNotifiedProperly() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("firstFromNames");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Hi");

        p.getAll().get(0).setValue("Nazdar");
        
        assertEquals(o.get(), "Nazdar");
        assertEquals(o.changes, 1, "One change so far");
    }
    
    @Test public void firstChangeInArrayToNull() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("firstFromNames");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Hi");

        p.getAll().get(0).setValue(null);
        
        assertEquals(o.get(), "Hello");
        assertEquals(o.changes, 1, "One change so far");
        
        p.getAll().get(0).setValue("Nazdar");

        assertEquals(o.get(), "Nazdar");
        assertEquals(o.changes, 2, "2nd change so far");
    }
    
    @Test public void firstChangeInArrayNotifiedTransitively() throws Exception {
        MyOverall p = Models.bind(
            new MyOverall(new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999))
        ), c);
        Models.applyBindings(p);
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("valueAccross");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Hi");

        final List<MyY> all = p.getX().getAll();
        MyY refStrong = all.get(0);
        Reference<MyY> ref = new WeakReference<MyY>(refStrong);
        refStrong.setValue("Nazdar");
        
        assertEquals(o.get(), "Nazdar");
        assertEquals(o.changes, 1, "One change so far");

        final MyY hi = Models.bind(new MyY("Ciao", 33), c);
        all.set(0, hi);

        assertEquals(o.changes, 2, "Second change");
        assertEquals(o.get(), "Ciao");

        refStrong.setValue("Ignore");
        assertEquals(o.changes, 2, "Still two changes");

        refStrong = null;
        assertGC(ref, "Original MyY can now disappear");
    }

    @Test
    public void disappearModel() throws Exception {
        MyOverall p = Models.bind(
            new MyOverall(new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999))
        ), c);

        MyY refStrong = disappearModelOperations(p);

        Reference<MyOverall> ref = new WeakReference<MyOverall>(p);
        p = null;
        assertGC(ref, "MyOverall can now disappear");
        assertNotNull(refStrong, "Submodel still used");
    }

    private MyY disappearModelOperations(MyOverall p) throws InvocationTargetException, IllegalAccessException, IllegalArgumentException {
        Models.applyBindings(p);
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("valueAccross");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Hi");
        final List<MyY> all = p.getX().getAll();
        MyY refStrong = all.get(0);
        refStrong.setValue("Nazdar");
        assertEquals(o.get(), "Nazdar");
        assertEquals(o.changes, 1, "One change so far");
        all.clear();
        assertEquals(o.changes, 2, "Second change");
        assertNull(o.get(), "MyY array is empty now");
        refStrong.setValue("Ignore");
        assertEquals(o.changes, 2, "Still two changes");
        return refStrong;
    }
    
    @Test public void secondChangeInArrayIgnored() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("firstFromNames");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Hi");

        p.getAll().get(1).setValue("Nazdar");
        
        assertEquals(o.get(), "Hi");
        assertEquals(o.changes, 0, "No change so far");
    }
    
    @Test public void changeInArraySizeNeedsToBeRecomputed() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("firstFromNames");
        assertNotNull(v, "Value should be in the map");
        assertEquals(v.getClass(), One.class, "It is instance of One");
        One o = (One)v;
        assertEquals(o.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Hi");

        p.getAll().remove(1);
        
        assertEquals(o.get(), "Hi");
        assertEquals(o.changes, 1, "This required a change");
    }
    
    @Test public void doublePropertyChangeNotified() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("oneName");
        assertNotNull(v, "Value should be in the map");
        Object v2 = m.get("sndName");
        assertNotNull(v2, "Value2 should be in the map");
        One o = (One)v;
        One o2 = (One)v2;
        assertEquals(o.changes, 0, "No changes so far");
        assertEquals(o2.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Ahoj");
        assertEquals(o2.get(), "AHOJ");

        p.getOne().setValue("Nazdar");
        
        assertEquals(o.get(), "Nazdar");
        assertEquals(o.changes, 1, "One change so far");
        assertEquals(o2.changes, 1, "One change so far");
        assertEquals(o2.get(), "NAZDAR");
    }
    
    @Test public void onlyAffectedPropertyChangeNotified() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("oneName");
        assertNotNull(v, "Value should be in the map");
        Object v2 = m.get("thrdName");
        assertNotNull(v2, "Value2 should be in the map");
        One o = (One)v;
        One o2 = (One)v2;
        assertEquals(o.changes, 0, "No changes so far");
        assertEquals(o2.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Ahoj");
        assertEquals(o2.get(), "X0");

        p.getOne().setCount(10);
        
        assertEquals(o.get(), "Ahoj");
        assertEquals(o.changes, 0, "Still no change");
        assertEquals(o2.changes, 1, "One change so far");
        assertEquals(o2.get(), "X10");
    }
    
    @Test public void onlyDeepPropsAreNotified() throws Exception {
        MyX p = Models.bind(
            new MyX(new MyY("Ahoj", 0), new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();
        
        Map m = (Map)Models.toRaw(p);
        Object v = m.get("oneName");
        assertNotNull(v, "Value should be in the map");
        Object v2 = m.get("noName");
        assertNotNull(v2, "Value2 should be in the map");
        One o = (One)v;
        One o2 = (One)v2;
        assertEquals(o.changes, 0, "No changes so far");
        assertEquals(o2.changes, 0, "No changes so far");
        assertTrue(o.pb.isReadOnly(), "Derived property");
        assertEquals(o.get(), "Ahoj");
        assertEquals(o2.get(), "AHOJ");

        p.getOne().setValue("Nazdar");
        
        assertEquals(o.get(), "Nazdar");
        assertEquals(o.changes, 1, "One change so far");
        assertEquals(o2.changes, 0, "This change is not noticed");
        assertEquals(o2.get(), "NAZDAR", "but property value changes when computed");
    }

    @Test
    public void mixingContextsIsOK() throws Exception {
        BrwsrCtx ctx = Contexts.newBuilder().build();
        final MyY one = Models.bind(new MyY("Ahoj", 0), ctx);
        MyX p = Models.bind(
            new MyX(one, new MyY("Hi", 333), new MyY("Hello", 999)), c
        ).applyBindings();

        Map m = (Map) Models.toRaw(p);
        Object v = m.get("oneName");
        assertNotNull(v, "Value should be in the map");
        One o = (One) v;
        assertEquals(o.get(), "Ahoj");

        p.getOne().setValue("Nazdar");

        assertEquals(o.get(), "Nazdar");
        assertEquals(o.changes, 1, "One change so far");
    }
    
    @Test
    public void rebindReplacesTheInstance() throws Exception {
        BrwsrCtx ctx = Contexts.newBuilder().build();
        MyX x = new MyX();

        MyY y = Models.bind(new MyY(), ctx);
        x.setOne(y);

        assertSame(x.getOne(), y);
    }

    @Test
    public void rebindReplacesTheInstanceAndNotifies() throws Exception {
        BrwsrCtx ctx = Contexts.newBuilder().build();
        final MyY one = Models.bind(new MyY(), ctx);
        MyX p = Models.bind(
            new MyX(one, new MyY("Hi", 333), new MyY("Hello", 999)), c
        ).applyBindings();

        Map m = (Map) Models.toRaw(p);
        
        Object v = m.get("one");
        assertNotNull(v, "Value should be in the map");
        One o = (One) v;
        assertEquals(o.changes, 0, "No changes yet");

        MyY y = Models.bind(new MyY(), ctx);
        p.setOne(y);

        assertSame(p.getOne(), y);
        assertSame(o.changes, 1, "One change now");
    }

    @Test
    public void mixingWithCloneIsOK() throws Exception {
        BrwsrCtx ctx = Contexts.newBuilder().build();
        final MyY one = Models.bind(new MyY("Ahoj", 0), ctx);
        MyX p = Models.bind(new MyX(one, new MyY("Hi", 333), new MyY("Hello", 999)
        ), c).applyBindings();

        Map m = (Map) Models.toRaw(p);
        Object v = m.get("oneCopy");
        assertNotNull(v, "Value should be in the map");
        One o = (One) v;
        assertEquals(((MyY)o.get()).getValue(), "Ahoj");

        p.getOne().setValue("Nazdar");

        assertEquals(((MyY)o.get()).getValue(), "Nazdar");
        assertEquals(o.changes, 1, "One change so far");
    }

    static final class One {

        int changes;
        final PropertyBinding pb;
        final FunctionBinding fb;

        One(Object m, PropertyBinding pb) throws NoSuchMethodException {
            this.pb = pb;
            this.fb = null;
        }

        One(Object m, FunctionBinding fb) throws NoSuchMethodException {
            this.pb = null;
            this.fb = fb;
        }

        Object get() throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {
            return pb.getValue();
        }

        void set(Object v) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException {
            pb.setValue(v);
        }

        void assertNoChange(String msg) {
            assertEquals(changes, 0, msg);
        }

        void assertChange(String msg) {
            if (changes == 0) {
                fail(msg);
            }
            changes = 0;
        }
    }

    static final class MapTechnology
            implements Technology<Map<String, One>>, Transfer {

        @Override
        public Map<String, One> wrapModel(Object model) {
            return new HashMap<String, One>();
        }

        @Override
        public void valueHasMutated(Map<String, One> data, String propertyName) {
            One p = data.get(propertyName);
            if (p != null) {
                p.changes++;
            }
        }

        @Override
        public void bind(PropertyBinding b, Object model, Map<String, One> data) {
            try {
                One o = new One(model, b);
                data.put(b.getPropertyName(), o);
            } catch (NoSuchMethodException ex) {
                throw new IllegalStateException(ex);
            }
        }

        @Override
        public void expose(FunctionBinding fb, Object model, Map<String, One> data) {
            try {
                data.put(fb.getFunctionName(), new One(model, fb));
            } catch (NoSuchMethodException ex) {
                throw new IllegalStateException(ex);
            }
        }

        @Override
        public void applyBindings(Map<String, One> data) {
        }

        @Override
        public Object wrapArray(Object[] arr) {
            return arr;
        }

        @Override
        public void extract(Object obj, String[] props, Object[] values) {
            Map<?, ?> map = obj instanceof Map ? (Map<?, ?>) obj : null;
            for (int i = 0; i < Math.min(props.length, values.length); i++) {
                if (map == null) {
                    values[i] = null;
                } else {
                    values[i] = map.get(props[i]);
                    if (values[i] instanceof One) {
                        values[i] = ((One) values[i]).pb.getValue();
                    }
                }
            }
        }

        @Override
        public void loadJSON(JSONCall call) {
            call.notifyError(new UnsupportedOperationException());
        }

        @Override
        public <M> M toModel(Class<M> modelClass, Object data) {
            return modelClass.cast(data);
        }

        @Override
        public Object toJSON(InputStream is) throws IOException {
            throw new IOException();
        }

        @Override
        public void runSafe(Runnable r) {
            throw new UnsupportedOperationException("Not supported yet."); //To change body of generated methods, choose Tools | Templates.
        }
    }

    private static void assertGC(Reference<?> ref, String msg) throws InterruptedException {
        for (int i = 0; i < 100; i++) {
            if (isGone(ref)) {
                return;
            }
            try {
                System.gc();
                System.runFinalization();
            } catch (Error err) {
                err.printStackTrace();
            }
        }
        throw new InterruptedException(msg);
    }

    private static boolean isGone(Reference<?> ref) {
        return ref.get() == null;
    }

}
