from __future__ import annotations

from pathlib import Path

from reportlab.lib.colors import Color, HexColor, white
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen import canvas


ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "output" / "pdf" / "AGENT_RELIABILITY_PORTFOLIO_KO_R2_20260718.pdf"
PUBLIC_DEMO_URL = "https://upstage-solar-reliability-demo-20260718.pages.dev"
WIDTH, HEIGHT = A4

NAVY = HexColor("#14233F")
INK = HexColor("#121B2E")
MUTED = HexColor("#657086")
BLUE = HexColor("#275BE8")
BLUE_SOFT = HexColor("#EAF0FF")
MINT = HexColor("#25B890")
MINT_SOFT = HexColor("#EAF8F4")
AMBER = HexColor("#E4A72D")
AMBER_SOFT = HexColor("#FFF7E4")
RED = HexColor("#D94D55")
PAPER = HexColor("#FFFFFF")
SOFT = HexColor("#F4F7FB")
LINE = HexColor("#DFE4EC")

FONT = "NotoSansKR"
FONT_BOLD = "NotoSansKR-Bold"


def register_fonts() -> None:
    pdfmetrics.registerFont(TTFont(FONT, str(ROOT / "assets" / "fonts" / "NotoSansKR_400Regular.ttf")))
    pdfmetrics.registerFont(TTFont(FONT_BOLD, str(ROOT / "assets" / "fonts" / "NotoSansKR_700Bold.ttf")))


def wrap_text(text: str, font: str, size: float, max_width: float) -> list[str]:
    lines: list[str] = []
    for paragraph in text.split("\n"):
        if not paragraph:
            lines.append("")
            continue
        current = ""
        for word in paragraph.split(" "):
            candidate = word if not current else f"{current} {word}"
            if pdfmetrics.stringWidth(candidate, font, size) <= max_width:
                current = candidate
                continue
            if current:
                lines.append(current)
                current = ""
            piece = ""
            for character in word:
                if piece and pdfmetrics.stringWidth(piece + character, font, size) > max_width:
                    lines.append(piece)
                    piece = character
                else:
                    piece += character
            current = piece
        if current:
            lines.append(current)
    return lines


class Portfolio:
    def __init__(self, path: Path) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        self.canvas = canvas.Canvas(str(path), pagesize=A4, pageCompression=1)
        self.canvas.setTitle("Agent Reliability Live Demo - Upstage 지원 포트폴리오")
        self.canvas.setSubject("Solar LLM과 fail-closed workflow reliability control plane")
        self.canvas.setAuthor("Agent Reliability Portfolio")
        self.page_number = 0

    def page(self, dark: bool = False, label: str = "AGENT RELIABILITY PORTFOLIO") -> None:
        if self.page_number:
            self.canvas.showPage()
        self.page_number += 1
        self.canvas.setFillColor(NAVY if dark else PAPER)
        self.canvas.rect(0, 0, WIDTH, HEIGHT, fill=1, stroke=0)
        self.canvas.setFillColor(HexColor("#9FB5DF") if dark else BLUE)
        self.canvas.setFont("Helvetica-Bold", 7.5)
        self.canvas.drawString(42, HEIGHT - 36, label)
        self.canvas.setFillColor(HexColor("#9FB0CE") if dark else MUTED)
        self.canvas.setFont("Helvetica", 7)
        self.canvas.drawRightString(WIDTH - 42, 27, f"{self.page_number:02d}  /  08")

    def eyebrow(self, text: str, x: float, y: float, color=BLUE) -> None:
        self.canvas.setFillColor(color)
        self.canvas.setFont("Helvetica-Bold", 8)
        self.canvas.drawString(x, y, text)

    def text(
        self,
        text: str,
        x: float,
        y: float,
        width: float,
        size: float = 11,
        leading: float | None = None,
        color=INK,
        font: str = FONT,
    ) -> float:
        leading = leading or size * 1.55
        self.canvas.setFillColor(color)
        self.canvas.setFont(font, size)
        for line in wrap_text(text, font, size, width):
            self.canvas.drawString(x, y, line)
            y -= leading
        return y

    def heading(self, text: str, x: float, y: float, width: float, size: float = 28, color=INK) -> float:
        return self.text(text, x, y, width, size=size, leading=size * 1.22, color=color, font=FONT_BOLD)

    def round_rect(self, x: float, y: float, width: float, height: float, fill, stroke=LINE, radius: float = 13) -> None:
        self.canvas.setFillColor(fill)
        self.canvas.setStrokeColor(stroke)
        self.canvas.setLineWidth(.7)
        self.canvas.roundRect(x, y, width, height, radius, fill=1, stroke=1)

    def card_title(self, index: str, title: str, x: float, y: float, width: float, accent=BLUE) -> None:
        self.canvas.setFillColor(accent)
        self.canvas.setFont("Helvetica-Bold", 8)
        self.canvas.drawString(x, y, index)
        self.canvas.setFillColor(INK)
        self.canvas.setFont(FONT_BOLD, 12)
        self.canvas.drawString(x, y - 24, title)

    def footer_note(self, text: str, dark: bool = False) -> None:
        self.canvas.setStrokeColor(Color(1, 1, 1, .14) if dark else LINE)
        self.canvas.line(42, 49, WIDTH - 42, 49)
        self.canvas.setFillColor(HexColor("#9FB0CE") if dark else MUTED)
        self.canvas.setFont(FONT, 6.8)
        self.canvas.drawString(42, 34, text)

    def cover(self) -> None:
        self.page(dark=True, label="UPSTAGE · AGENT GYM APPLICATION PORTFOLIO")
        c = self.canvas
        c.setStrokeColor(Color(0.32, .49, 1, .19))
        c.setLineWidth(30)
        c.circle(WIDTH + 5, HEIGHT - 120, 118, stroke=1, fill=0)
        c.setStrokeColor(Color(.14, .72, .56, .12))
        c.setLineWidth(18)
        c.circle(-18, 85, 92, stroke=1, fill=0)

        self.eyebrow("SOLAR LLM + FAIL-CLOSED WORKFLOW", 48, 664, HexColor("#8FB1FF"))
        y = self.heading("AI가 실패해도\n업무 사고 없이 끝나는가", 48, 610, 470, size=34, color=white)
        y -= 18
        self.text(
            "문서 구조화, 사람 승인, checkpoint 복구, 재시도와 중복 방지를 하나의 실행 가능한 데모와 검증 evidence로 묶었습니다.",
            48,
            y,
            420,
            size=12.5,
            leading=21,
            color=HexColor("#B9C7DF"),
        )

        self.round_rect(48, 214, WIDTH - 96, 160, HexColor("#101F3A"), Color(1, 1, 1, .13), 18)
        metrics = [("432", "고유 시험"), ("24/24", "최종 재실행"), ("20,736", "matrix testcase"), ("0", "중복 효과")]
        cell_width = (WIDTH - 112) / 4
        for index, (value, label) in enumerate(metrics):
            x = 56 + index * cell_width
            if index:
                c.setStrokeColor(Color(1, 1, 1, .12))
                c.line(x, 237, x, 351)
            c.setFillColor(white)
            c.setFont("Helvetica-Bold", 22)
            c.drawCentredString(x + cell_width / 2, 298, value)
            c.setFillColor(HexColor("#9FB0CE"))
            c.setFont(FONT, 8.5)
            c.drawCentredString(x + cell_width / 2, 268, label)

        c.setFillColor(HexColor("#8FB1FF"))
        c.setFont(FONT, 9)
        c.drawString(48, 171, "Windows · Linux  /  Python 3.11 · 3.12 · 3.13")
        c.setFillColor(HexColor("#B9C7DF"))
        c.setFont(FONT, 8.3)
        c.drawString(48, 145, "합성 데이터 · 로컬 mock tool · 서버 측 API key · 공개용 evidence 요약")
        self.footer_note("제출 범위: Cloudflare Pages 공개 데모 + 로컬 실행본 + 검증 evidence + 포트폴리오 PDF", dark=True)

    def problem(self) -> None:
        self.page(label="01 · PROBLEM")
        self.eyebrow("WHY RELIABILITY, NOT ONLY MODEL QUALITY", 42, 748)
        y = self.heading("좋은 답을 만드는 것과\n안전하게 일을 끝내는 것은 다릅니다", 42, 709, 500, 27)
        self.text(
            "Agent가 도구를 쓰기 시작하면 정확도 외에 승인 경계, 중단 복구, 재시도, 중복 효과, 실행 증거가 제품 품질이 됩니다.",
            42,
            y - 13,
            480,
            11.5,
            18,
            MUTED,
        )

        items = [
            ("01", "환각과 누락", "모델 출력이 그럴듯해도 필수값이 없거나 허용되지 않은 필드가 섞일 수 있습니다.", BLUE, BLUE_SOFT),
            ("02", "승인 없는 실행", "설명 생성과 실제 변경 권한을 붙이면 모델의 오류가 곧 업무 사고가 됩니다.", RED, HexColor("#FFF0F1")),
            ("03", "응답 유실", "저장은 끝났지만 응답만 사라지면 단순 재시도가 같은 기록을 두 번 만들 수 있습니다.", AMBER, AMBER_SOFT),
            ("04", "중단과 재개", "프로세스가 바뀐 뒤 기억만 믿고 재개하면 승인 대상과 실행 인자가 달라질 수 있습니다.", MINT, MINT_SOFT),
        ]
        card_w = 244
        card_h = 150
        for index, (num, title, body, accent, fill) in enumerate(items):
            col, row = index % 2, index // 2
            x = 42 + col * (card_w + 23)
            top = 496 - row * (card_h + 18)
            self.round_rect(x, top - card_h, card_w, card_h, fill, fill, 16)
            self.card_title(num, title, x + 18, top - 22, card_w - 36, accent)
            self.text(body, x + 18, top - 72, card_w - 36, 9.2, 15, MUTED)

        self.round_rect(42, 91, WIDTH - 84, 75, NAVY, NAVY, 15)
        self.eyebrow("PROJECT QUESTION", 60, 139, HexColor("#8FB1FF"))
        self.text("“모델이나 시스템이 실패해도 승인되지 않은 작업과 중복 작업을 0으로 유지할 수 있는가?”", 60, 115, WIDTH - 120, 11, 17, white)
        self.footer_note("이 프로젝트는 정상 응답보다 실패 시의 종료·복구·증거를 먼저 설계했습니다.")

    def architecture(self) -> None:
        self.page(label="02 · SOLUTION ARCHITECTURE")
        self.eyebrow("SEPARATE GENERATION FROM AUTHORITY", 42, 748)
        y = self.heading("LLM은 읽고, 통제층이 실행합니다", 42, 709, 500, 29)
        self.text("확률적인 생성 결과와 실제 변경 권한을 분리한 fail-closed 구조입니다.", 42, y - 10, 500, 11.5, 18, MUTED)

        steps = [
            ("01", "Solar LLM", "문서를 strict JSON 후보로 구조화", BLUE),
            ("02", "Schema + Policy", "필수값·허용 필드·업무 범위를 검사", BLUE),
            ("03", "Human Approval", "case·tool·arguments digest를 승인과 결속", AMBER),
            ("04", "Checkpoint Resume", "새 runtime이 SQLite 상태에서 재개", MINT),
            ("05", "Idempotent Tool", "동일 요청이면 persisted result를 재사용", MINT),
        ]
        x = 42
        top = 578
        width = WIDTH - 84
        row_h = 79
        for index, (num, title, body, accent) in enumerate(steps):
            y0 = top - index * (row_h + 8)
            fill = PAPER if index % 2 == 0 else SOFT
            self.round_rect(x, y0 - row_h, width, row_h, fill, LINE, 13)
            self.round_rect(x + 13, y0 - 57, 46, 46, accent, accent, 12)
            self.canvas.setFillColor(white)
            self.canvas.setFont("Helvetica-Bold", 10)
            self.canvas.drawCentredString(x + 36, y0 - 39, num)
            self.canvas.setFillColor(INK)
            self.canvas.setFont(FONT, 12)
            self.canvas.drawString(x + 75, y0 - 30, title)
            self.text(body, x + 75, y0 - 53, width - 95, 9.2, 14, MUTED)
            if index < len(steps) - 1:
                self.canvas.setStrokeColor(HexColor("#AAB5C7"))
                self.canvas.setLineWidth(1)
                self.canvas.line(x + 36, y0 - row_h, x + 36, y0 - row_h - 8)

        self.round_rect(42, 80, WIDTH - 84, 54, BLUE_SOFT, BLUE_SOFT, 12)
        self.text("핵심 원칙  ·  승인 증거가 없거나 schema가 맞지 않거나 상태가 불명확하면 실행하지 않습니다.", 58, 112, WIDTH - 116, 9.2, 14, BLUE)
        self.footer_note("생성 모델을 교체해도 policy·checkpoint·tool contract·verifier는 독립적으로 유지됩니다.")

    def demo(self) -> None:
        self.page(label="03 · LIVE DEMO")
        self.eyebrow("90-SECOND INTERACTIVE SCENARIO", 42, 748)
        y = self.heading("응답이 사라져도 기록은 한 번", 42, 709, 500, 29)
        self.text("실제 화면에서는 문서 분석, 승인, fault injection, replay를 한 흐름에서 확인합니다.", 42, y - 10, 500, 11.5, 18, MUTED)

        x0, y0, w, h = 42, 212, WIDTH - 84, 410
        self.round_rect(x0, y0, w, h, PAPER, LINE, 18)
        self.canvas.setFillColor(NAVY)
        self.canvas.roundRect(x0, y0 + h - 42, w, 42, 18, fill=1, stroke=0)
        self.canvas.rect(x0, y0 + h - 24, w, 24, fill=1, stroke=0)
        self.canvas.setFillColor(white)
        self.canvas.setFont(FONT, 8.5)
        self.canvas.drawString(x0 + 16, y0 + h - 26, "Agent Reliability Live Demo")
        self.canvas.setFillColor(HexColor("#F4D787"))
        self.canvas.roundRect(x0 + w - 88, y0 + h - 31, 70, 18, 9, fill=1, stroke=0)
        self.canvas.setFillColor(HexColor("#76520A"))
        self.canvas.setFont(FONT, 6.8)
        self.canvas.drawCentredString(x0 + w - 53, y0 + h - 25, "샘플 / LIVE 표시")

        left_x, right_x = x0 + 16, x0 + 228
        body_top = y0 + h - 58
        self.round_rect(left_x, y0 + 18, 196, h - 80, SOFT, LINE, 12)
        self.canvas.setFillColor(INK)
        self.canvas.setFont(FONT, 10)
        self.canvas.drawString(left_x + 13, body_top - 14, "01  합성 지출 문서")
        self.round_rect(left_x + 13, body_top - 153, 170, 120, PAPER, LINE, 8)
        sample = ["공급자: 한빛 데이터 서비스", "청구번호: INV-2026-0718-04", "금액: 1,280,000", "요청자: 김도윤", "사용목적: 성능 검증"]
        for i, line in enumerate(sample):
            self.text(line, left_x + 24, body_top - 53 - i * 19, 150, 7.2, 10, MUTED)
        self.canvas.setFillColor(AMBER_SOFT)
        self.canvas.roundRect(left_x + 13, body_top - 208, 170, 39, 8, fill=1, stroke=0)
        self.text("선택: 저장 후 응답 유실", left_x + 24, body_top - 186, 145, 7.5, 10, HexColor("#8C6412"))
        self.canvas.setFillColor(BLUE)
        self.canvas.roundRect(left_x + 13, y0 + 34, 170, 39, 9, fill=1, stroke=0)
        self.canvas.setFillColor(white)
        self.canvas.setFont(FONT, 8)
        self.canvas.drawCentredString(left_x + 98, y0 + 50, "1. 문서 분석 시작")

        self.round_rect(right_x, y0 + 18, w - 244, h - 80, PAPER, LINE, 12)
        self.canvas.setFillColor(INK)
        self.canvas.setFont(FONT, 10)
        self.canvas.drawString(right_x + 13, body_top - 14, "02  실행 결과")
        states = [("문서 분석", BLUE), ("정책 검사", BLUE), ("사람 승인", AMBER), ("안전 기록", MINT)]
        line_y = body_top - 58
        available = w - 282
        gap = available / 3
        for i, (label, color) in enumerate(states):
            cx = right_x + 29 + i * gap
            if i:
                self.canvas.setStrokeColor(LINE)
                self.canvas.line(cx - gap + 14, line_y, cx - 14, line_y)
            self.canvas.setFillColor(color)
            self.canvas.circle(cx, line_y, 10, fill=1, stroke=0)
            self.canvas.setFillColor(MUTED)
            self.canvas.setFont(FONT, 6.2)
            self.canvas.drawCentredString(cx, line_y - 25, label)
        self.round_rect(right_x + 13, body_top - 182, w - 270, 78, AMBER_SOFT, HexColor("#EFDCAD"), 9)
        self.text("사람 승인이 필요합니다", right_x + 25, body_top - 127, w - 294, 8.5, 12, INK)
        self.text("승인 전 외부 기록 0회", right_x + 25, body_top - 146, w - 294, 7.2, 10, MUTED)
        self.canvas.setFillColor(BLUE)
        self.canvas.roundRect(right_x + 25, body_top - 177, w - 294, 23, 6, fill=1, stroke=0)
        self.canvas.setFillColor(white)
        self.canvas.setFont(FONT, 6.8)
        self.canvas.drawCentredString(right_x + (w - 244) / 2, body_top - 169, "2. 승인하고 checkpoint에서 재개")

        metrics = [("1", "실제 기록"), ("0", "중복"), ("1+", "결과 재사용")]
        metric_w = (w - 282) / 3
        for i, (value, label) in enumerate(metrics):
            mx = right_x + 13 + i * metric_w
            self.round_rect(mx, y0 + 53, metric_w - 6, 63, MINT_SOFT, MINT_SOFT, 8)
            self.canvas.setFillColor(MINT)
            self.canvas.setFont("Helvetica-Bold", 15)
            self.canvas.drawCentredString(mx + (metric_w - 6) / 2, y0 + 87, value)
            self.canvas.setFillColor(MUTED)
            self.canvas.setFont(FONT, 6.5)
            self.canvas.drawCentredString(mx + (metric_w - 6) / 2, y0 + 68, label)

        self.round_rect(42, 93, WIDTH - 84, 83, NAVY, NAVY, 14)
        self.text("시연 결론", 58, 146, 90, 8, 12, HexColor("#8FB1FF"))
        self.text("commit 후 응답이 유실되고 같은 요청이 다시 와도 persisted result를 재사용해 실제 효과는 한 번만 남습니다.", 58, 121, WIDTH - 116, 10, 15, white)
        self.footer_note("공개판은 Cloudflare 서버의 Solar Pro 3를 사용하며 API 키와 입력 원문을 브라우저에 저장하지 않습니다.")

    def components(self) -> None:
        self.page(label="04 · WHAT WAS BUILT")
        self.eyebrow("FROM PROTOTYPE TO CONTROL PLANE", 42, 748)
        y = self.heading("완성한 것은 ‘챗봇’이 아니라\n실행과 복구의 제어층입니다", 42, 709, 500, 27)
        self.text("서로 독립적으로 시험할 수 있는 여섯 개 책임으로 나눴습니다.", 42, y - 10, 500, 11.5, 18, MUTED)

        items = [
            ("STATE MACHINE", "명시적 상태 전이", "불법 전이와 terminal 이후 write를 차단"),
            ("CHECKPOINT", "지속 가능한 재개", "프로세스가 바뀌어도 SQLite state에서 재개"),
            ("APPROVAL", "승인 증거 결속", "case·tool·arguments·TTL이 맞을 때만 실행"),
            ("RETRY", "분류된 실패와 budget", "일시 오류만 제한 횟수 안에서 재시도"),
            ("IDEMPOTENCY", "중복 효과 방지", "같은 key의 committed result를 조회·재사용"),
            ("EVALUATOR", "독립 검증", "agent 주장 대신 persisted state와 journal을 판정"),
        ]
        card_w = 159
        card_h = 153
        for index, (tag, title, body) in enumerate(items):
            col, row = index % 3, index // 3
            x = 42 + col * (card_w + 16)
            top = 515 - row * (card_h + 17)
            self.round_rect(x, top - card_h, card_w, card_h, PAPER if row == 0 else SOFT, LINE, 14)
            self.canvas.setFillColor(BLUE if index < 3 else MINT)
            self.canvas.setFont("Helvetica-Bold", 6.8)
            self.canvas.drawString(x + 15, top - 22, tag)
            self.text(title, x + 15, top - 54, card_w - 30, 10.3, 14, INK)
            self.text(body, x + 15, top - 91, card_w - 30, 8.2, 13, MUTED)

        self.round_rect(42, 94, WIDTH - 84, 87, BLUE_SOFT, BLUE_SOFT, 14)
        self.text("표현 계층 추가", 58, 151, 100, 8, 12, BLUE)
        self.text("Solar strict-output adapter + Cloudflare Pages Functions·D1 공개판 + 반응형 한국어 UI + 로컬 자동 실행기 + demo test", 58, 125, WIDTH - 116, 9.2, 15, INK)
        self.footer_note("Core와 presentation layer의 검증 범위를 분리해 숫자를 과장하지 않았습니다.")

    def bug_story(self) -> None:
        self.page(label="05 · ENGINEERING STORY")
        self.eyebrow("A REAL FAILURE FOUND DURING VALIDATION", 42, 748)
        y = self.heading("Windows PID 재사용이\ncleanup 증거를 속일 수 있었습니다", 42, 709, 500, 28)
        self.text("오래 도는 matrix에서 드러난 문제를 identity 결속으로 수정하고 회귀 시험에 고정했습니다.", 42, y - 10, 500, 11.2, 18, MUTED)

        self.round_rect(42, 435, WIDTH - 84, 141, HexColor("#FFF0F1"), HexColor("#F1CDD0"), 16)
        self.eyebrow("BEFORE", 60, 546, RED)
        self.text("종료한 child의 PID만 기억", 60, 515, 205, 13, 18, INK)
        self.text("운영체제가 같은 PID를 다른 프로세스에 재사용하면, 새 프로세스를 예전 child로 오인하거나 잘못 정리할 위험이 있었습니다.", 60, 482, 214, 9, 14, MUTED)
        self.canvas.setStrokeColor(RED)
        self.canvas.setLineWidth(2)
        self.canvas.line(301, 506, 357, 506)
        self.canvas.line(347, 515, 357, 506)
        self.canvas.line(347, 497, 357, 506)
        self.text("PID 8124", 379, 516, 120, 13, 18, RED, "Helvetica-Bold")
        self.text("다른 process", 379, 486, 120, 9, 14, MUTED)

        self.round_rect(42, 257, WIDTH - 84, 141, MINT_SOFT, HexColor("#CDE9DF"), 16)
        self.eyebrow("AFTER", 60, 368, MINT)
        self.text("PID + creation time 결속", 60, 337, 205, 13, 18, INK)
        self.text("프로세스 ID와 생성 시각이 모두 일치할 때만 같은 실행 주체로 인정했습니다. cleanup과 회수 대상의 identity가 고정됩니다.", 60, 304, 214, 9, 14, MUTED)
        self.canvas.setStrokeColor(MINT)
        self.canvas.setLineWidth(2)
        self.canvas.line(301, 328, 357, 328)
        self.canvas.line(347, 337, 357, 328)
        self.canvas.line(347, 319, 357, 328)
        self.text("8124 + 10:42:18.431", 379, 338, 145, 10, 15, MINT, "Helvetica-Bold")
        self.text("identity match", 379, 306, 120, 9, 14, MUTED)

        self.round_rect(42, 101, WIDTH - 84, 112, NAVY, NAVY, 15)
        self.eyebrow("WHY IT MATTERS", 60, 181, HexColor("#8FB1FF"))
        self.text("Agent 평가에서는 testcase PASS만큼 ‘실패 후 무엇이 남았는가’가 중요합니다. 이 버그는 orphan·zombie 0이라는 주장 자체가 정확한 identity 검사에 의존한다는 점을 보여줬습니다.", 60, 153, WIDTH - 120, 9.4, 15, white)
        self.footer_note("수정은 전용 회귀 시험과 Windows/Linux platform matrix에 포함했습니다.")

    def evidence(self) -> None:
        self.page(label="06 · VALIDATION EVIDENCE")
        self.eyebrow("EVIDENCE, NOT CLAIMS", 42, 748)
        y = self.heading("고정된 소스와 후보를\n독립 검증으로 다시 확인했습니다", 42, 709, 500, 27)
        self.text("시험 수가 아니라 소스 결속, 실패 주입, cleanup, 재현성까지 판정했습니다.", 42, y - 10, 500, 11.3, 18, MUTED)

        metrics = [
            ("432/432", "고유 시험", "manifest와 discovery 일치"),
            ("14/14", "Authority", "기준·음성대조·독립검증"),
            ("10/10", "Platform", "Windows/Linux + 3 Python"),
            ("24/24", "Exact-final", "최종 후보 고정 재실행"),
        ]
        for i, (value, title, note) in enumerate(metrics):
            x = 42 + (i % 2) * 267
            top = 531 - (i // 2) * 123
            self.round_rect(x, top - 105, 250, 105, PAPER, LINE, 14)
            self.canvas.setFillColor(BLUE if i < 2 else MINT)
            self.canvas.setFont("Helvetica-Bold", 19)
            self.canvas.drawString(x + 16, top - 33, value)
            self.text(title, x + 16, top - 61, 110, 9.5, 13, INK)
            self.text(note, x + 112, top - 33, 120, 7.5, 12, MUTED)

        self.round_rect(42, 202, WIDTH - 84, 88, NAVY, NAVY, 14)
        self.canvas.setFillColor(white)
        self.canvas.setFont("Helvetica-Bold", 27)
        self.canvas.drawString(60, 238, "20,736")
        self.text("matrix testcase executions  ·  432 × 24행", 187, 246, 330, 10, 15, white)
        self.text("failure · error · missing · duplicate · unexpected · orphan · zombie = 각 0", 187, 222, 330, 8.2, 12, HexColor("#B9C7DF"))

        self.round_rect(42, 102, WIDTH - 84, 70, SOFT, SOFT, 12)
        self.text("무결성", 58, 145, 70, 8, 12, BLUE)
        self.text("99 source files · source tree SHA-256 고정 · candidate/result/exact-final digest 결속 · rollback 420 → reapply 432 PASS", 126, 145, 395, 8.2, 13, MUTED)
        self.footer_note("공개 패키지에는 raw 내부 경로 대신 sanitization한 JSON 요약과 digest만 포함합니다.")

    def fit(self) -> None:
        self.page(label="07 · ROLE FIT & BOUNDARIES")
        self.eyebrow("WHY THIS FITS AGENT GYM", 42, 748)
        y = self.heading("모델·환경·평가·운영 안전을\n하나의 검증 루프로 다뤘습니다", 42, 709, 500, 27)
        self.text("결과물은 PDF 한 장의 주장보다 재현 가능한 환경과 실패 evidence를 우선합니다.", 42, y - 10, 500, 11.3, 18, MUTED)

        fit_items = [
            ("ENVIRONMENT", "상태·도구·승인 경계를 명시한 synthetic environment"),
            ("EVALUATION", "독립 evaluator와 executable ablation, source-bound receipt"),
            ("ROBUSTNESS", "timeout·stall·signal·응답 유실·process hazard 주입"),
            ("REPRODUCIBILITY", "Windows/Linux, Python 3.11-3.13 cold/warm matrix"),
        ]
        for i, (tag, body) in enumerate(fit_items):
            x = 42 + (i % 2) * 267
            top = 535 - (i // 2) * 102
            self.round_rect(x, top - 84, 250, 84, BLUE_SOFT if i < 2 else MINT_SOFT, BLUE_SOFT if i < 2 else MINT_SOFT, 13)
            self.eyebrow(tag, x + 15, top - 22, BLUE if i < 2 else MINT)
            self.text(body, x + 15, top - 48, 220, 8.7, 13, INK)

        self.eyebrow("HONEST BOUNDARIES", 42, 303, MUTED)
        boundaries = [
            "합성 문서와 로컬 mock business tool을 사용합니다.",
            "idempotency 계약을 검증하며 distributed exactly-once는 주장하지 않습니다.",
            "432개 core 검증과 10개 demo·자동 실행기 시험의 범위를 구분합니다.",
            "라이브 Solar E2E는 키 1회 자동 실행 후 별도 PASS receipt로 판정합니다.",
            "기술 검증은 운영환경의 완전한 안전성이나 채용 결과를 보장하지 않습니다.",
        ]
        y = 275
        for boundary in boundaries:
            self.canvas.setFillColor(HexColor("#AAB4C5"))
            self.canvas.circle(48, y + 3, 2.5, fill=1, stroke=0)
            y = self.text(boundary, 60, y, WIDTH - 102, 8.8, 16, MUTED) - 2

        self.round_rect(42, 86, WIDTH - 84, 67, NAVY, NAVY, 13)
        self.text("공개", 58, 127, 55, 8, 12, HexColor("#8FB1FF"))
        self.text("웹주소 접속 → 키 입력 없이 가상 문서 분석·승인·오류복구·중복방지 체험", 113, 127, 390, 8.2, 13, white, FONT)
        self.text("로컬 실행본도 함께 제공하며, 두 경로 모두 실제 Solar 호출 증거와 비밀정보 비저장을 검증", 58, 104, 440, 8, 12, HexColor("#B9C7DF"))

        career_url = "https://careers.upstage.ai/ko/o/194880"
        console_url = "https://console.upstage.ai/api-keys?api=chat"
        self.canvas.linkURL(PUBLIC_DEMO_URL, (42, 49, 190, 65), relative=0)
        self.canvas.linkURL(career_url, (215, 49, 365, 65), relative=0)
        self.canvas.linkURL(console_url, (390, 49, WIDTH - 42, 65), relative=0)
        self.canvas.setFillColor(BLUE)
        self.canvas.setFont(FONT, 6.8)
        self.canvas.drawString(42, 55, "Solar 공개 데모 열기")
        self.canvas.drawString(215, 55, "Upstage Agent GYM 채용 공고")
        self.canvas.drawString(390, 55, "Upstage Console API Key")

    def save(self) -> None:
        self.canvas.save()


def main() -> None:
    register_fonts()
    portfolio = Portfolio(OUTPUT)
    portfolio.cover()
    portfolio.problem()
    portfolio.architecture()
    portfolio.demo()
    portfolio.components()
    portfolio.bug_story()
    portfolio.evidence()
    portfolio.fit()
    portfolio.save()
    print(OUTPUT)


if __name__ == "__main__":
    main()
