Skip to content
use crate::auth;
use crate::db::DbConfig;
use crate::templates;
use actix_web::{HttpRequest, HttpResponse, get, post, web};
use chrono::{DateTime, Utc};
use maud::html;
use uuid::Uuid;

fn render_content(raw: &str) -> String {
    if raw.contains("<p>") || raw.contains("<br") {
        raw.to_string()
    } else {
        let escaped = raw
            .replace('&', "&amp;")
            .replace('<', "&lt;")
            .replace('>', "&gt;");
        format!(
            "<p>{}</p>",
            escaped.replace("\n\n", "</p><p>").replace('\n', "<br>")
        )
    }
}

fn strip_html_tags(html: &str) -> String {
    let mut result = String::with_capacity(html.len());
    let mut in_tag = false;
    for ch in html.chars() {
        if ch == '<' {
            in_tag = true;
        } else if ch == '>' {
            in_tag = false;
        } else if !in_tag {
            result.push(ch);
        }
    }
    result
        .replace("&nbsp;", " ")
        .replace("&amp;", "&")
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&#39;", "'")
}

struct LatestArticle {
    slug: String,
    title: String,
    audience_slug: String,
    content: String,
    published_at: DateTime<Utc>,
}

struct AudienceRow {
    slug: String,
    name: String,
    description: Option<String>,
}

struct ArticleRow {
    slug: String,
    title: String,
}

struct DashboardArticle {
    id: Uuid,
    title: String,
    audience_name: String,
    is_published: bool,
}

struct CommitRow {
    id: Uuid,
    message: Option<String>,
    created_at: DateTime<Utc>,
}

#[get("/")]
pub async fn index(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let latest = client
        .query_opt(
            "SELECT a.slug, a.title, aud.slug AS audience_slug, c.content, p.published_at \
             FROM published p \
             JOIN articles a ON p.article_id = a.id \
             JOIN audiences aud ON a.audience_id = aud.id \
             JOIN commits c ON p.commit_id = c.id \
             ORDER BY p.published_at DESC \
             LIMIT 1",
            &[],
        )
        .await
        .ok()
        .flatten()
        .map(|row| LatestArticle {
            slug: row.get(0),
            title: row.get(1),
            audience_slug: row.get(2),
            content: row.get(3),
            published_at: row.get(4),
        });

    let author = auth::get_current_author(&req, &db).await;

    let body = html! {
        header .tl.mt5.mb4.mw7 {
            h1 .f-headline.serif.ma0 { "L" span .f2 { "ettre" } }
            p .f4.mid-gray.mt2 { "Just articles" }
        }
        section .tl.mb5.mw7 {
            @if let Some(ref art) = latest {
                @let excerpt: String = strip_html_tags(&art.content).chars().take(200).collect();
                @let date_str = art.published_at.with_timezone(&chrono::Local).format("%B %-d, %Y").to_string();
                a .link.dark-gray.dim href={"/"(&art.audience_slug)"/"(&art.slug)} {
                    h2 .f2.serif.mb1 { (&art.title) }
                }
                p .f5.mid-gray.mb2 { (date_str) }
                p .f5.mid-gray { (excerpt) "…" }
                a .link.dim.db.pa3.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.mt3.pointer href="/audiences" { "all audiences →" }
            } @else {
                p .mid-gray { "Nothing published yet." }
                a .link.dim.db.pa3.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer href="/audiences" { "browse audiences →" }
            }
        }
        section .mw7 {
            @if let Some((_id, name)) = author {
                p .tl { "hello, " (name) " — " a .link.dark-gray.dim href="/dashboard" { "dashboard" } }
            } @else {
                p .tl { a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.pointer href="/login" { "author login" } }
            }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout("lettre", body).into_string())
}

#[get("/audiences")]
pub async fn audiences(_req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let audience_list: Vec<AudienceRow> = client
        .query(
            "SELECT slug, name, description FROM audiences ORDER BY name",
            &[],
        )
        .await
        .unwrap_or_default()
        .into_iter()
        .map(|row| AudienceRow {
            slug: row.get(0),
            name: row.get(1),
            description: row.get(2),
        })
        .collect();

    let body = html! {
        header .mt5.mb4.mw7 {
            h1 .f2.serif.ma0 { "audiences" }
            p .f5.mid-gray { "writing for every kind of reader" }
        }
        section .mw7 {
            @for aud in &audience_list {
                article .mb4.pv3.bt.b--near-white {
                    a .link.dark-gray.dim href={"/"(&aud.slug)} {
                        h2 .f3.serif.mb1 { (&aud.name) }
                    }
                    @if let Some(ref desc) = aud.description {
                        p .f5.mid-gray.measure { (desc) }
                    }
                }
            }
        }
        nav .tl.mt4.mid-gray {
            a .link.dark-gray.dim href="/" { "← back home" }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout("audiences — lettre", body).into_string())
}

#[get("/{audience_slug}")]
pub async fn audience_page(
    _req: HttpRequest,
    db: web::Data<DbConfig>,
    path: web::Path<String>,
) -> HttpResponse {
    let slug = path.into_inner();
    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let audience = client
        .query_opt(
            "SELECT id, name, description FROM audiences WHERE slug = $1",
            &[&slug],
        )
        .await
        .ok()
        .flatten();

    let Some(audience) = audience else {
        return HttpResponse::NotFound().body("Audience not found");
    };

    let audience_name: String = audience.get(1);

    let article_list: Vec<ArticleRow> = client
        .query(
            "SELECT a.slug, a.title \
             FROM articles a \
             JOIN published pub ON pub.article_id = a.id \
             WHERE a.audience_id = (SELECT id FROM audiences WHERE slug = $1) \
             ORDER BY pub.published_at DESC",
            &[&slug],
        )
        .await
        .unwrap_or_default()
        .into_iter()
        .map(|row| ArticleRow {
            slug: row.get(0),
            title: row.get(1),
        })
        .collect();

    let page_title = format!("{audience_name} — lettre");

    let body = html! {
        header .mt5.mb4.mw7 {
            h1 .f2.serif.ma0 { (&audience_name) }
            p .f5.mid-gray { "an audience" }
        }
        section .mw7 {
            @for art in &article_list {
                article .mb3.pv3.bt.b--near-white {
                    a .link.dark-gray.dim href={"/"(&slug)"/"(&art.slug)} {
                        h3 .f3.serif.mb1 { (&art.title) }
                    }
                }
            }
        }
        nav .tl.mt4.mid-gray {
            a .link.dark-gray.dim href="/audiences" { "← all audiences" }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout(&page_title, body).into_string())
}

#[get("/{audience_slug}/{article_slug}")]
pub async fn article_page(
    _req: HttpRequest,
    db: web::Data<DbConfig>,
    path: web::Path<(String, String)>,
) -> HttpResponse {
    let (audience_slug, article_slug) = path.into_inner();
    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let row = client
        .query_opt(
            "SELECT a.title, c.content, pub.published_at, c.message \
             FROM articles a \
             JOIN published pub ON pub.article_id = a.id \
             JOIN commits c ON pub.commit_id = c.id \
             JOIN audiences aud ON a.audience_id = aud.id \
             WHERE aud.slug = $1 AND a.slug = $2",
            &[&audience_slug, &article_slug],
        )
        .await
        .ok()
        .flatten();

    let Some(row) = row else {
        return HttpResponse::NotFound().body("Article not found");
    };

    let title: String = row.get(0);
    let content_text: String = row.get(1);
    let published_at: DateTime<Utc> = row.get(2);
    let commit_msg: Option<&str> = row.get(3);
    let date_str = published_at
        .with_timezone(&chrono::Local)
        .format("%B %-d, %Y")
        .to_string();
    let content_html = render_content(&content_text);
    let page_title = format!("{title} — lettre");

    let body = html! {
        article .mt5.lh-copy style="max-width:83rem" {
            header .mb4 {
                h1 .f2.serif.ma0.mb2 { (title) }
                p .f6.mid-gray {
                    (date_str)
                    @if let Some(msg) = commit_msg {
                        " — " (msg)
                    }
                }
            }
            section .measure-wide.lh-copy.serif {
                (maud::PreEscaped(&content_html))
            }
        }
        nav .tl.mt4.mb5.mid-gray {
            a .link.dark-gray.dim href={"/"(audience_slug)} { "← back to " (audience_slug) }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout(&page_title, body).into_string())
}

#[get("/login")]
pub async fn login_page(_req: HttpRequest) -> HttpResponse {
    let body = html! {
        section .mt5.mw7.center {
            h1 .f2.serif.tl.mb3 { "sign in" }
            form .measure action="/login" method="post" {
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="email" { "email" }
                    input #email .pa2.w-100.ba.b--near-white type="email" name="email" required="";
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="password" { "password" }
                    input #password .pa2.w-100.ba.b--near-white type="password" name="password" required="";
                }
                div .tl {
                    button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "sign in" }
                }
            }
            p .tl.mid-gray.mt4.f6 { "need an account? " a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.pointer href="/invite" { "use an invite" } }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout("sign in — lettre", body).into_string())
}

#[post("/login")]
pub async fn login_post(
    _req: HttpRequest,
    db: web::Data<DbConfig>,
    form: web::Form<LoginFormData>,
) -> HttpResponse {
    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let author = client
        .query_opt(
            "SELECT id, password_hash FROM authors WHERE email = $1",
            &[&form.email],
        )
        .await
        .ok()
        .flatten();

    match author {
        Some(row) => {
            let id: Uuid = row.get(0);
            let hash: String = row.get(1);
            if auth::verify_password(&form.password, &hash) {
                match auth::create_session(&db, id).await {
                    Ok(token) => HttpResponse::SeeOther()
                        .cookie(
                            actix_web::cookie::Cookie::build("session_token", token.to_string())
                                .path("/")
                                .http_only(true)
                                .finish(),
                        )
                        .insert_header(("Location", "/dashboard"))
                        .finish(),
                    Err(e) => {
                        log::error!("Session creation failed: {e}");
                        HttpResponse::InternalServerError().body("Could not create session")
                    }
                }
            } else {
                HttpResponse::SeeOther()
                    .insert_header(("Location", "/login?error=invalid"))
                    .finish()
            }
        }
        None => HttpResponse::SeeOther()
            .insert_header(("Location", "/login?error=invalid"))
            .finish(),
    }
}

#[post("/logout")]
pub async fn logout_post(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
    if let Some(cookie) = req.cookie("session_token")
        && let Ok(token) = Uuid::parse_str(cookie.value())
    {
        let _ = auth::delete_session(&db, token).await;
    }
    HttpResponse::SeeOther()
        .cookie(
            actix_web::cookie::Cookie::build("session_token", "")
                .path("/")
                .http_only(true)
                .max_age(actix_web::cookie::time::Duration::ZERO)
                .finish(),
        )
        .insert_header(("Location", "/"))
        .finish()
}

#[get("/invite")]
pub async fn invite_page(_req: HttpRequest) -> HttpResponse {
    let body = html! {
        section .mt5.mw7.center {
            h1 .f2.serif.tl.mb3 { "generate invite" }
            p .f5.mid-gray.tl.mb4 { "enter the main key to create an invite ticket" }
            form .measure action="/invite" method="post" {
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="key" { "main key" }
                    input .pa2.w-100.ba.b--near-white type="password" name="key" required="";
                }
                div .tl {
                    button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "generate" }
                }
            }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout("invite — lettre", body).into_string())
}

#[post("/invite")]
pub async fn invite_post(
    _req: HttpRequest,
    db: web::Data<DbConfig>,
    form: web::Form<InviteFormData>,
) -> HttpResponse {
    let main_key = std::env::var("MAIN_KEY").unwrap_or_default();
    if form.key != main_key {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/invite?error=invalid"))
            .finish();
    }

    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let code = Uuid::new_v4().to_string();
    match client
        .execute("INSERT INTO invite_tickets (code) VALUES ($1)", &[&code])
        .await
    {
        Ok(_) => {
            let invite_link = format!("/register/{code}");
            let body = html! {
                section .mt5.mw7.center {
                    h1 .f2.serif.tl.mb3 { "invite created" }
                    p .f5.tl.mb4 { "share this link with the person you want to invite:" }
                    div .pa3.ba.b--near-white.bg-light-gray.tl.mono.f6.break-all {
                        (invite_link)
                    }
                    p .f6.mid-gray.tl.mt3 { "this link can only be used once" }
                }
            };
            HttpResponse::Ok()
                .content_type("text/html; charset=utf-8")
                .body(templates::layout("invite created — lettre", body).into_string())
        }
        Err(e) => {
            log::error!("Invite creation failed: {e}");
            HttpResponse::InternalServerError().body("Could not create invite")
        }
    }
}

#[get("/register")]
pub async fn register_redirect(_req: HttpRequest) -> HttpResponse {
    HttpResponse::SeeOther()
        .insert_header(("Location", "/login"))
        .finish()
}

#[get("/register/{code}")]
pub async fn register_page(
    _req: HttpRequest,
    path: web::Path<String>,
    db: web::Data<DbConfig>,
) -> HttpResponse {
    let code = path.into_inner();
    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let valid = client
        .query_opt(
            "SELECT id FROM invite_tickets WHERE code = $1 AND used = FALSE",
            &[&code],
        )
        .await
        .ok()
        .flatten();

    if valid.is_none() {
        let body = html! {
            section .mt5.mw7.center {
                h1 .f2.serif.tl.mb3 { "invalid invite" }
                p .f5.mid-gray.tl { "this invite code is not valid or has already been used" }
            }
        };
        return HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(templates::layout("invalid invite — lettre", body).into_string());
    }

    let form_action = format!("/register/{code}");
    let body = html! {
        section .mt5.mw7.center {
            h1 .f2.serif.tl.mb3 { "create account" }
            form .measure action=(form_action) method="post" {
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="display_name" { "display name" }
                    input .pa2.w-100.ba.b--near-white type="text" name="display_name" required="";
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="reg_email" { "email" }
                    input .pa2.w-100.ba.b--near-white type="email" name="email" required="";
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="reg_password" { "password" }
                    input .pa2.w-100.ba.b--near-white type="password" name="password" required="";
                }
                div .tl {
                    button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "create account" }
                }
            }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout("create account — lettre", body).into_string())
}

#[post("/register/{code}")]
pub async fn register_post(
    _req: HttpRequest,
    path: web::Path<String>,
    db: web::Data<DbConfig>,
    form: web::Form<RegisterFormData>,
) -> HttpResponse {
    let code = path.into_inner();
    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let valid = client
        .query_opt(
            "SELECT id FROM invite_tickets WHERE code = $1 AND used = FALSE",
            &[&code],
        )
        .await
        .ok()
        .flatten();

    if valid.is_none() {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    }

    let password_hash = match auth::hash_password(&form.password) {
        Ok(h) => h,
        Err(e) => {
            log::error!("Password hashing failed: {e}");
            return HttpResponse::InternalServerError().body("Could not create account");
        }
    };

    let author_id = Uuid::new_v4();
    let result = client
        .execute(
            "INSERT INTO authors (id, email, password_hash, display_name) VALUES ($1, $2, $3, $4)",
            &[&author_id, &form.email, &password_hash, &form.display_name],
        )
        .await;

    match result {
        Ok(_) => {
            let _ = client
                .execute(
                    "UPDATE invite_tickets SET used = TRUE WHERE code = $1",
                    &[&code],
                )
                .await;

            match auth::create_session(&db, author_id).await {
                Ok(token) => HttpResponse::SeeOther()
                    .cookie(
                        actix_web::cookie::Cookie::build("session_token", token.to_string())
                            .path("/")
                            .http_only(true)
                            .finish(),
                    )
                    .insert_header(("Location", "/dashboard"))
                    .finish(),
                Err(e) => {
                    log::error!("Session creation failed: {e}");
                    HttpResponse::SeeOther()
                        .insert_header(("Location", "/login"))
                        .finish()
                }
            }
        }
        Err(e) => {
            log::error!("Author creation failed: {e}");
            HttpResponse::SeeOther()
                .insert_header(("Location", "/register"))
                .finish()
        }
    }
}

#[get("/dashboard")]
pub async fn dashboard(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
    let Some((author_id, author_name)) = auth::get_current_author(&req, &db).await else {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    };

    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let dash_articles: Vec<DashboardArticle> = client
        .query(
            "SELECT a.id, a.title, aud.name AS audience_name, \
             pub.commit_id IS NOT NULL AS is_published \
             FROM articles a \
             JOIN audiences aud ON a.audience_id = aud.id \
             LEFT JOIN published pub ON pub.article_id = a.id \
             WHERE a.author_id = $1 \
             ORDER BY a.created_at DESC",
            &[&author_id],
        )
        .await
        .unwrap_or_default()
        .into_iter()
        .map(|row| DashboardArticle {
            id: row.get(0),
            title: row.get(1),
            audience_name: row.get(2),
            is_published: row.get(3),
        })
        .collect();

    let body = html! {
        header .mt5.mb4.mw7 {
            h1 .f2.serif.ma0 { "dashboard" }
            p .f5.mid-gray { "hello, " (author_name) }
        }
        section .mb4.mw7 {
            a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.mr2.pointer href="/dashboard/articles/new" { "new article" }
            a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.mr2.pointer href="/dashboard/audiences/new" { "new audience" }
            a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.pointer href="/invite" { "invite author" }
        }
        section .mb5.mw7 {
            @for art in &dash_articles {
                @let edit_url = format!("/dashboard/articles/{}/edit", art.id);
                article .mb3.pv3.bt.b--near-white {
                    div .flex.items-center {
                        h3 .f4.serif.ma0 { (&art.title) }
                        @if art.is_published {
                            span .ml2.f7.dark-green { "published" }
                        } @else {
                            span .ml2.f7.mid-gray { "draft" }
                        }
                    }
                    p .f6.mid-gray { (&art.audience_name) " — " a .link.dim.pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.pointer href=(edit_url) { "edit" } }
                }
            }
        }
        nav .tl.mid-gray {
            form action="/logout" method="post" style="display:inline" {
                button .pa2.ph3.ba.b--moon-gray.bg-near-white.serif.f6.hover-bg-light-gray.dark-gray.dim.pointer type="submit" { "sign out" }
            }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout("dashboard — lettre", body).into_string())
}

#[get("/dashboard/articles/new")]
pub async fn new_article_page(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
    let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    };

    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let audience_list: Vec<(Uuid, String)> = client
        .query("SELECT id, name FROM audiences ORDER BY name", &[])
        .await
        .unwrap_or_default()
        .into_iter()
        .map(|row| (row.get::<_, Uuid>(0), row.get::<_, String>(1)))
        .collect();

    if audience_list.is_empty() {
        let body = html! {
            section .mt5.mw7.center {
                h1 .f2.serif.tl.mb3 { "no audiences yet" }
                p .f5.mid-gray.tl { "you need at least one audience before creating articles" }
                a .link.dark-gray.dim.db.tl.mt3 href="/dashboard" { "← back to dashboard" }
            }
        };
        return HttpResponse::Ok()
            .content_type("text/html; charset=utf-8")
            .body(templates::layout("no audiences — lettre", body).into_string());
    }

    let body = html! {
        section .mt5.mw7.center {
            h1 .f2.serif.tl.mb3 { "new article" }
            form .measure action="/dashboard/articles" method="post" on-submit="q('#content').value = window.quillEditor.getSemanticHTML()" {
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="title" { "title" }
                    input #title .pa2.w-100.ba.b--near-white type="text" name="title" required="";
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="slug" { "slug (url-safe)" }
                    input #slug .pa2.w-100.ba.b--near-white type="text" name="slug" required="";
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="audience_id" { "audience" }
                    select #audience_id .pa2.w-100.ba.b--near-white name="audience_id" {
                        @for (id, name) in &audience_list {
                            option value=(id.to_string()) { (name) }
                        }
                    }
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="quill-editor" { "content" }
                    div #quill-editor style="min-height: 20rem;" on-init="window.quillEditor = new Quill('#quill-editor', {theme: 'snow', placeholder: 'write your article…'})" {}
                    textarea #content style="display:none" name="content" {}
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="message" { "commit message" }
                    input #message .pa2.w-100.ba.b--near-white type="text" name="message" placeholder="initial draft";
                }
                div .tl {
                    button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "create" }
                }
            }
            a .link.dark-gray.dim.db.tl.mt4.f6 href="/dashboard" { "← back to dashboard" }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout("new article — lettre", body).into_string())
}

#[post("/dashboard/articles")]
pub async fn create_article(
    req: HttpRequest,
    db: web::Data<DbConfig>,
    form: web::Form<CreateArticleFormData>,
) -> HttpResponse {
    let Some((author_id, _)) = auth::get_current_author(&req, &db).await else {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    };

    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let article_id = Uuid::new_v4();
    let audience_id = match Uuid::parse_str(&form.audience_id) {
        Ok(id) => id,
        Err(_) => return HttpResponse::BadRequest().body("Invalid audience"),
    };

    let result = client
        .execute(
            "INSERT INTO articles (id, title, slug, audience_id, author_id) VALUES ($1, $2, $3, $4, $5)",
            &[&article_id, &form.title, &form.slug, &audience_id, &author_id],
        )
        .await;

    if let Err(e) = result {
        log::error!("Article creation failed: {e}");
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/dashboard/articles/new"))
            .finish();
    }

    let commit_id = Uuid::new_v4();
    let message = if form.message.is_empty() {
        None
    } else {
        Some(form.message.as_str())
    };

    let _ = client
        .execute(
            "INSERT INTO commits (id, article_id, content, message) VALUES ($1, $2, $3, $4)",
            &[&commit_id, &article_id, &form.content, &message],
        )
        .await;

    let redirect_url = format!("/dashboard/articles/{article_id}/edit");
    HttpResponse::SeeOther()
        .insert_header(("Location", redirect_url))
        .finish()
}

#[get("/dashboard/articles/{id}/edit")]
pub async fn edit_article_page(
    req: HttpRequest,
    db: web::Data<DbConfig>,
    path: web::Path<String>,
) -> HttpResponse {
    let article_id_str = path.into_inner();
    let article_id = match Uuid::parse_str(&article_id_str) {
        Ok(id) => id,
        Err(_) => return HttpResponse::BadRequest().body("Invalid article ID"),
    };

    let Some((author_id, _)) = auth::get_current_author(&req, &db).await else {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    };

    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let article = client
        .query_opt(
            "SELECT a.title, a.slug, aud.name, a.audience_id \
             FROM articles a JOIN audiences aud ON a.audience_id = aud.id \
             WHERE a.id = $1 AND a.author_id = $2",
            &[&article_id, &author_id],
        )
        .await
        .ok()
        .flatten();

    let Some(article) = article else {
        return HttpResponse::NotFound().body("Article not found");
    };

    let title: String = article.get(0);
    let audience_name: String = article.get(2);

    let current_content: String = client
        .query_opt(
            "SELECT content FROM commits WHERE article_id = $1 ORDER BY created_at DESC LIMIT 1",
            &[&article_id],
        )
        .await
        .ok()
        .flatten()
        .map(|r| r.get::<_, String>(0))
        .unwrap_or_default();

    let published_commit_id: Option<Uuid> = client
        .query_opt(
            "SELECT commit_id FROM published WHERE article_id = $1",
            &[&article_id],
        )
        .await
        .ok()
        .flatten()
        .map(|r| r.get(0));

    let commit_list: Vec<CommitRow> = client
        .query(
            "SELECT id, message, created_at FROM commits WHERE article_id = $1 ORDER BY created_at DESC",
            &[&article_id],
        )
        .await
        .unwrap_or_default()
        .into_iter()
        .map(|row| CommitRow {
            id: row.get(0),
            message: row.get(1),
            created_at: row.get(2),
        })
        .collect();

    let page_title = format!("edit: {title} — lettre");
    let commit_action = format!("/dashboard/articles/{article_id}/commit");

    let body = html! {
        section .mt5 {
            div .tl.mid-gray.f6.mb4 {
                a .link.dark-gray.dim href="/dashboard" { "← back to dashboard" }
            }

            div .flex {
                aside .w-50.pr4 {
                    p .f7.mid-gray.ttu.tracked.mb3 { "preview" }
                    article .lh-copy {
                        header .mb4 {
                            h1 .f2.serif.ma0.mb2 { (title) }
                            p .f6.mid-gray { (audience_name) }
                        }
                        section #preview-content .measure.lh-copy.serif {
                            (maud::PreEscaped(&current_content))
                        }
                    }
                }

                div .w-50.pl4.bl.b--near-white {
                    form .mb4 action=(commit_action) method="post" on-submit="q('#ed_content').value = window.quillEditor.getSemanticHTML()" {
                        div .mb3 {
                            label .db.mb1.f6.mid-gray for="quill-editor" { "content" }
                            div #quill-editor style="min-height: 20rem;" on-init="window.quillEditor = new Quill('#quill-editor', {theme: 'snow'}); window.quillEditor.on('text-change', () => { q('#preview-content').innerHTML = window.quillEditor.getSemanticHTML() })" {
                                (maud::PreEscaped(&current_content))
                            }
                            textarea #ed_content style="display:none" name="content" {}
                        }
                        div .mb3 {
                            label .db.mb1.f6.mid-gray for="commit_msg" { "commit message" }
                            input #commit_msg .pa2.w-100.ba.b--near-white type="text" name="message" placeholder="what changed";
                        }
                        div .tl {
                            button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "save commit" }
                        }
                    }

                    @if !commit_list.is_empty() {
                        section .mb4 {
                            h2 .f4.serif.mb2 { "commits" }
                            @for commit in commit_list.iter().take(2) {
                                @let date_str = commit.created_at.with_timezone(&chrono::Local).format("%b %-d %H:%M").to_string();
                                @let publish_action = format!("/dashboard/articles/{article_id}/publish");
                                div .mb2.pv2.bt.b--near-white.flex.items-center {
                                    span .f6.mr2 { (date_str) }
                                    @if let Some(ref msg) = commit.message {
                                        span .f6.mid-gray { (msg) }
                                    }
                                    @if Some(commit.id) == published_commit_id {
                                        span .ml2.f7.dark-green { "published" }
                                    } @else {
                                        form .ml2 action=(publish_action) method="post" style="display:inline" {
                                            input type="hidden" name="commit_id" value=(commit.id.to_string());
                                            button .f7.ba.b--moon-gray.bg-near-white.serif.pa1.ph2.hover-bg-light-gray.dark-gray.dim.pointer type="submit" { "publish this" }
                                        }
                                    }
                                }
                            }
                            @if commit_list.len() > 2 {
                                details .mt2 {
                                    summary .f6.mid-gray.pointer.mb2 { "older commits" }
                                    @for commit in commit_list.iter().skip(2) {
                                        @let date_str = commit.created_at.with_timezone(&chrono::Local).format("%b %-d %H:%M").to_string();
                                        @let publish_action = format!("/dashboard/articles/{article_id}/publish");
                                        div .mb2.pv2.bt.b--near-white.flex.items-center {
                                            span .f6.mr2 { (date_str) }
                                            @if let Some(ref msg) = commit.message {
                                                span .f6.mid-gray { (msg) }
                                            }
                                            @if Some(commit.id) == published_commit_id {
                                                span .ml2.f7.dark-green { "published" }
                                            } @else {
                                                form .ml2 action=(publish_action) method="post" style="display:inline" {
                                                    input type="hidden" name="commit_id" value=(commit.id.to_string());
                                                    button .f7.ba.b--moon-gray.bg-near-white.serif.pa1.ph2.hover-bg-light-gray.dark-gray.dim.pointer type="submit" { "publish this" }
                                                }
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout(&page_title, body).into_string())
}

#[post("/dashboard/articles/{id}/commit")]
pub async fn commit_article(
    req: HttpRequest,
    db: web::Data<DbConfig>,
    path: web::Path<String>,
    form: web::Form<CommitFormData>,
) -> HttpResponse {
    let article_id_str = path.into_inner();
    let article_id = match Uuid::parse_str(&article_id_str) {
        Ok(id) => id,
        Err(_) => return HttpResponse::BadRequest().body("Invalid article ID"),
    };

    let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    };

    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let parent = client
        .query_opt(
            "SELECT id FROM commits WHERE article_id = $1 ORDER BY created_at DESC LIMIT 1",
            &[&article_id],
        )
        .await
        .ok()
        .flatten();

    let parent_id: Option<Uuid> = parent.as_ref().map(|r| r.get(0));

    let commit_id = Uuid::new_v4();
    let message = if form.message.is_empty() {
        None
    } else {
        Some(form.message.as_str())
    };

    let _ = client
        .execute(
            "INSERT INTO commits (id, article_id, content, message, parent_id) VALUES ($1, $2, $3, $4, $5)",
            &[&commit_id, &article_id, &form.content, &message, &parent_id],
        )
        .await;

    let redirect_url = format!("/dashboard/articles/{article_id}/edit");
    HttpResponse::SeeOther()
        .insert_header(("Location", redirect_url))
        .finish()
}

#[post("/dashboard/articles/{id}/publish")]
pub async fn publish_article(
    req: HttpRequest,
    db: web::Data<DbConfig>,
    path: web::Path<String>,
    form: web::Form<PublishFormData>,
) -> HttpResponse {
    let article_id_str = path.into_inner();
    let article_id = match Uuid::parse_str(&article_id_str) {
        Ok(id) => id,
        Err(_) => return HttpResponse::BadRequest().body("Invalid article ID"),
    };

    let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    };

    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let commit_id = match Uuid::parse_str(&form.commit_id) {
        Ok(id) => id,
        Err(_) => return HttpResponse::BadRequest().body("Invalid commit ID"),
    };

    let _ = client
        .execute(
            "INSERT INTO published (article_id, commit_id) VALUES ($1, $2) \
             ON CONFLICT (article_id) DO UPDATE SET commit_id = $2, published_at = NOW()",
            &[&article_id, &commit_id],
        )
        .await;

    let redirect_url = format!("/dashboard/articles/{article_id}/edit");
    HttpResponse::SeeOther()
        .insert_header(("Location", redirect_url))
        .finish()
}

#[derive(Debug, serde::Deserialize)]
pub struct LoginFormData {
    pub email: String,
    pub password: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct RegisterFormData {
    pub display_name: String,
    pub email: String,
    pub password: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct InviteFormData {
    pub key: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct CreateArticleFormData {
    pub title: String,
    pub slug: String,
    pub audience_id: String,
    pub content: String,
    pub message: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct CommitFormData {
    pub content: String,
    pub message: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct PublishFormData {
    pub commit_id: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct CreateAudienceFormData {
    pub name: String,
    pub slug: String,
    pub description: String,
}

#[get("/dashboard/audiences/new")]
pub async fn new_audience_page(req: HttpRequest, db: web::Data<DbConfig>) -> HttpResponse {
    let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    };

    let body = html! {
        section .mt5.mw7.center {
            h1 .f2.serif.tl.mb3 { "new audience" }
            form .measure action="/dashboard/audiences" method="post" {
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="name" { "name" }
                    input #name .pa2.w-100.ba.b--near-white type="text" name="name" required="";
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="slug" { "slug (url-safe)" }
                    input #slug .pa2.w-100.ba.b--near-white type="text" name="slug" required="";
                }
                div .mb3 {
                    label .db.mb1.f6.mid-gray for="description" { "description" }
                    textarea #description .pa2.w-100.ba.b--near-white style="min-height: 6rem;" name="description" {}
                }
                div .tl {
                    button .pa2.ph4.ba.b--moon-gray.bg-near-white.serif.f5.hover-bg-light-gray.dark-gray.pointer type="submit" { "create" }
                }
            }
            a .link.dark-gray.dim.db.tl.mt4.f6 href="/dashboard" { "← back to dashboard" }
        }
    };

    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(templates::layout("new audience — lettre", body).into_string())
}

#[post("/dashboard/audiences")]
pub async fn create_audience(
    req: HttpRequest,
    db: web::Data<DbConfig>,
    form: web::Form<CreateAudienceFormData>,
) -> HttpResponse {
    let Some((_author_id, _)) = auth::get_current_author(&req, &db).await else {
        return HttpResponse::SeeOther()
            .insert_header(("Location", "/login"))
            .finish();
    };

    let client = match db.connect().await {
        Ok(c) => c,
        Err(e) => {
            log::error!("DB connection failed: {e}");
            return HttpResponse::InternalServerError().body("Database error");
        }
    };

    let description = if form.description.is_empty() {
        None
    } else {
        Some(form.description.as_str())
    };

    let result = client
        .execute(
            "INSERT INTO audiences (slug, name, description) VALUES ($1, $2, $3)",
            &[&form.slug, &form.name, &description],
        )
        .await;

    match result {
        Ok(_) => HttpResponse::SeeOther()
            .insert_header(("Location", "/dashboard"))
            .finish(),
        Err(e) => {
            log::error!("Audience creation failed: {e}");
            HttpResponse::SeeOther()
                .insert_header(("Location", "/dashboard/audiences/new"))
                .finish()
        }
    }
}