# Lab 10 - Classes for points, lines, and quadratics
# YOUR NAMES HERE
import math



# a few items about complex numbers
def warmup(): 
    z = 1j # sqrt(-1)
    print("z = sqrt(-1) = i = 1j = ", z)
    print("z's type is ", type(z))
    print("z*z = -1 = ", z*z)
    re = (z*z).real # real part
    im = (z*z).imag # imaginary part
    print("re im = ", re, im)

    # i^i = e^{-pi/2}
    print("i^i = e^{-pi/2} = ", z**z)
    print("i^i = e^{-pi/2} = ", math.exp(-math.pi/2))
    
    # roots of f(x) = x*x - 5*x + 7
    # a = 1, b = -5, c = 7
    r1 = (5/2) - (math.sqrt(3)/2)*1j
    r2 = (5/2) + (math.sqrt(3)/2)*1j
    print(r1, r2)
    print(r1*r1 - 5*r1 + 7) # 0j = 0 + 0j
    print(r2*r2 - 5*r2 + 7) # 0j = 0 + 0j

    # construct a complex number from two real parts
    # 3 + 2j
    re = 3
    im = 2
    z1 = re + im*1j
    print("z1 = ", z1)
    z2 = complex(re, im)
    print("z2 = ", z2)
    # a purely real number, represented as a complex number
    re = 10
    im = 0
    z3 = complex(re, im)
    print("z3 = 10 = 10 + 0j = ", z3)

    print()
    return


# a class to represent a 2D Point
class Point:
    def __init__(self, x, y):  # constructor
        self.x = x
        self.y = y

    def __str__(self):  # printable string version
        return "(" + str(self.x) + "," + str(self.y) + ")"
    
    # distance from this point (self) to the point p
    def distance(self, p):
        dx = p.x-self.x
        dy = p.y-self.y
        return math.sqrt(dx*dx + dy*dy)


# a class to represent a line --- y = m x + b
class Line:
    def __init__(self, v1, v2):  # constructor - three variants
        # slope-intercept form
        if isinstance(v1, (float, int)) and isinstance(v2, (float, int)):
            self.m = v1
            self.b = v2
        # point-slope form
        elif isinstance(v1, Point) and isinstance(v2, (float, int)):
            p1 = v1
            m = v2
            b = p1.y - m*p1.x 
            self.m = m
            self.b = b
        # point-point form
        elif isinstance(v1, Point) and isinstance(v2, Point):
            p1 = v1
            p2 = v2
            m = (p2.y - p1.y)/(p2.x - p1.x)
            b = p1.y - m*p1.x 
            self.m = m
            self.b = b
            
    def __str__(self): # printable string version
        return "y = " + str(self.m) + " x + " + str(self.b)

    # evaluate f(x) = mx + b
    def f(self, x):
        return self.m*x + self.b

    # return slope
    def slope(self):
        return self.m

    # return intercept
    def intercept(self):
        return self.b

    # where does the line cross the x-axis: f(x) = 0
    def xintercept(self):
        return -self.b/self.m

    # return True is self and L are parallel lines
    def isParallel(self, L):
        return math.isclose(self.m,L.m)

    def isPerpendicular(self, L):
        return math.isclose(self.m*L.m, -1)
    
    # return the unique point of intersection or None
    def intersection(self, L):
        if (self.isParallel(L)):
            return None
        x = (L.intercept() - self.b)/(self.m - L.slope())
        y = self.f(x)
        return Point(x,y)

    # return a Line parallel through a given point
    def parallel(self, p):
        return Line(p, self.m)

    # return a Line perpendicular through a given point
    def perpendicular(self, p):
        return Line(p, -1/self.m)


# Class to represent a quadratic function --- a x^2 + b x + c
# where x^Y represents x raised to the y power
class Quadratic:
    # WORKS AS IS! 
    def __init__(self, a, b, c):  # constructor
        # coefficients
        self.a = a
        self.b = b
        self.c = c

    # WORKS AS IS!        
    def __str__(self):  # printable string version
        return "y = " + \
            str(self.a) + " x^2 + " + \
            str(self.b) + " x + " + \
            str(self.c)

    # returns a double representing quadratic function evaluated at x
    # a x^2 + b x + c
    # works even if x is complex!
    def f(self, x):
        return 0

    # returns the discriminant of this quadratic: (b^2 - 4 a c)
    def discriminant(self):
        return 0

    # returns True if the graph of the quadratic opens up \/
    def isConcaveUp(self):
        return False

    # returns True if the graph of the quadratic opens down /\
    def isConcaveDown(self):
        return False

    # returns an int that represents the number of distinct real roots of this quadratic
    # 0 - both roots are complex when the discriminant is negative
    # 1 - both roots are equal and real when the discriminant is zero
    # 2 - both roots are real and distinct when the discriminant is positive
    def realRootCount(self):
        return -1

    # returns a complex value that represents the first root
    # use quadratic formula - https://en.wikipedia.org/wiki/Quadratic_formula
    # see https://www.tutorialspoint.com/complex-numbers-in-python
    # note that if D is negative, then -D is positive
    # use math.sqrt(v) and not v**0.5 for computing square roots
    def root1(self):
        re = 0
        im = 0
        return complex(re,im)

    # returns a complex value that represents the second root
    # might be the same as root1
    # use math.sqrt(v) and not v**0.5 for computing square roots
    def root2(self):
        return complex(0,0)

    # returns a Point representing the vetex of the parabola
    # Recall that the vertex of the parabola is given by (-b/(2a), f(-b/(2a)))
    def vertex(self):
        return Point(0,0)

    # returns the instantaneous slope of this Quadratic at x: 2 a x + b
    def slope(self, x):
        return 0

    # returns a line that represents the derivative of this Quadratic at x
    # 2 a x + b is the slope
    # (x, f(x)) is a point on the line
    # Use point-slope Line constructor
    def derivative(self, x):
        return Line(0,0)

    # integral-ish helper method
    # Define F(x) = (a/3)x^3 + (b/2)x^2 + c x
    def F(self, x):
        return 0

    # returns a double representing the definite integral from x1 < x2
    # integrate(x1,x2) returns F(x2) - F(x1)
    def integrate(self, x1, x2):
        return 0
        

def main():
    # refresher on complex numbers
    warmup()
    
    # Test code for Point class
    print("Point Testing")
    p1 = Point(0,0)
    p2 = Point(6,8)
    print(p1, p2)
    print(p1.distance(p2), Point.distance(p1,p2))  # 10.0 10.0 --- two equivalent ways to accomplish same things
    # Comment out as needed
    
    # Test code for Line class
    print("\nLine Testing")
    line1 = Line(2, 3)  # point-slope contruction
    print(line1)  # y = 2 x + 3
    line2 = Line(Point(1,5), -1/2)  # point-slope contruction
    print(line2)  # y = -0.5 x + 5.5
    line3 = Line(Point(2,2), Point(3,4))
    print(line3)  # y = 2.0 x + -2.0
    print(Line.isParallel(line1, line2))  # False
    print(line1.isParallel(line3))  # True
    print(line2.isParallel(line3))  # False
    print(Line.isPerpendicular(line1, line2))  # True
    print(line1.isPerpendicular(line3))  # False
    print(line2.isPerpendicular(line3))  # True
    print(Line.intersection(line1, line2))  # (1.0,5.0)
    print(line1.intersection(line3))  # None
    print(line2.intersection(line3))  # (3.0,4.0)
    # Comment out as needed
    
    # Test code for Quadratic class
    print("\nQuadratic Testing")
    print("q1")
    q1 = Quadratic(1.0, -5.0, 6.0)
    print(q1)  # 1.0 x^2 + -5.0 x + 6.0
    print(q1.isConcaveUp())  # True
    print(q1.isConcaveDown())  # False
    print(q1.f(0))  # 6.0
    print(q1.f(2))  # 0.0
    print(q1.f(3))  # 0.0
    print("vertex is", q1.vertex())  # (2.5, -0.25)
    print("axis of symmetry is", q1.f(q1.vertex().x))  # -0.25
    print("slope at x=4 is", q1.slope(4))  # 3
    print(q1.integrate(0,2))  # 4.66666666
    print(q1.discriminant())  # 1.0
    print(q1.realRootCount())  # 2
    print(q1.root1())  # 3.0
    print(q1.root2())  # 2.0
    print(q1.f(q1.root1())) # (0+0j)
    print(q1.f(q1.root2())) # (0+0j)
    print(q1.derivative(1))  # y = 2.0 x - 5.0
    print("- - - - - - - - - - - - - - - - -")
    print("q2")
    q2 = Quadratic(-1.0,-2.0,-3.0)
    print(q2)  # -1.0 x^2 + -2.0 x - 3.0
    print(q2.isConcaveUp())  # False
    print(q2.isConcaveDown())  # True
    print(q2.f(0))  # -3
    print(q2.f(2))  # -11
    print(q2.f(3))  # -18
    print("vertex is", q2.vertex())  # (-1,-2)
    print("axis of symmetry is", q2.f(q2.vertex().x))  # -2
    print("slope at x=4 is", q2.slope(4))  # -10
    print(q2.integrate(0,2))  # -12.66666666
    print(q2.discriminant())  # -8
    print(q2.root1())  # (-1.0-1.4142135623730951j)
    print(q2.root2())  # (-1.0+1.4142135623730951j)
    print(q2.f(q2.root1())) # (0+0j)
    print(q2.f(q2.root2())) # (0+0j)
    print(q2.realRootCount())  # 0
    print(q2.derivative(1))  # y = -2.0 x + -2.0
    print("- - - - - - - - - - - - - - - - -")
    print("q3")
    q3 = Quadratic(0.2,2,5)
    print(q3)  # 0.2 x^2 + 2.0 x + 5.0
    print(q3.isConcaveUp())  # True
    print(q3.isConcaveDown())  # Falso
    print(q3.f(0))  # 5
    print(q3.f(-5))  # 0
    print(q3.f(-15))  # 20
    print(q3.vertex())  # (-5,0)
    print(q3.f(q3.vertex().x))  # 0
    print("slope at x=0 is", q3.slope(0))  # 2
    print(q3.integrate(-5,10))  # 225
    print(q3.discriminant())  # 0
    print(q3.root1())  # -5.0
    print(q3.root2())  # -5.0
    print(q3.f(q3.root1())) # (0+0j)
    print(q3.f(q3.root2())) # (0+0j)
    print(q3.realRootCount())  # 1
    print(q3.derivative(0))  # y = 0.4 x + 2.0

main()

