import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import networkx as nx
import numpy as np
import pandas as pd
from matplotlib.gridspec import GridSpec

# ===== INPUT =====
# rows = source page, cols = destination page
pages = ["A", "B", "C", "D"]
A = np.array(
    [
        [0, 1, 1, 0],  # A -> B, C
        [0, 0, 1, 0],  # B -> C
        [1, 0, 0, 0],  # C -> A
        [0, 0, 1, 0],  # D -> C
    ],
    dtype=int,
)

d = 0.85
max_iter = 10
tol = 1e-8

# ===== PREP =====
n = len(pages)
base = (1 - d) / n
adj = pd.DataFrame(A, index=pages, columns=pages)

incoming = {p: [src for src in pages if adj.loc[src, p] == 1] for p in pages}
outgoing = {p: int(adj.loc[p].sum())                           for p in pages}

# Identify dangling nodes (no outgoing links)
dangling = [p for p in pages if outgoing[p] == 0]

pr = {p: 1 / n for p in pages}

# ─── FORMULA NOTE ─────────────────────────────────────────────────────────────
#
# FORMULA A (Standard / Simplified):
#   PR(p) = (1-d)/N + d * Σ  PR(q) / L(q)
#                       q∈in(p)
#   where  L(q) = number of outgoing links from q
#          N    = total number of pages
#          d    = damping factor (typically 0.85)
#
# FORMULA B (Original Google / Brin-Page 1998):
#   PR(p) = (1-d) + d * Σ  PR(q) / C(q)
#                     q∈in(p)
#   where  C(q) = out-degree of q (same as L(q))
#          (1-d) is a CONSTANT, not divided by N
#   Sum of all PageRanks converges to N instead of 1.
#
# The two formulas are proportional: PR_google = N * PR_standard
# Both give the same RANKING; only the absolute values differ.
# ──────────────────────────────────────────────────────────────────────────────

print("Step 1: Web Graph (Adjacency Matrix)")
print(adj.to_string())
print(f"\nTotal pages N = {n}   |   Damping factor d = {d}")
print(f"Base term (1-d)/N = {base:.4f}   [Google formula base = {1-d:.4f}]")

print("\nStep 2: Link Structure")
link_df = pd.DataFrame({
    "Page": pages,
    "Incoming": [", ".join(incoming[p]) if incoming[p] else "—" for p in pages],
    "Outgoing Count": [outgoing[p] for p in pages],
    "Dangling?": ["YES" if p in dangling else "no" for p in pages],
})
print(link_df.to_string(index=False))

print(f"\nInitial PageRank: PR(p) = 1/N = 1/{n} = {1/n:.6f} for all p")

# ===== ITERATIONS =====
history = [pr.copy()]

for it in range(1, max_iter + 1):
    new_pr = {}
    print(f"\n\n{'='*60}")
    print(f"  ITERATION {it}")
    print(f"{'='*60}")

    for p in pages:
        s = 0
        terms, nums = [], []

        for src in incoming[p]:
            c = pr[src] / outgoing[src] if outgoing[src] > 0 else 0
            s += c
            terms.append(f"PR({src})/{outgoing[src]}")
            nums.append(f"{pr[src]:.4f}/{outgoing[src]} = {c:.4f}")

        # ── FORMULA A (Standard) — ACTIVE ─────────────────────────────────────
        new_pr[p] = base + d * s

        # ── FORMULA B (Google / Brin-Page) — COMMENTED OUT ────────────────────
        # Uncomment below and comment the line above to use the original formula.
        # new_pr[p] = (1 - d) + d * s
        # Note: results will be N × (Formula A values) in magnitude.
        # ──────────────────────────────────────────────────────────────────────

        print(f"\n  Page {p}")
        print(f"  Incoming links : {', '.join(incoming[p]) if incoming[p] else '—'}")

        if terms:
            # Standard formula shown in full symbolic form
            print(f"  PR({p}) = (1-d)/N + d × ( {' + '.join(terms)} )")
            print(f"         = {base:.4f} + {d} × ( {' + '.join(nums)} )")
            # Google formula (symbolic) — uncomment if using Formula B above
            # print(f"  [Google] PR({p}) = (1-d) + d × ( {' + '.join(terms)} )")
            # print(f"         = {1-d:.4f} + {d} × ( {' + '.join(nums)} )")
        else:
            print(f"  PR({p}) = {base:.4f}  [no incoming links]")
            # Google formula edge case:
            # print(f"  [Google] PR({p}) = {1-d:.4f}  [no incoming links]")

        print(f"  PR({p}) = {new_pr[p]:.6f}")

    history.append(new_pr.copy())
    diff = sum(abs(new_pr[p] - pr[p]) for p in pages)
    pr   = new_pr

    # Per-iteration summary table
    print(f"\n  {'─'*40}")
    print(f"  PageRank after Iteration {it}   (Σ diff = {diff:.2e})")
    print(f"  {'─'*40}")
    iter_df = pd.DataFrame({
        "Page": pages,
        f"PR (iter {it})": [f"{pr[p]:.6f}" for p in pages],
        "Rank": pd.Series([pr[p] for p in pages]).rank(ascending=False).astype(int).values,
    })
    print(iter_df.to_string(index=False))

    if diff < tol:
        print(f"\n  ✓ Converged at iteration {it}  (Σ|ΔPR| = {diff:.2e} < tol={tol})")
        break

# ===== CONVERGENCE TABLE =====
print("\n\n" + "=" * 65)
print("  CONVERGENCE TABLE  (PageRank per page per iteration)")
print("=" * 65)
conv_data = {f"Iter {i}": [round(h[p], 6) for p in pages]
             for i, h in enumerate(history)}
conv_df = pd.DataFrame(conv_data, index=pages)
conv_df.index.name = "Page"
print(conv_df.to_string())

# ===== FINAL RESULT =====
print("\n\n" + "=" * 50)
print("  FINAL PageRank")
print("=" * 50)
final_df = (pd.DataFrame({
    "Page": pages,
    "Final PR": [round(pr[p], 6) for p in pages],
}).assign(
    **{"Normalised PR": lambda df: (df["Final PR"] / df["Final PR"].sum()).round(6),
       "Rank": lambda df: df["Final PR"].rank(ascending=False).astype(int)}
).sort_values("Rank")
 .reset_index(drop=True))

print(final_df.to_string(index=False))
print(f"\n  Sum of PR = {sum(pr.values()):.6f}  (should ≈ 1.0 for Formula A)")

# ===== VISUALIZATION =====
G = nx.DiGraph()
for i, src in enumerate(pages):
    for j, dst in enumerate(pages):
        if A[i, j] == 1:
            G.add_edge(src, dst)

fig = plt.figure(figsize=(16, 10))
gs  = GridSpec(2, 3, figure=fig, hspace=0.45, wspace=0.35)

ax_graph = fig.add_subplot(gs[:, :2])   # left 2/3 — graph
ax_bar   = fig.add_subplot(gs[0, 2])    # top right — bar chart
ax_conv  = fig.add_subplot(gs[1, 2])    # bottom right — convergence

# ── Graph layout & drawing ─────────────────────────────────────────────────
pos = nx.spring_layout(G, seed=42, k=2.2)

pr_vals    = np.array([pr[p] for p in G.nodes()])
pr_norm    = (pr_vals - pr_vals.min()) / (pr_vals.max() - pr_vals.min() + 1e-9)
node_sizes = pr_norm * 3500 + 900

# Node colours scaled by PR using default tab10 cycle
node_colors = [plt.cm.tab10(i / len(pages)) for i in range(len(list(G.nodes())))]

# Edges
nx.draw_networkx_edges(
    G, pos, ax=ax_graph,
    arrows=True, arrowsize=22,
    connectionstyle="arc3,rad=0.12",
    width=1.8,
)

# Nodes
nx.draw_networkx_nodes(
    G, pos, ax=ax_graph,
    node_size=node_sizes,
    node_color=node_colors,
)

# Labels: page name + PR value
labels = {p: f"{p}\n{pr[p]:.4f}" for p in G.nodes()}
nx.draw_networkx_labels(
    G, pos, labels=labels, ax=ax_graph,
    font_size=10, font_weight="bold",
)

ax_graph.set_title("PageRank Graph  (node size ∝ PR)", fontsize=13, fontweight="bold", pad=12)
ax_graph.axis("off")

# ── Bar chart ──────────────────────────────────────────────────────────────
sorted_pages = final_df["Page"].tolist()
sorted_prs   = final_df["Final PR"].tolist()

bars = ax_bar.barh(sorted_pages, sorted_prs, height=0.55)
for bar, val in zip(bars, sorted_prs):
    ax_bar.text(bar.get_width() + 0.003, bar.get_y() + bar.get_height() / 2,
                f"{val:.4f}", va="center", ha="left", fontsize=9, fontweight="bold")

ax_bar.set_xlabel("PageRank", fontsize=9)
ax_bar.set_title("Final PR Ranking", fontsize=11, fontweight="bold")
ax_bar.set_xlim(0, max(sorted_prs) * 1.25)
ax_bar.invert_yaxis()

# ── Convergence lines ──────────────────────────────────────────────────────
for p in pages:
    vals = [history[i][p] for i in range(len(history))]
    ax_conv.plot(range(len(vals)), vals, marker="o", markersize=4, linewidth=1.8, label=p)

ax_conv.set_xlabel("Iteration", fontsize=9)
ax_conv.set_ylabel("PageRank", fontsize=9)
ax_conv.set_title("Convergence", fontsize=11, fontweight="bold")
ax_conv.legend(fontsize=9)
ax_conv.grid(True, alpha=0.3)

fig.suptitle("PageRank Algorithm", fontsize=16, fontweight="bold", y=0.97)
plt.tight_layout()
plt.show()