/*
 *  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 groovy.transform.stc

import org.codehaus.groovy.ast.ClassNode
import org.codehaus.groovy.classgen.GeneratorContext
import org.codehaus.groovy.control.BytecodeProcessor
import org.codehaus.groovy.control.CompilePhase
import org.codehaus.groovy.control.MultipleCompilationErrorsException
import org.codehaus.groovy.control.SourceUnit
import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import org.codehaus.groovy.control.customizers.CompilationCustomizer
import org.junit.jupiter.api.Test

import static groovy.test.GroovyAssert.shouldFail

/**
 * Units tests for type checking extensions.
 */
final class TypeCheckingExtensionsTest extends StaticTypeCheckingTestCase {

    private void setExtension(String name) {
        def cz = config.compilationCustomizers.find {
            it instanceof ASTTransformationCustomizer
        }
        if (name) {
            cz.annotationParameters = [extensions: name]
        } else {
            cz.annotationParameters = [:]
        }
    }

    @Test
    void testSetupExtension() {
        extension = 'groovy/transform/stc/SetupTestExtension.groovy'
        assertScript '''
            @ASTTest(phase=INSTRUCTION_SELECTION, value={
                assert node.getNodeMetaData('setup')
            })
            class A {}
            @ASTTest(phase=INSTRUCTION_SELECTION, value={
                assert node.getNodeMetaData('setup') == null
            })
            class B {}
            new A()
        '''
    }

    @Test
    void testNonExistentExtension() {
        def extensionPath = 'groovy/transform/stc/NonExistentTestExtension.groovy'
        extension = extensionPath

        String errorMessage = "Static type checking extension '${extensionPath}' was not found on the classpath."

        String message = shouldFail shell, MultipleCompilationErrorsException, '''
            assert true
        '''

        assert message.contains(errorMessage)
    }

    @Test
    void testFinishExtension() {
        extension = 'groovy/transform/stc/FinishTestExtension.groovy'
        assertScript '''
            @ASTTest(phase=INSTRUCTION_SELECTION, value={
                assert node.getNodeMetaData('finish')
            })
            class A {}
            @ASTTest(phase=INSTRUCTION_SELECTION, value={
                assert node.getNodeMetaData('finish') == null
            })
            class B {}
            new A()
        '''
    }

    @Test
    void testNewMethodAndIsGenerated() {
        extension = 'groovy/transform/stc/NewMethodAndIsGeneratedTestExtension.groovy'
        shouldFailWithMessages '''
            'foo'
        ''',
        'Extension was executed properly'
    }

    @Test
    void testUndefinedVariable() {
        extension = 'groovy/transform/stc/UndefinedVariableTestExtension.groovy'
        try {
            assertScript '''
                foo.toUpperCase() // normal type checker would fail here
            '''
        } catch (MissingPropertyException e) {
            // normal
        }
    }

    @Test
    void testUndefinedVariableNoHandle() {
        extension = 'groovy/transform/stc/UndefinedVariableNoHandleTestExtension.groovy'
        shouldFailWithMessages '''
            foo.toUpperCase() // normal type checker would fail here
        ''',
        'The variable [foo] is undeclared'
    }

    @Test
    void testMissingMethod() {
        extension = null
        shouldFailWithMessages '''
            String msg = 'foo'
            msg.TOUPPERCASE()
        ''',
        'Cannot find matching method'

        extension = 'groovy/transform/stc/MissingMethod1TestExtension.groovy'
        try {
            assertScript '''
                String msg = 'foo'
                msg.TOUPPERCASE()
            '''
        } catch (MissingMethodException e) {
            // normal
        }
    }

    @Test
    void testMissingMethodWithLogic() {
        extension = null
        shouldFailWithMessages '''
            String msg = 'foo'
            msg.SIZE()
            msg.CONCAT('bar')
        ''',
        'Cannot find matching method java.lang.String#SIZE()', 'Cannot find matching method java.lang.String#CONCAT(java.lang.String)'

        extension = 'groovy/transform/stc/MissingMethod2TestExtension.groovy'
        try {
            assertScript '''
                String msg = 'foo'
                msg.SIZE()
                msg.CONCAT('bar')
            '''
        } catch (MissingMethodException e) {
            // normal
        }
    }

    @Test
    void testShouldSilenceTypeChecker() {
        extension = 'groovy/transform/stc/SilentTestExtension.groovy'
        assertScript '''import org.codehaus.groovy.runtime.typehandling.GroovyCastException
            try {
                int x = 'foo'
            } catch (GroovyCastException e) {
            }
        '''
    }

    @Test
    void testShouldChangeErrorPrefix() {
        extension = 'groovy/transform/stc/PrefixChangerTestExtension.groovy'
        shouldFailWithMessages '''
           int x = 'foo'
        ''',
        '[Custom] - Cannot assign value of type java.lang.String to variable of type int'
    }

    @Test
    void testAfterMethodCallHook() {
        extension = 'groovy/transform/stc/SprintfExtension.groovy'
        shouldFailWithMessages '''
            String count = 'foo'
            sprintf("Count = %d", count)
        ''',
        'Parameter types didn\'t match types expected from the format String',
        'For placeholder 1 [%d] expected \'int\' but was \'java.lang.String\''
    }

    @Test
    void testBeforeMethodCallHook() {
        extension = 'groovy/transform/stc/UpperCaseMethodTest1Extension.groovy'
        shouldFailWithMessages '''
            String method() { 'foo' }
            String BOO() { 'bar' }
            method() // ok
            BOO() // error
        ''',
        'Calling a method which is all uppercase is not allowed'
    }

    @Test
    void testBeforeMethodHook() {
        extension = 'groovy/transform/stc/UpperCaseMethodTest2Extension.groovy'
        shouldFailWithMessages '''
            String method() { 'foo' } // ok
            String BOO() { 'bar' } // error
        ''',
        'Defining method which is all uppercase is not allowed'
    }

    @Test
    void testAfterMethodHook() {
        extension = 'groovy/transform/stc/UpperCaseMethodTest3Extension.groovy'
        shouldFailWithMessages '''
            String method() { 'foo' } // ok
            String BOO() { 'bar' } // error
        ''',
        'Defining method which is all uppercase is not allowed'
    }

    @Test
    void testMethodSelection() {
        // first step checks that without extension, type checking works properly
        extension = null
        assertScript '''
            @ASTTest(phase=INSTRUCTION_SELECTION, value={
                assert node.getNodeMetaData('selected') == null
            })
            def str = 'foo'.toUpperCase()
        '''

        // then we use a type checking extension, we add node metadata
        extension = 'groovy/transform/stc/OnMethodSelectionTestExtension.groovy'
        assertScript '''
            @ASTTest(phase=INSTRUCTION_SELECTION, value={
                assert node.getNodeMetaData('selected') == true
            })
            def str = 'foo'.toUpperCase()
        '''
    }

    @Test
    void testUnresolvedProperty() {
        extension = null
        shouldFailWithMessages '''
            'str'.FOO
        ''',
        'No such property: FOO for class: java.lang.String'

        extension = 'groovy/transform/stc/UnresolvedPropertyTestExtension.groovy'
        assertScript '''
            try {
                'str'.FOO
            } catch (MissingPropertyException ex) {
            }
        '''
    }

    @Test
    void testUnresolvedAttribute() {
        extension = null
        shouldFailWithMessages '''
            'str'.@FOO
        ''',
        'No such attribute: FOO for class: java.lang.String'

        extension = 'groovy/transform/stc/UnresolvedAttributeTestExtension.groovy'
        assertScript '''
            try {
                'str'.@FOO
            } catch (MissingFieldException ex) {
            }
        '''
    }

    @Test
    void testScopeEnterScopeExit() {
        extension = 'groovy/transform/stc/ScopeEnterExitTestExtension.groovy'
        shouldFailWithMessages '''
            class Support {
                void foo(Closure c) { c() }
            }
            new Support().foo {
                'a'.toUpperCase()
            }
        ''',
        'Scope enter and exit behave correctly' // we're using shouldFail just to verify that the extension is ran
    }

    @Test
    void testMatchingArguments() {
        extension = 'groovy/transform/stc/ArgumentsTestingTestExtension.groovy'
        shouldFailWithMessages '''
            def zero() {}
            def two(String a, Integer b) {}
            def three(String a, int b, Date c) {}
            'foo'.concat('bar')
            zero()
            two('foo', 1)
            three('foo', 2, new Date())
            three('foo', (Integer)2, new Date())
        ''',
        'Method [zero] with matching arguments found: 0',
        'Method [concat] with matching arguments found: 1',
        'Method [two] with matching arguments found: 2',
        'Method [three] with matching arguments found: 3',
        'Method [three] with matching arguments found: 3'
    }

    @Test
    void testFirstArgsMatches() {
        extension = 'groovy/transform/stc/FirstArgumentsTestingTestExtension.groovy'
        shouldFailWithMessages '''
            def two(String a, Integer b) {}
            def three(String a, int b, Date c) {}
            two('foo', 1)
            three('foo', 2, new Date())
            three('foo', (Integer)2, new Date())
        ''',
        'Method [two] with matching arguments found: 2',
        'Method [three] with matching arguments found: 3',
        'Method [three] with matching arguments found: 3'
    }

    @Test
    void testNthArgMatches() {
        extension = 'groovy/transform/stc/NthArgumentTestingTestExtension.groovy'
        shouldFailWithMessages '''
            def two(String a, Integer b) {}
            def three(String a, int b, Date c) {}
            two('foo', 1)
            three('foo', 2, new Date())
            three('foo', (Integer)2, new Date())
        ''',
        'Method [two] with matching argument found: [0, class java.lang.String]',
        'Method [two] with matching argument found: [1, class java.lang.Integer]',
        'Method [three] with matching argument found: [0, class java.lang.String]',
        'Method [three] with matching argument found: [1, class java.lang.Integer]',
        'Method [three] with matching argument found: [2, class java.util.Date]',
        'Method [three] with matching argument found: [0, class java.lang.String]',
        'Method [three] with matching argument found: [1, class java.lang.Integer]',
        'Method [three] with matching argument found: [2, class java.util.Date]'
    }

    @Test
    void testBinaryOperatorNotFound() {
        extension = null
        shouldFailWithMessages '''
            int x = 1
            Date y = new Date()
            x+y
        ''',
        'Cannot find matching method java.lang.Integer#plus(java.util.Date)'

        extension = 'groovy/transform/stc/BinaryOperatorTestExtension.groovy'
        assertScript '''
            try {
                int x = 1
                Date y = new Date()
                x+y
            } catch (e) {}
        '''
    }

    @Test
    void testBinaryOperatorNotFound2() {
        extension = null
        shouldFailWithMessages '''
            int x = 1
            Date y = new Date()
            x << y
        ''',
        'Cannot find matching method java.lang.Integer#leftShift(java.util.Date)'

        extension = 'groovy/transform/stc/BinaryOperatorTestExtension.groovy'
        assertScript '''
            try {
                int x = 1
                Date y = new Date()
                x+y
            } catch (e) {}
        '''
    }

    @Test
    void testDelegatesTo() {
        String source = '''
            class Item {
                void pick() {}
            }
            void build(Closure c) {
                c.delegate = new Item()
                c.call()
            }
            build {
                pick()
            }
        '''

        extension = null
        shouldFailWithMessages(source, 'Cannot find matching method')

        extension = 'groovy/transform/stc/DelegatesToTestExtension.groovy'
        assertScript(source)
    }

    @Test
    void testIsAnnotatedBy() {
        extension = null
        assertScript '''
            @groovy.transform.stc.MyType(String)
            int foo() { 1 }
        '''

        extension = 'groovy/transform/stc/AnnotatedByTestExtension.groovy'
        assertScript '''
            @groovy.transform.stc.MyType(String)
            @ASTTest(phase=INSTRUCTION_SELECTION,value={
                assert node.getNodeMetaData(INFERRED_RETURN_TYPE) == STRING_TYPE
            })
            int foo() { 1 }
        '''
    }

    @Test
    void testBeforeAfterClass() {
        extension = 'groovy/transform/stc/BeforeAfterClassTestExtension.groovy'
        assertScript '''
            @ASTTest(phase=INSTRUCTION_SELECTION, value={
                assert node.getNodeMetaData(INFERRED_TYPE)?.name == 'A'
                assert node.getNodeMetaData('after') == true
            })
            class A {}

            @ASTTest(phase=INSTRUCTION_SELECTION, value={
                assert node.getNodeMetaData(INFERRED_TYPE) == null // disabled through beforeVisitClass
                assert node.getNodeMetaData('after') == true
            })
            class B {
                void hasTypeCheckingError() { int x = 'foo' }
            }
            new A()
        '''
    }

    @Test
    void testLookupClassNodeNotAvailableOnExtensionsClasspath() {
        extension = 'groovy/transform/stc/RobotMove.groovy'
        assertScript '''
            class Robot {
                void move(String dist) { println "Moved $dist" }
            }

            this.binding.setVariable('robot', new Robot())

            void operate() {
                robot.move "left"
            }

            operate()
        '''
    }

    @Test
    void testShouldNotThrowNPE_Groovy6047() {
        extension = 'groovy/transform/stc/Groovy6047Extension.groovy'
        try {
            assertScript '''
                def b = new Vector()
                b.elems()
            '''
        } catch (MissingMethodException e) {
            // expected
        }
    }

    @Test
    void testAmbiguousMethodCall() {
        // fail with error from type checker
        extension = null
        shouldFailWithMessages '''
            int foo(Integer x) { 1 }
            int foo(String s) { 2 }
            int foo(Date d) { 3 }
            assert foo(null) == 2
        ''', 'Reference to method is ambiguous'
        // fail with error from runtime
        extension = 'groovy/transform/stc/AmbiguousMethods.groovy'
        shouldFail shell, '''
            int foo(Integer x) { 1 }
            int foo(String s) { 2 }
            int foo(Date d) { 3 }
            assert foo(null) == 2
        '''
    }

    @Test
    void testIncompatibleAssignment() {
        String source = '''
            int x = 'x'
            assert x == 120
        '''

        extension = null
        shouldFailWithMessages source,
            'Cannot assign value of type java.lang.String to variable of type int'

        extension = 'groovy/transform/stc/IncompatibleAssignmentTestExtension.groovy'
        assertScript source
    }

    @Test
    void testIncompatibleReturnType() {
        String source = '''
            Date foo() { '1' }
            true
        '''

        extension = null
        shouldFailWithMessages source,
            'Cannot return value of type java.lang.String for method returning java.util.Date'

        extension = 'groovy/transform/stc/IncompatibleReturnTypeTestExtension.groovy'
        assertScript source
    }

    // GROOVY-8168
    @Test
    void testIncompatibleReturnType2() {
        String source = '''
            @FunctionalInterface
            interface Operation {
                double calculate(int i)
            }

            Operation operation = { i -> return 1.0 }
            def result = operation.calculate(2)
            assert result == 1.0d
        '''

        extension = null
        shouldFailWithMessages source,
            'Cannot return value of type java.math.BigDecimal for closure expecting double'

        extension = 'groovy/transform/stc/IncompatibleReturnTypeTestExtension.groovy'
        assertScript source
    }

    @Test
    void testPrecompiledExtension() {
        extension = null
        assertScript '''
            println 'Everything is ok'
        '''

        extension = 'groovy.transform.stc.PrecompiledExtension'
        shouldFailWithMessages '''
            println 'Everything is ok'
        ''',
        'Error thrown from extension'
    }

    @Test
    void testPrecompiledExtensionNotExtendingTypeCheckingDSL() {
        extension = null
        assertScript '''
            println 'Everything is ok'
        '''

        extension = 'groovy.transform.stc.PrecompiledExtensionNotExtendingDSL'
        shouldFailWithMessages '''
            println 'Everything is ok'
        ''',
        'Error thrown from extension in onMethodSelection'
    }

    @Test
    void testExtensionScriptInheritsConfigurationButNotCustomizers() {
        Set<String> postprocessed = []
        Set<String> customized = []

        // not a customizer, so it reaches the extension script only if that script's
        // compiler configuration was derived from the enclosing compilation's
        config.bytecodePostprocessor = { String name, byte[] bytes ->
            postprocessed << name
            bytes
        } as BytecodeProcessor

        // a customizer, registered for the enclosing compilation: it must not reach
        // the extension script, since customizers commonly assume the source units
        // and class nodes of the compilation they were registered for
        config.addCompilationCustomizers(new CompilationCustomizer(CompilePhase.CANONICALIZATION) {
            @Override
            void call(SourceUnit source, GeneratorContext context, ClassNode classNode) {
                customized << classNode.name
            }
        })

        extension = 'groovy/transform/stc/SetupTestExtension.groovy'
        assertScript '''
            class A {}
            new A()
        '''

        // the enclosing compilation is named TestScript<uuid>; the extension script is
        // compiled separately by GroovyTypeCheckingExtensionSupport and named Script<n>
        assert customized.contains('A'), 'customizer should see the enclosing compilation'
        assert postprocessed.any { it.startsWith('Script') },
            'extension script should inherit the enclosing compiler configuration'
        assert !customized.any { it.startsWith('Script') },
            'extension script should not inherit the enclosing compilation customizers'
    }

    @Test
    void testSwitchExpressionIsCaseMethodSelection() {
        extension = 'groovy/transform/stc/SwitchExpressionIsCaseExtension.groovy'
        shouldFailWithMessages '''
            def m(Object o) {
                switch (o) {
                    case String -> 's'
                    default -> 'd'
                }
            }
        ''', 'isCase selected'

        shouldFailWithMessages '''
            def m(int n) {
                switch (n) {
                    case 1..10 -> 'in'
                    default -> 'out'
                }
            }
        ''', 'isCase selected'
    }

    @Test
    void testSwitchExpressionIntrinsicIntDoesNotSelectIsCase() {
        extension = 'groovy/transform/stc/SwitchExpressionIsCaseExtension.groovy'
        assertScript '''
            int m(int n) {
                switch (n) {
                    case 1 -> 10
                    case 2 -> 20
                    default -> 0
                }
            }
            assert m(1) == 10
        '''
    }

    @Test // GROOVY-12289
    void testSwitchExpressionDuplicateLabelEscapeHatch() {
        String dsl = '''
            def dsl(int x) {
                def r = switch (x) {
                    case 1 -> 'a'
                    case 1 -> 'b'
                    default -> 'c'
                }
                r
            }
        '''
        shouldFailWithMessages dsl, 'Duplicate case label: 1'

        // an extension that opts DSL-style methods out of type checking
        // restores dynamic first-match-wins semantics for them
        extension = 'groovy/transform/stc/Groovy12289Extension.groovy'
        assertScript dsl + '''
            assert dsl(1) == 'a'
            assert dsl(9) == 'c'
        '''
    }
}
