SubtitleTools.psm1

#Requires -Version 5.1

# ============================================================
# CLASS DEFINITIONS
# Must be defined directly in the root psm1 — NOT dot-sourced.
# PowerShell 5.1 classes in dot-sourced files are not accessible
# to callers that use Import-Module (only 'using module' would work).
# ============================================================

class SubtitleEntry {
    [int]       $Index
    [TimeSpan]  $Start
    [TimeSpan]  $End
    [string[]]  $Lines
    [string]    $RawText
    [hashtable] $Metadata

    SubtitleEntry() {
        $this.Metadata = @{}
        $this.Lines    = @()
    }

    [TimeSpan] Duration() { return $this.End - $this.Start }
    [string]   Text()     { return $this.Lines -join "`n" }

    [string] ToString() {
        return '[{0}] {1} --> {2} : {3}' -f $this.Index, $this.Start, $this.End, ($this.Lines -join ' ')
    }
}

class SrtEntry : SubtitleEntry {
    [int]  $BlockNumber
    [bool] $HasHtmlTags

    SrtEntry() : base() { $this.HasHtmlTags = $false }
}

class AssEntry : SubtitleEntry {
    [string]   $Layer
    [string]   $Style
    [string]   $Name
    [string]   $MarginL
    [string]   $MarginR
    [string]   $MarginV
    [string]   $Effect
    [string]   $EventType
    [string[]] $OverrideTags

    AssEntry() : base() {
        $this.Layer        = '0'
        $this.Style        = 'Default'
        $this.Name         = ''
        $this.MarginL      = '0000'
        $this.MarginR      = '0000'
        $this.MarginV      = '0000'
        $this.Effect       = ''
        $this.EventType    = 'Dialogue'
        $this.OverrideTags = @()
    }
}

class AssStyle {
    [string]  $Name
    [string]  $Fontname
    [int]     $Fontsize
    [string]  $PrimaryColour
    [string]  $SecondaryColour
    [string]  $OutlineColour
    [string]  $BackColour
    [bool]    $Bold
    [bool]    $Italic
    [bool]    $Underline
    [bool]    $StrikeOut
    [decimal] $ScaleX
    [decimal] $ScaleY
    [decimal] $Spacing
    [decimal] $Angle
    [int]     $BorderStyle
    [decimal] $Outline
    [decimal] $Shadow
    [int]     $Alignment
    [int]     $MarginL
    [int]     $MarginR
    [int]     $MarginV
    [int]     $Encoding

    AssStyle() {
        $this.Name            = 'Default'
        $this.Fontname        = 'Arial'
        $this.Fontsize        = 20
        $this.PrimaryColour   = '&H00FFFFFF&'
        $this.SecondaryColour = '&H000000FF&'
        $this.OutlineColour   = '&H00000000&'
        $this.BackColour      = '&H00000000&'
        $this.Bold            = $false
        $this.Italic          = $false
        $this.Underline       = $false
        $this.StrikeOut       = $false
        $this.ScaleX          = 100
        $this.ScaleY          = 100
        $this.Spacing         = 0
        $this.Angle           = 0
        $this.BorderStyle     = 1
        $this.Outline         = 2
        $this.Shadow          = 0
        $this.Alignment       = 2
        $this.MarginL         = 10
        $this.MarginR         = 10
        $this.MarginV         = 10
        $this.Encoding        = 1
    }

    [string] ToAssLine() {
        $b = if ($this.Bold)      { '-1' } else { '0' }
        $i = if ($this.Italic)    { '-1' } else { '0' }
        $u = if ($this.Underline) { '-1' } else { '0' }
        $s = if ($this.StrikeOut) { '-1' } else { '0' }
        return 'Style: {0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11},{12},{13},{14},{15},{16},{17},{18},{19},{20},{21},{22}' -f `
            $this.Name, $this.Fontname, $this.Fontsize,
            $this.PrimaryColour, $this.SecondaryColour, $this.OutlineColour, $this.BackColour,
            $b, $i, $u, $s,
            $this.ScaleX, $this.ScaleY, $this.Spacing, $this.Angle,
            $this.BorderStyle, $this.Outline, $this.Shadow,
            $this.Alignment, $this.MarginL, $this.MarginR, $this.MarginV, $this.Encoding
    }
}

class AssHeader {
    [string]     $ScriptType
    [string]     $Title
    [string]     $OriginalScript
    [string]     $PlayResX
    [string]     $PlayResY
    [string]     $YCbCrMatrix
    [hashtable]  $ExtraFields
    [AssStyle[]] $Styles
    [string[]]   $EventColumnOrder

    AssHeader() {
        $this.ScriptType       = 'v4.00+'
        $this.Title            = 'Untitled'
        $this.OriginalScript   = ''
        $this.PlayResX         = '640'
        $this.PlayResY         = '480'
        $this.YCbCrMatrix      = ''
        $this.ExtraFields      = @{}
        $this.Styles           = @()
        $this.EventColumnOrder = @('Layer','Start','End','Style','Name','MarginL','MarginR','MarginV','Effect','Text')
    }
}

class SubtitleFile {
    [string]          $Path
    [string]          $Format
    [string]          $Encoding
    [bool]            $HasBom
    [AssHeader]       $Header
    [SubtitleEntry[]] $Entries
    [hashtable]       $ParserWarnings

    SubtitleFile() {
        $this.Entries        = @()
        $this.ParserWarnings = @{}
        $this.HasBom         = $false
        $this.Encoding       = 'UTF-8'
    }

    [int]      EntryCount()    { return $this.Entries.Count }
    [TimeSpan] TotalDuration() {
        if ($this.Entries.Count -eq 0) { return [TimeSpan]::Zero }
        return ($this.Entries | Sort-Object End | Select-Object -Last 1).End
    }
    [string] ToString() {
        return '[SubtitleFile] Format={0} Entries={1} Path={2}' -f $this.Format, $this.Entries.Count, $this.Path
    }
}

class ValidationIssue {
    [int]    $EntryIndex
    [string] $Field
    [string] $Message
    [string] $Severity

    ValidationIssue([int]$index, [string]$field, [string]$message, [string]$severity) {
        $this.EntryIndex = $index
        $this.Field      = $field
        $this.Message    = $message
        $this.Severity   = $severity
    }
    [string] ToString() {
        return '[{0}] Entry {1} {2}: {3}' -f $this.Severity, $this.EntryIndex, $this.Field, $this.Message
    }
}

class ValidationResult {
    [bool]              $IsValid
    [string]            $FilePath
    [string]            $Format
    [ValidationIssue[]] $Errors
    [ValidationIssue[]] $Warnings
    [int]               $ErrorCount
    [int]               $WarningCount

    ValidationResult() {
        $this.IsValid      = $true
        $this.Errors       = @()
        $this.Warnings     = @()
        $this.ErrorCount   = 0
        $this.WarningCount = 0
    }

    [void] AddError([int]$index, [string]$field, [string]$message) {
        $issue         = [ValidationIssue]::new($index, $field, $message, 'Error')
        $this.Errors  += $issue
        $this.ErrorCount++
        $this.IsValid  = $false
    }

    [void] AddWarning([int]$index, [string]$field, [string]$message) {
        $issue            = [ValidationIssue]::new($index, $field, $message, 'Warning')
        $this.Warnings   += $issue
        $this.WarningCount++
    }

    [string] ToString() {
        $status = if ($this.IsValid) { 'VALID' } else { 'INVALID' }
        return '[ValidationResult] {0} Errors={1} Warnings={2}' -f $status, $this.ErrorCount, $this.WarningCount
    }
}

class TranslationProvider {
    [string]   $Name
    [string]   $Model
    [string]   $ApiKeyEncrypted    # DPAPI-encrypted base64 API key (CurrentUser scope)
    [string]   $BaseUrl
    [int]      $MaxTokensPerBatch
    [int]      $RateLimitRpm       # Requests per minute (0 = unlimited)
    [decimal]  $Temperature
    [string[]] $SupportedLanguages

    TranslationProvider() {
        $this.Temperature        = 0.3
        $this.MaxTokensPerBatch  = 4000
        $this.RateLimitRpm       = 60
        $this.SupportedLanguages = @()
    }

    [string] ToString() {
        return '[TranslationProvider] {0} Model={1}' -f $this.Name, $this.Model
    }
}


# ============================================================
# LOAD PRIVATE FUNCTIONS
# ============================================================
$PrivateFiles = Get-ChildItem -Path "$PSScriptRoot\Private" -Recurse -Filter '*.ps1' -ErrorAction SilentlyContinue

foreach ($File in $PrivateFiles) {
    . $File.FullName
}

# ============================================================
# LOAD PUBLIC FUNCTIONS
# ============================================================
$PublicFiles = Get-ChildItem -Path "$PSScriptRoot\Public" -Recurse -Filter '*.ps1' -ErrorAction SilentlyContinue

foreach ($File in $PublicFiles) {
    . $File.FullName
}

# Export only public functions
$ExportNames = $PublicFiles | ForEach-Object { $_.BaseName }
Export-ModuleMember -Function $ExportNames

# ============================================================
# LOAD REFERENCE DATA
# ============================================================
$DataPath = "$PSScriptRoot\Data"

if (Test-Path "$DataPath\ProviderDefaults.json") {
    $script:ProviderDefaults = Get-Content "$DataPath\ProviderDefaults.json" -Raw | ConvertFrom-Json
}
if (Test-Path "$DataPath\LanguageCodes.json") {
    $script:LanguageCodes = Get-Content "$DataPath\LanguageCodes.json" -Raw | ConvertFrom-Json
}
if (Test-Path "$DataPath\DefaultAssStyles.json") {
    $script:DefaultAssStyles = Get-Content "$DataPath\DefaultAssStyles.json" -Raw | ConvertFrom-Json
}
if (Test-Path "$DataPath\SubDLLanguages.json") {
    $script:SubDLLanguages = Get-Content "$DataPath\SubDLLanguages.json" -Raw | ConvertFrom-Json
}

# Module-scope storage for configured translation providers
$script:ConfiguredProviders = @{}
$script:DefaultProvider     = $null
$script:ProvidersFilePath   = Join-Path $env:APPDATA 'SubtitleTools\providers.json'

# Module-scope SubDL token store
$script:SubDLTokenEncrypted = $null
$script:SubDLTokenStorePath = Join-Path $env:APPDATA 'SubtitleTools\subdl.json'

# Load persisted providers on module import
if (Test-Path $script:ProvidersFilePath) {
    try {
        $store = Get-Content $script:ProvidersFilePath -Raw | ConvertFrom-Json
        $script:DefaultProvider = $store.DefaultProvider
        foreach ($provName in ($store.Providers | Get-Member -MemberType NoteProperty -ErrorAction SilentlyContinue).Name) {
            $p        = $store.Providers.$provName
            $provider = [TranslationProvider]::new()
            $provider.Name              = $p.Name
            $provider.Model             = $p.Model
            $provider.BaseUrl           = $p.BaseUrl
            $provider.RateLimitRpm      = $p.RateLimitRpm
            $provider.MaxTokensPerBatch = $p.MaxTokensPerBatch
            $provider.Temperature       = [decimal]$p.Temperature
            $provider.ApiKeyEncrypted   = $p.ApiKeyEncrypted
            $script:ConfiguredProviders[$provName] = $provider
        }
        Write-Verbose "SubtitleTools: Loaded $($script:ConfiguredProviders.Count) provider(s). Default: $script:DefaultProvider"
    } catch {
        Write-Warning "SubtitleTools: Could not load saved providers: $_"
    }
}

# Load persisted SubDL token on module import
if (Test-Path $script:SubDLTokenStorePath) {
    try {
        $subdlStore = Get-Content $script:SubDLTokenStorePath -Raw | ConvertFrom-Json
        $script:SubDLTokenEncrypted = $subdlStore.TokenEncrypted
        Write-Verbose 'SubtitleTools: SubDL token loaded.'
    } catch {
        Write-Warning "SubtitleTools: Could not load SubDL token: $_"
    }
}