import numpy as np
import matplotlib.pyplot as plt

# householder function from question 2
def householder_qr(A: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """
    Perform Householder QR factorization on matrix A to obtain orthogonal matrix Q.
    
    Parameters:
    A (np.ndarray): The input matrix to be orthogonalized.
    
    Returns:
    tuple[np.ndarray, np.ndarray]: The orthogonal matrix Q and upper triangular matrix R such that A = QR.
    """
    m, n = A.shape
    Q = np.eye(m, dtype=A.dtype)
    R = A.copy()
    
    for k in range(n):
        # Extract the vector x
        # Target: x -> [||x||, 0, 0, ..., 0]^T
        x = R[k:, k] # x = [a_k, a_(k+1), ..., a_m]^T
        s = np.sign(x[0]) if x[0] != 0 else 1
        e = np.zeros_like(x) # e = [||x||, 0, 0, ..., 0]^T
        e[0] = s * np.linalg.norm(x)
        # Compute the Householder vector
        # x - e is the reflection plane normal
        u = x - e
        if np.linalg.norm(u) < 1e-14:
            continue  # Skip the transformation if u is too small
        v = u / np.linalg.norm(u)
        # Apply the Householder transformation to R[k:, k:]
        R[k:, k:] -= 2 * np.outer(v, v.conj().T @ R[k:, k:])
        # Update Q
        # Q_k = I - 2 v v^*
        Q_k = np.eye(m, dtype=A.dtype)
        Q_k[k:, k:] -= 2 * np.outer(v, v.conj().T)
        Q = Q @ Q_k

    return Q, R

def householder_lstsq(A: np.ndarray, b: np.ndarray) -> np.ndarray:
    """
    Solve the least squares problem Ax = b using Householder reflections.
    
    Parameters:
    A (np.ndarray): The input matrix A.
    b (np.ndarray): The right-hand side vector b.
    
    Returns:
    np.ndarray: The solution vector x.
    """
    """
    Ax = b, A = QR
    Rx = Q^*b
    x = R^{-1}Q^*b
    R is upper triangular
    Q is orthogonal
    """
    m, n = A.shape # m is N + 1, n is n + 1
    Q, R = householder_qr(A)
    # Compute Q^*b
    Qt_b = Q.conj().T @ b
    # Solve Rx = Q^*b using back substitution
    x = np.zeros(n, dtype=A.dtype)
    for i in range(n-1, -1, -1):
        x[i] = (Qt_b[i] - R[i, i+1:] @ x[i+1:]) / R[i, i]
    return x

def f1(t: float) -> float:
    """
    f1(t) = 1 / (1 + t^2)
    """
    return 1.0 / (1.0 + t**2)

def build_A(N: int, n: int, f: callable, f_range: tuple) -> tuple[np.ndarray, np.ndarray]:
    """
    Build the matrix A for the least squares problem.
    
    Parameters:
    n + 1 (int): The number of columns in the matrix A.
    f_range (tuple): The range of the function f.
    Returns:
    np.ndarray: The constructed matrix A.
    """
    """
    {t_j}j=0->N, t_0=f_range[0], t_N=f_range[1], t_j divides [f_range[0], f_range[1]] equally
    A = [[1, t_0, t_0^2, t_0^3, ..., t_0^n],
        [1, t_1, t_1^2, t_1^3, ..., t_1^n],
        ...
        [1, t_N, t_N^2, t_N^3, ..., t_N^n]] (N + 1) x (n + 1)

    b = [f(t_0), f(t_1), ..., f(t_N)]^T (N + 1) x 1
    x = [a_0, a_1, a_2, ..., a_n]^T (n + 1) x 1
    Ax = b, x is the coeffs of the polynomial
    """
    A = np.zeros((N + 1, n + 1), dtype=float)
    t = np.linspace(f_range[0], f_range[1], N + 1)
    for i in range(N + 1):
        A[i, :] = [t[i]**j for j in range(n + 1)]
    b = np.array([f(t_i) for t_i in t])
    return A, b

def question_3c():
    Ns = [30, 30, 30]
    ns = [5, 15, 30]
    # p(t) = a_n t^n + a_(n-1) t^(n-1) + ... + a_1 t + a_0
    # plot f(t) and p(t) on the same figure for each (N, n)
    t = np.linspace(-5, 5, 1000)
    f_t = f1(t)
    plt.figure(figsize=(15, 5))
    for i in range(len(Ns)):
        N = Ns[i]
        n = ns[i]
        A = np.zeros((N + 1, n + 1), dtype=float)
        A, b = build_A(N, n, f1, (-5, 5))
        x = householder_lstsq(A, b)
        p_t = sum([x[j] * t**j for j in range(n + 1)])
        plt.subplot(1, 3, i + 1)
        plt.plot(t, f_t, label='f(t)', color='blue')
        plt.plot(t, p_t, label='p(t)', color='red', linestyle='--')
        plt.title(f'N={N}, n={n}')
        plt.xlabel('t')
        plt.ylabel('y')
        plt.legend()
        plt.grid(True, alpha=0.3)
    plt.suptitle('Least Squares Polynomial Approximation using Householder QR')
    plt.tight_layout(rect=[0, 0.03, 1, 0.95])
    plt.savefig('Project1_A3c_least_squares_approximation.png')
    #plt.show()

def f2(n:int, t: float) -> float:
    """
    f2(t) = t^n + t^(n-1) + ... + t^2 + t + 1
    """
    return sum([t**i for i in range(n + 1)])

def question_3d():
    Ns = [25, 50, 100]
    ns = Ns
    t = np.linspace(-5, 5, 1000)
    f_t = f1(t)
    plt.figure(figsize=(15, 10))
    for i in range(len(Ns)):
        N = Ns[i]
        n = ns[i]
        A = np.zeros((N + 1, n + 1), dtype=float)
        A, b = build_A(N, n, f1, (-5, 5))
        x = householder_lstsq(A, b)
        p_t = sum([x[j] * t**j for j in range(n + 1)])
        plt.subplot(1, 3, i + 1)
        plt.plot(t, f_t, label='f(t)', color='blue')
        plt.plot(t, p_t, label='p(t)', color='red', linestyle='--')
        plt.title(f'N={N}, n={n}')
        plt.xlabel('t')
        plt.ylabel('y')
        plt.legend()
        plt.grid(True, alpha=0.3)
    plt.suptitle('Least Squares Polynomial Approximation using Householder QR')
    plt.tight_layout(rect=[0, 0.03, 1, 0.95])
    plt.savefig('Project1_A3d_least_squares_approximation.png')
    #plt.show()

def question_3e():
    # n = 4, 6, 8, 10, ..., 20
    ns = list(range(4, 21, 2))
    # N = 2n
    # quantity is log10||(a_0-1,...,a_n-1)||_2)
    losses = []
    for n in ns:
        N = 2 * n
        A, b = build_A(N, n, lambda t: f2(n, t), (0, 1))
        x = householder_lstsq(A, b)
        true_coeffs = np.array([1.0 for _ in range(n + 1)])
        loss = np.linalg.norm(x - true_coeffs)
        losses.append(np.log10(loss))
    plt.figure(figsize=(10, 6))
    plt.plot(ns, losses, marker='o')
    plt.xlabel('Polynomial Degree n')
    plt.ylabel('log10(||a - true_coeffs||_2)')
    plt.title('Coefficient Error vs Polynomial Degree using Householder QR')
    plt.xticks(ns)
    plt.grid(True, alpha=0.3)
    plt.savefig('Project1_A3e_coefficient_error.png')
    #plt.show()


if __name__ == "__main__":
    question_3c()
    question_3d()
    question_3e()


