---
phase: 10-branding-data-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - SharepointToolbox/Core/Models/LogoData.cs
  - SharepointToolbox/Core/Models/BrandingSettings.cs
  - SharepointToolbox/Core/Models/TenantProfile.cs
  - SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs
  - SharepointToolbox/Services/IBrandingService.cs
  - SharepointToolbox/Services/BrandingService.cs
  - SharepointToolbox.Tests/Services/BrandingServiceTests.cs
  - SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
autonomous: true
requirements:
  - BRAND-01
  - BRAND-03
  - BRAND-06

must_haves:
  truths:
    - "An MSP logo imported as PNG or JPG is persisted as base64 in branding.json and survives round-trip"
    - "A client logo imported per tenant profile is persisted as base64 inside the profile JSON"
    - "A file that is not PNG/JPG (e.g., BMP) is rejected with a descriptive error message"
    - "A file larger than 512 KB is silently compressed to fit under the limit"
    - "A file under 512 KB is stored without modification"
  artifacts:
    - path: "SharepointToolbox/Core/Models/LogoData.cs"
      provides: "Shared logo record with Base64 and MimeType properties"
      contains: "record LogoData"
    - path: "SharepointToolbox/Core/Models/BrandingSettings.cs"
      provides: "MSP logo wrapper model"
      contains: "LogoData? MspLogo"
    - path: "SharepointToolbox/Core/Models/TenantProfile.cs"
      provides: "Client logo property on existing profile model"
      contains: "LogoData? ClientLogo"
    - path: "SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs"
      provides: "JSON persistence for BrandingSettings with write-then-replace"
      contains: "SemaphoreSlim"
    - path: "SharepointToolbox/Services/BrandingService.cs"
      provides: "Logo import with magic byte validation and auto-compression"
      exports: ["ImportLogoAsync"]
    - path: "SharepointToolbox.Tests/Services/BrandingServiceTests.cs"
      provides: "Unit tests for validation, compression, rejection"
      min_lines: 60
    - path: "SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs"
      provides: "Unit tests for repository round-trip"
      min_lines: 30
  key_links:
    - from: "SharepointToolbox/Services/BrandingService.cs"
      to: "SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs"
      via: "constructor injection"
      pattern: "BrandingRepository"
    - from: "SharepointToolbox/Services/BrandingService.cs"
      to: "SharepointToolbox/Core/Models/LogoData.cs"
      via: "return type"
      pattern: "LogoData"
    - from: "SharepointToolbox/Core/Models/BrandingSettings.cs"
      to: "SharepointToolbox/Core/Models/LogoData.cs"
      via: "property type"
      pattern: "LogoData\\? MspLogo"
    - from: "SharepointToolbox/Core/Models/TenantProfile.cs"
      to: "SharepointToolbox/Core/Models/LogoData.cs"
      via: "property type"
      pattern: "LogoData\\? ClientLogo"
---

<objective>
Create the logo storage infrastructure: models, repository, and branding service with validation/compression.

Purpose: BRAND-01, BRAND-03, BRAND-06 require models for logo data, a repository for MSP branding persistence, extension of TenantProfile for client logos, and a service that validates format (magic bytes) and auto-compresses oversized files.

Output: LogoData record, BrandingSettings model, TenantProfile extension, BrandingRepository, BrandingService (with IBrandingService interface), and comprehensive unit tests.
</objective>

<execution_context>
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/10-branding-data-foundation/10-RESEARCH.md

<interfaces>
<!-- Existing patterns the executor needs to follow exactly. -->

From SharepointToolbox/Core/Models/AppSettings.cs:
```csharp
namespace SharepointToolbox.Core.Models;

public class AppSettings
{
    public string DataFolder { get; set; } = string.Empty;
    public string Lang { get; set; } = "en";
}
```

From SharepointToolbox/Core/Models/TenantProfile.cs:
```csharp
namespace SharepointToolbox.Core.Models;

public class TenantProfile
{
    public string Name { get; set; } = string.Empty;
    public string TenantUrl { get; set; } = string.Empty;
    public string ClientId { get; set; } = string.Empty;
}
```

From SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs:
```csharp
namespace SharepointToolbox.Infrastructure.Persistence;

public class SettingsRepository
{
    private readonly string _filePath;
    private readonly SemaphoreSlim _writeLock = new(1, 1);

    public SettingsRepository(string filePath) { _filePath = filePath; }
    public async Task<AppSettings> LoadAsync() { /* File.ReadAllTextAsync + JsonSerializer.Deserialize */ }
    public async Task SaveAsync(AppSettings settings) { /* SemaphoreSlim + write-tmp + validate round-trip + File.Move */ }
}
```

From SharepointToolbox.Tests/Services/SettingsServiceTests.cs (test pattern):
```csharp
[Trait("Category", "Unit")]
public class SettingsServiceTests : IDisposable
{
    private readonly string _tempFile;
    public SettingsServiceTests() { _tempFile = Path.GetTempFileName(); File.Delete(_tempFile); }
    public void Dispose() { if (File.Exists(_tempFile)) File.Delete(_tempFile); if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp"); }
    private SettingsRepository CreateRepository() => new(_tempFile);
}
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create logo models, BrandingRepository, and repository tests</name>
  <files>
    SharepointToolbox/Core/Models/LogoData.cs,
    SharepointToolbox/Core/Models/BrandingSettings.cs,
    SharepointToolbox/Core/Models/TenantProfile.cs,
    SharepointToolbox/Infrastructure/Persistence/BrandingRepository.cs,
    SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs
  </files>
  <behavior>
    - Test 1: BrandingRepository.LoadAsync returns default BrandingSettings (MspLogo=null) when file does not exist
    - Test 2: BrandingRepository round-trips BrandingSettings with a non-null MspLogo (Base64 + MimeType preserved)
    - Test 3: BrandingRepository.SaveAsync creates directory if it does not exist
    - Test 4: TenantProfile with ClientLogo serializes to JSON with camelCase "clientLogo" key and deserializes back correctly (use System.Text.Json directly)
    - Test 5: TenantProfile without ClientLogo (null) serializes with clientLogo absent or null and deserializes with ClientLogo=null (forward-compatible)
  </behavior>
  <action>
    1. Create `LogoData.cs` as a non-positional record with `{ get; init; }` properties (NOT positional constructor) to avoid System.Text.Json deserialization pitfall (see RESEARCH Pitfall 3):
       ```csharp
       namespace SharepointToolbox.Core.Models;
       public record LogoData
       {
           public string Base64 { get; init; } = string.Empty;
           public string MimeType { get; init; } = string.Empty;
       }
       ```

    2. Create `BrandingSettings.cs`:
       ```csharp
       namespace SharepointToolbox.Core.Models;
       public class BrandingSettings
       {
           public LogoData? MspLogo { get; set; }
       }
       ```

    3. Extend `TenantProfile.cs` — add ONE property: `public LogoData? ClientLogo { get; set; }`. Do NOT remove or rename any existing properties. This is additive only. ProfileRepository needs no code change — System.Text.Json handles the new nullable property automatically.

    4. Create `BrandingRepository.cs` as an exact structural clone of `SettingsRepository.cs`, substituting `BrandingSettings` for `AppSettings`. Same pattern: `SemaphoreSlim(1,1)`, `File.ReadAllTextAsync`, `JsonSerializer.Deserialize<BrandingSettings>`, write-then-replace with `.tmp` file, `JsonDocument.Parse` validation, `File.Move(overwrite: true)`. Use `PropertyNameCaseInsensitive = true` for Load and `PropertyNamingPolicy = JsonNamingPolicy.CamelCase` + `WriteIndented = true` for Save. Same error handling (InvalidDataException for IO/JSON errors).

    5. Write `BrandingRepositoryTests.cs` following the `SettingsServiceTests` pattern: `IDisposable`, `Path.GetTempFileName()`, cleanup of `.tmp` files, `[Trait("Category", "Unit")]`. Tests for TenantProfile serialization use `JsonSerializer` directly (no repository needed — just confirm the model serializes/deserializes with the new property).
  </action>
  <verify>
    <automated>dotnet test --filter "FullyQualifiedName~BrandingRepositoryTests" --no-build</automated>
  </verify>
  <done>LogoData record, BrandingSettings model, TenantProfile.ClientLogo property, and BrandingRepository all exist. Repository round-trips BrandingSettings with MspLogo. TenantProfile with ClientLogo serializes correctly. All tests pass.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create BrandingService with validation, compression, and tests</name>
  <files>
    SharepointToolbox/Services/IBrandingService.cs,
    SharepointToolbox/Services/BrandingService.cs,
    SharepointToolbox.Tests/Services/BrandingServiceTests.cs
  </files>
  <behavior>
    - Test 1: ImportLogoAsync with valid PNG bytes (magic: 0x89,0x50,0x4E,0x47 + minimal valid content) returns LogoData with MimeType="image/png" and correct Base64
    - Test 2: ImportLogoAsync with valid JPEG bytes (magic: 0xFF,0xD8,0xFF + minimal content) returns LogoData with MimeType="image/jpeg"
    - Test 3: ImportLogoAsync with BMP bytes (magic: 0x42,0x4D) throws InvalidDataException with message containing "PNG" and "JPG"
    - Test 4: ImportLogoAsync with empty file throws InvalidDataException
    - Test 5: ImportLogoAsync with file under 512 KB returns Base64 matching original bytes exactly (no compression)
    - Test 6: ImportLogoAsync with file over 512 KB returns LogoData where decoded bytes are <= 512 KB (compressed)
    - Test 7: SaveMspLogoAsync calls BrandingRepository.SaveAsync with the logo set on BrandingSettings.MspLogo
    - Test 8: ClearMspLogoAsync saves BrandingSettings with MspLogo=null
    - Test 9: GetMspLogoAsync returns null when no logo is configured
  </behavior>
  <action>
    1. Create `IBrandingService.cs`:
       ```csharp
       namespace SharepointToolbox.Services;
       public interface IBrandingService
       {
           Task<LogoData> ImportLogoAsync(string filePath);
           Task SaveMspLogoAsync(LogoData logo);
           Task ClearMspLogoAsync();
           Task<LogoData?> GetMspLogoAsync();
       }
       ```
       Note: `ImportLogoAsync` is a pure validation+encoding function. It reads the file, validates magic bytes, compresses if needed, and returns `LogoData`. It does NOT persist anything. The caller (ViewModel in Phase 11) decides whether to save as MSP logo or client logo.

    2. Create `BrandingService.cs`:
       - Constructor takes `BrandingRepository` (same pattern as `SettingsService` taking `SettingsRepository`).
       - `ImportLogoAsync(string filePath)`:
         a. Read all bytes via `File.ReadAllBytesAsync`.
         b. Detect MIME type from magic bytes: PNG signature `0x89,0x50,0x4E,0x47` (first 4 bytes), JPEG signature `0xFF,0xD8,0xFF` (first 3 bytes). If neither matches, throw `InvalidDataException("File format is not PNG or JPG. Only PNG and JPG are accepted.")`.
         c. If bytes.Length > 512 * 1024, call `CompressToLimit(bytes, mimeType, 512 * 1024)`.
         d. Return `new LogoData { Base64 = Convert.ToBase64String(bytes), MimeType = mimeType }`.
       - `CompressToLimit` private static method: Use `System.Drawing.Bitmap` to resize to max 300x300px (proportional scaling) and re-encode at quality 75. Use `System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders()` to find the codec matching the MIME type. Use `EncoderParameters` with `Encoder.Quality` set to 75L. If still over limit after first pass, reduce to 200x200 and quality 50. Return the compressed bytes.
       - `SaveMspLogoAsync(LogoData logo)`: Load settings from repo, set `MspLogo = logo`, save back.
       - `ClearMspLogoAsync()`: Load settings, set `MspLogo = null`, save back.
       - `GetMspLogoAsync()`: Load settings, return `MspLogo` (may be null).

    3. Create `BrandingServiceTests.cs`:
       - Use `[Trait("Category", "Unit")]` and `IDisposable` pattern.
       - For magic byte tests: create small byte arrays with correct headers. For PNG, use the 8-byte PNG signature (`0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A`) followed by minimal IHDR+IEND chunks to make a valid 1x1 PNG. For JPEG, use `0xFF,0xD8,0xFF,0xE0` + minimal JFIF header + `0xFF,0xD9` (EOI). Write these to temp files and call `ImportLogoAsync`.
       - For compression test: generate a valid PNG/JPEG that exceeds 512 KB (e.g., create a 400x400 bitmap filled with random pixels, save as PNG to a temp file, verify it exceeds 512 KB, then call `ImportLogoAsync` and verify result decodes to <= 512 KB).
       - For SaveMspLogoAsync/ClearMspLogoAsync/GetMspLogoAsync: use real `BrandingRepository` with temp file (same pattern as `SettingsServiceTests`).
       - Do NOT mock BrandingRepository — the existing test pattern in this codebase uses real file I/O with temp files.
  </action>
  <verify>
    <automated>dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build</automated>
  </verify>
  <done>BrandingService validates PNG/JPG via magic bytes, rejects other formats with descriptive error, auto-compresses files over 512 KB, and provides MSP logo CRUD. All tests pass including round-trip through repository.</done>
</task>

</tasks>

<verification>
```bash
dotnet build --no-restore -warnaserror
dotnet test --filter "FullyQualifiedName~Branding" --no-build
dotnet test --filter "FullyQualifiedName~ProfileService" --no-build
```
All three commands must succeed with zero failures. The ProfileServiceTests confirm TenantProfile changes do not break existing profile persistence.
</verification>

<success_criteria>
- LogoData record exists with Base64 and MimeType init properties
- BrandingSettings class exists with nullable MspLogo property
- TenantProfile has nullable ClientLogo property (additive, no breaking changes)
- BrandingRepository persists BrandingSettings to JSON with write-then-replace safety
- BrandingService validates magic bytes (PNG/JPG only), auto-compresses > 512 KB, and provides MSP logo CRUD
- All existing tests continue to pass (no regressions from TenantProfile extension)
- New tests cover: repository round-trip, format validation, compression, rejection, CRUD
</success_criteria>

<output>
After completion, create `.planning/phases/10-branding-data-foundation/10-01-SUMMARY.md`
</output>
