#!/usr/bin/env python3
"""Build the Boat Lift Size Calculator research dataset.

Punta Gorda Boat Lift Repair Research
Dataset version 2.0.0
Verification date: 2026-07-24

The program separates three kinds of values:
1. Primary-source reference constants (NIST, USGS, NOAA).
2. Declared reference-vessel parameters used only for method comparison.
3. Formula rules transcribed from the named publisher's own resource.

A blank or excluded component is never silently converted into a measured zero.
Where a published method needs a factor it does not disclose, the neutral factor
used is identified in the method note and in the page methodology.
"""

from __future__ import annotations

import csv
import json
from dataclasses import asdict, dataclass
from decimal import Decimal, ROUND_CEILING
from pathlib import Path
from typing import Callable

BUILD_DATE = "2026-07-24"
DATASET_VERSION = "2.0.0"
DRY_WEIGHT_SCOPE = (
    "The declared base dry-weight input excludes every component separately "
    "itemized in the study: engines, fuel and tank contents, stored gear, "
    "listed batteries, and the declared top."
)
OUTPUT_DIR = Path(__file__).resolve().parent

# Primary-source reference constants.
# NIST Handbook 105-8 (2019), average fuel densities at 60 °F.
NIST_GASOLINE_LB_PER_GAL = Decimal("6.213")
NIST_DIESEL_LB_PER_GAL = Decimal("7.113")
# USGS Water Density: one US gallon of tap water at 70 °F.
USGS_FRESH_WATER_LB_PER_GAL = Decimal("8.329")
# NOAA World Ocean Database conversion reference: 1025 kg/m³.
US_GALLON_M3 = Decimal("0.003785411784")
KG_TO_LB = Decimal("2.20462262185")
NOAA_SEAWATER_REFERENCE_KG_M3 = Decimal("1025")
NOAA_SEAWATER_LB_PER_GAL = (
    NOAA_SEAWATER_REFERENCE_KG_M3 * US_GALLON_M3 * KG_TO_LB
)


def ceil_lb(value: Decimal) -> int:
    """Round a capacity screen upward to the next whole pound."""
    return int(value.to_integral_value(rounding=ROUND_CEILING))


@dataclass(frozen=True)
class ReferenceVessel:
    vessel: str
    vessel_label: str
    dry_weight_excluding_engines_lb: Decimal
    engine_count: int
    engine_unit_weight_lb: Decimal
    fuel_gal: Decimal
    livewell_saltwater_gal: Decimal
    freshwater_gal: Decimal
    wastewater_gal: Decimal
    stored_gear_lb: Decimal
    battery_count: int
    battery_unit_weight_lb: Decimal
    top_weight_lb: Decimal

    @property
    def engines_lb(self) -> Decimal:
        return Decimal(self.engine_count) * self.engine_unit_weight_lb

    @property
    def batteries_lb(self) -> Decimal:
        return Decimal(self.battery_count) * self.battery_unit_weight_lb


VESSELS = [
    ReferenceVessel(
        vessel="RV-1",
        vessel_label="RV-1: 24 ft coastal center console, single outboard",
        dry_weight_excluding_engines_lb=Decimal("3200"),
        engine_count=1,
        engine_unit_weight_lb=Decimal("629"),
        fuel_gal=Decimal("120"),
        livewell_saltwater_gal=Decimal("40"),
        freshwater_gal=Decimal("0"),
        wastewater_gal=Decimal("0"),
        stored_gear_lb=Decimal("250"),
        battery_count=2,
        battery_unit_weight_lb=Decimal("70"),
        top_weight_lb=Decimal("150"),
    ),
    ReferenceVessel(
        vessel="RV-2",
        vessel_label="RV-2: 32 ft coastal cruiser, twin outboards",
        dry_weight_excluding_engines_lb=Decimal("8500"),
        engine_count=2,
        engine_unit_weight_lb=Decimal("629"),
        fuel_gal=Decimal("300"),
        livewell_saltwater_gal=Decimal("50"),
        freshwater_gal=Decimal("30"),
        wastewater_gal=Decimal("20"),
        stored_gear_lb=Decimal("400"),
        battery_count=4,
        battery_unit_weight_lb=Decimal("70"),
        top_weight_lb=Decimal("400"),
    ),
]


@dataclass(frozen=True)
class MethodResult:
    low: Decimal
    high: Decimal


MethodFunction = Callable[[ReferenceVessel], MethodResult]


def water_reference(v: ReferenceVessel) -> Decimal:
    return (
        v.livewell_saltwater_gal * NOAA_SEAWATER_LB_PER_GAL
        + (v.freshwater_gal + v.wastewater_gal) * USGS_FRESH_WATER_LB_PER_GAL
    )


def method_shoremaster(v: ReferenceVessel) -> MethodResult:
    # Published guide: dry + gasoline at 6 + 500; add omitted motor; account for
    # onboard water; then use a 20% safety margin and size up.
    subtotal = (
        v.dry_weight_excluding_engines_lb
        + v.engines_lb
        + v.fuel_gal * Decimal("6")
        + water_reference(v)
        + Decimal("500")
    )
    value = subtotal * Decimal("1.20")
    return MethodResult(value, value)


def method_hydrohoist(v: ReferenceVessel) -> MethodResult:
    # Published guide: dry + gasoline at 6 + 500, water at 8, then at least 20%.
    # The guide does not expressly tell the reader to add an omitted engine.
    subtotal = (
        v.dry_weight_excluding_engines_lb
        + v.fuel_gal * Decimal("6")
        + (v.livewell_saltwater_gal + v.freshwater_gal + v.wastewater_gal)
        * Decimal("8")
        + Decimal("500")
    )
    value = subtotal * Decimal("1.20")
    return MethodResult(value, value)


def method_hewitt(v: ReferenceVessel) -> MethodResult:
    # Published formula: dry + fuel + people/gear + livewell/ballast water +
    # batteries, then ×1.2. The page gives no fuel coefficient, so the NIST
    # gasoline reference is used. People are set to zero for an unoccupied lift.
    subtotal = (
        v.dry_weight_excluding_engines_lb
        + v.fuel_gal * NIST_GASOLINE_LB_PER_GAL
        + v.stored_gear_lb
        + v.livewell_saltwater_gal * NOAA_SEAWATER_LB_PER_GAL
        + v.batteries_lb
    )
    value = subtotal * Decimal("1.20")
    return MethodResult(value, value)


def method_imm(v: ReferenceVessel) -> MethodResult:
    # IMM guidance: add omitted outboards, gasoline at about 6, water at about 8,
    # gear and non-factory additions, then 20%.
    subtotal = (
        v.dry_weight_excluding_engines_lb
        + v.engines_lb
        + v.fuel_gal * Decimal("6")
        + (v.livewell_saltwater_gal + v.freshwater_gal + v.wastewater_gal)
        * Decimal("8")
        + v.stored_gear_lb
        + v.batteries_lb
        + v.top_weight_lb
    )
    value = subtotal * Decimal("1.20")
    return MethodResult(value, value)


def method_pws(v: ReferenceVessel) -> MethodResult:
    # Pier & Waterfront Solutions: account for an omitted outboard, fuel at 6–7,
    # water at 8, gear and non-factory additions, then a 10–20% margin.
    def subtotal(fuel_factor: Decimal) -> Decimal:
        return (
            v.dry_weight_excluding_engines_lb
            + v.engines_lb
            + v.fuel_gal * fuel_factor
            + (v.livewell_saltwater_gal + v.freshwater_gal + v.wastewater_gal)
            * Decimal("8")
            + v.stored_gear_lb
            + v.batteries_lb
            + v.top_weight_lb
        )

    return MethodResult(
        subtotal(Decimal("6")) * Decimal("1.10"),
        subtotal(Decimal("7")) * Decimal("1.20"),
    )


def method_hurricane(v: ReferenceVessel) -> MethodResult:
    # Current guide: dry + fuel (about 6) + gear/equipment + water, then 10–20%.
    # Batteries and the fixed top are treated as equipment. The guide does not
    # expressly tell the reader to add an omitted engine. It gives no water
    # coefficient, so the neutral USGS/NOAA references are used.
    subtotal = (
        v.dry_weight_excluding_engines_lb
        + v.fuel_gal * Decimal("6")
        + water_reference(v)
        + v.stored_gear_lb
        + v.batteries_lb
        + v.top_weight_lb
    )
    return MethodResult(subtotal * Decimal("1.10"), subtotal * Decimal("1.20"))


def method_calculator_academy(v: ReferenceVessel) -> MethodResult:
    # Custom method: dry + fuel + gear/passengers/accessories + water at 8.34,
    # with 10–25%+. The page warns that dry weight may omit engines, so the known
    # engine, batteries and top are placed in the accessories input.
    def subtotal(fuel_factor: Decimal) -> Decimal:
        return (
            v.dry_weight_excluding_engines_lb
            + v.engines_lb
            + v.fuel_gal * fuel_factor
            + (v.livewell_saltwater_gal + v.freshwater_gal + v.wastewater_gal)
            * Decimal("8.34")
            + v.stored_gear_lb
            + v.batteries_lb
            + v.top_weight_lb
        )

    return MethodResult(
        subtotal(Decimal("6.0")) * Decimal("1.10"),
        subtotal(Decimal("6.3")) * Decimal("1.25"),
    )


def method_neptune(v: ReferenceVessel) -> MethodResult:
    # Calculator guidance: add omitted engines; gasoline 6.5; water 8.5; known
    # equipment may be itemized; calculator adds a 15% allowance. Neptune also
    # calls for an appropriate capacity margin but does not publish its percent,
    # so no second numerical margin is added here.
    subtotal = (
        v.dry_weight_excluding_engines_lb
        + v.engines_lb
        + v.fuel_gal * Decimal("6.5")
        + (v.livewell_saltwater_gal + v.freshwater_gal + v.wastewater_gal)
        * Decimal("8.5")
        + v.stored_gear_lb
        + v.batteries_lb
        + v.top_weight_lb
    )
    value = subtotal * Decimal("1.15")
    return MethodResult(value, value)


def full_component_benchmark(v: ReferenceVessel) -> tuple[dict[str, Decimal], Decimal, Decimal]:
    components = {
        "published_dry_weight_lb": v.dry_weight_excluding_engines_lb,
        "engines_lb": v.engines_lb,
        "fuel_lb": v.fuel_gal * NIST_GASOLINE_LB_PER_GAL,
        "livewell_saltwater_lb": v.livewell_saltwater_gal
        * NOAA_SEAWATER_LB_PER_GAL,
        "freshwater_lb": v.freshwater_gal * USGS_FRESH_WATER_LB_PER_GAL,
        "wastewater_lb": v.wastewater_gal * USGS_FRESH_WATER_LB_PER_GAL,
        "stored_gear_lb": v.stored_gear_lb,
        "batteries_lb": v.batteries_lb,
        "top_lb": v.top_weight_lb,
    }
    loaded = sum(components.values(), Decimal("0"))
    capacity = loaded * Decimal("1.20")
    return components, loaded, capacity


@dataclass(frozen=True)
class MethodDefinition:
    method: str
    publisher_type: str
    source_title: str
    source_url: str
    source_date: str
    function: MethodFunction | None
    counts_or_flags_omitted_engine: bool
    interactive: bool
    email_gated_result: bool
    numeric_separate_margin: str
    formula_transparency: str
    method_note: str


METHODS = [
    MethodDefinition(
        "ShoreMaster",
        "manufacturer",
        "Determining Boat Lift Capacity: A Guide to Accurate Measurements",
        "https://www.shoremaster.com/blog/articles/determining-boat-lift-capacity-a-guide-to-accurate-measurements-shoremaster/",
        "2021-08-26",
        method_shoremaster,
        True,
        False,
        False,
        "20%",
        "published formula and narrative additions",
        "Adds an omitted motor and water to dry + fuel×6 + 500, then recommends 20% and sizing up.",
    ),
    MethodDefinition(
        "HydroHoist",
        "manufacturer",
        "Boat Lift Weight Capacity: How Do I Determine What Lift Capacity I Need?",
        "https://www.boatlift.com/blog/articles/boat-lift-weight-capacity-how-do-i-determine-what-lift-capacity-i-need-hydrohoist/",
        "2023-05-25",
        method_hydrohoist,
        False,
        False,
        False,
        "at least 20%",
        "published formula",
        "Dry + fuel×6 + water×8 + 500, then at least 20%; no express omitted-engine instruction.",
    ),
    MethodDefinition(
        "Hewitt",
        "manufacturer",
        "How to Size a Boat Lift Correctly",
        "https://www.hewittrad.com/about-us/news-and-events/how-to-size-a-boat-lift-correctly",
        "2025-06-30",
        method_hewitt,
        False,
        False,
        False,
        "20%",
        "published formula; fuel factor undisclosed",
        "Dry + fuel + people/gear + livewell/ballast water + batteries, ×1.2; NIST fuel reference used here.",
    ),
    MethodDefinition(
        "IMM Quality Boat Lifts",
        "manufacturer",
        "How to Choose the Correct Capacity Boat Lift for your Boat",
        "https://iqboatlifts.com/how-to-choose-the-correct-capacity-boat-lift-for-your-boat/",
        "2017-04-09",
        method_imm,
        True,
        True,
        True,
        "20%",
        "guide discloses factors; calculator result is gated",
        "Adds omitted outboards, fuel×6, water×8, gear and additions, then 20%.",
    ),
    MethodDefinition(
        "Pier & Waterfront Solutions",
        "dealer/installer",
        "Boat Lift Calculations Part 4 of 4",
        "https://wisconsinpws.com/boat-lift-calculations-part-4/",
        "2019-02-26",
        method_pws,
        True,
        False,
        False,
        "10–20%",
        "published ranges and method",
        "Adds an omitted outboard, fuel×6–7, water×8, gear and modifications, then 10–20%.",
    ),
    MethodDefinition(
        "Hurricane Boat Lifts",
        "manufacturer",
        "Boat Lift Weight Capacity Explained: How to Calculate the Right Lift for Your Vessel",
        "https://www.hurricaneboatlifts.com/boat-lift-weight-capacity-calculator/",
        "current page read 2026-07-24",
        method_hurricane,
        False,
        False,
        False,
        "10–20%",
        "published method; water factor undisclosed",
        "Dry + fuel + water + gear/equipment, then 10–20%; neutral water references used here.",
    ),
    MethodDefinition(
        "Calculator Academy",
        "independent calculator publisher",
        "Boat Lift Capacity Calculator",
        "https://calculator.academy/boat-lift-capacity-calculator/",
        "2026-01-16",
        method_calculator_academy,
        True,
        True,
        False,
        "10–25%+",
        "published custom formula and factors",
        "Custom method counts known omitted engine as an accessory, fuel×6.0–6.3, water×8.34, then 10–25%.",
    ),
    MethodDefinition(
        "Neptune Boat Lifts",
        "manufacturer",
        "Boat Lift Weight Calculator",
        "https://www.neptuneboatlifts.com/resources/boat-lift-weight-calculator/",
        "current page read 2026-07-24",
        method_neptune,
        True,
        True,
        False,
        "not quantified separately",
        "published constants; calculator code not disclosed",
        "Adds omitted engines, fuel×6.5, water×8.5 and known equipment, then a 15% allowance.",
    ),
    MethodDefinition(
        "Golden Boat Lifts",
        "manufacturer",
        "Weight Calculator",
        "https://goldenboatlifts.com/weight-calculator/",
        "current page read 2026-07-24",
        None,
        True,
        True,
        False,
        "not disclosed",
        "inputs visible; coefficients and algorithm undisclosed",
        "Collects propulsion, outboard count and horsepower, tanks, top, batteries and extras; excluded from numeric spread.",
    ),
    MethodDefinition(
        "Hi-Tide Boat Lifts",
        "manufacturer",
        "Lift Calculator",
        "https://hi-tide.com/lift-calculator/",
        "current page read 2026-07-24",
        None,
        True,
        True,
        False,
        "not disclosed separately",
        "inputs and 15% gear allowance visible; fluid coefficients undisclosed",
        "Dry weight explicitly excludes outboards; engine and tank inputs; 15% onboard-gear allowance; excluded from numeric spread.",
    ),
]


CHARLOTTE_2020 = [
    ("A-1", "under 12 ft", 3160, 23),
    ("A-2", "12 ft–15 ft 11 in", 2505, 35),
    ("1", "16 ft–25 ft 11 in", 14097, 364),
    ("2", "26 ft–39 ft 11 in", 2608, 73),
    ("3", "40 ft–64 ft 11 in", 400, 10),
    ("4", "65 ft–109 ft 11 in", 3, 0),
    ("5", "110 ft and over", 1, 0),
    ("Canoe", "canoes", 202, 1),
]
CHARLOTTE_PLEASURE_TOTAL = 22976
CHARLOTTE_COMMERCIAL_TOTAL = 506
CHARLOTTE_DEALERS = 60
CHARLOTTE_OVERALL_TOTAL = 23542


def decimal_text(value: Decimal, places: int = 3) -> str:
    return f"{value:.{places}f}"


def build() -> None:
    method_rows: list[dict[str, object]] = []
    benchmark_rows: list[dict[str, object]] = []

    for vessel in VESSELS:
        components, loaded, benchmark_capacity = full_component_benchmark(vessel)
        benchmark_rows.append(
            {
                "vessel": vessel.vessel,
                "vessel_label": vessel.vessel_label,
                **{key: decimal_text(value) for key, value in components.items()},
                "loaded_weight_lb": decimal_text(loaded),
                "capacity_margin_pct": "20",
                "minimum_capacity_screen_exact_lb": decimal_text(benchmark_capacity),
                "minimum_capacity_screen_rounded_up_lb": ceil_lb(benchmark_capacity),
            }
        )

        for method in METHODS:
            if method.function is None:
                result_low = result_high = None
                low_rounded = high_rounded = None
                status = "reviewed—not numerically reproducible"
            else:
                result = method.function(vessel)
                result_low, result_high = result.low, result.high
                low_rounded, high_rounded = ceil_lb(result.low), ceil_lb(result.high)
                status = "reviewed—numerically reproducible"

            method_rows.append(
                {
                    "vessel": vessel.vessel,
                    "vessel_label": vessel.vessel_label,
                    "method": method.method,
                    "publisher_type": method.publisher_type,
                    "source_title": method.source_title,
                    "source_url": method.source_url,
                    "source_date": method.source_date,
                    "required_capacity_low_exact_lb": (
                        decimal_text(result_low) if result_low is not None else ""
                    ),
                    "required_capacity_high_exact_lb": (
                        decimal_text(result_high) if result_high is not None else ""
                    ),
                    "required_capacity_low_rounded_up_lb": low_rounded or "",
                    "required_capacity_high_rounded_up_lb": high_rounded or "",
                    "counts_or_flags_omitted_engine": method.counts_or_flags_omitted_engine,
                    "interactive": method.interactive,
                    "email_gated_result": method.email_gated_result,
                    "numeric_separate_margin": method.numeric_separate_margin,
                    "formula_transparency": method.formula_transparency,
                    "method_note": method.method_note,
                    "status": status,
                    "verified_date": BUILD_DATE,
                }
            )

    pleasure_sum = sum(row[2] for row in CHARLOTTE_2020)
    commercial_sum = sum(row[3] for row in CHARLOTTE_2020)
    assert pleasure_sum == CHARLOTTE_PLEASURE_TOTAL
    assert commercial_sum == CHARLOTTE_COMMERCIAL_TOTAL
    assert (
        CHARLOTTE_DEALERS + pleasure_sum + commercial_sum
        == CHARLOTTE_OVERALL_TOTAL
    )

    fleet_rows = [
        {
            "county": "Charlotte",
            "state": "FL",
            "data_year": 2020,
            "registration_class": registration_class,
            "length_range": length_range,
            "pleasure_vessels": pleasure,
            "commercial_vessels": commercial,
            "pct_of_county_pleasure_fleet": round(
                pleasure / CHARLOTTE_PLEASURE_TOTAL * 100, 2
            ),
            "source": "FLHSMV Alphabetical Vessel Statistics by County, 2020",
            "source_url": "https://www.flhsmv.gov/pdf/vessels/vesselstats2020.pdf",
            "verified_date": BUILD_DATE,
        }
        for registration_class, length_range, pleasure, commercial in CHARLOTTE_2020
    ]

    # Integrity gates for the headline findings.
    for vessel in VESSELS:
        rows = [
            row
            for row in method_rows
            if row["vessel"] == vessel.vessel
            and row["status"] == "reviewed—numerically reproducible"
        ]
        low = min(int(row["required_capacity_low_rounded_up_lb"]) for row in rows)
        high = max(int(row["required_capacity_high_rounded_up_lb"]) for row in rows)
        if vessel.vessel == "RV-1":
            assert (low, high) == (5283, 6824)
        if vessel.vessel == "RV-2":
            assert (low, high) == (13447, 16953)

    method_csv = OUTPUT_DIR / "boat-lift-sizing-method-comparison.csv"
    with method_csv.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(method_rows[0].keys()))
        writer.writeheader()
        writer.writerows(method_rows)

    fleet_csv = OUTPUT_DIR / "charlotte-county-vessel-fleet-by-class.csv"
    with fleet_csv.open("w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=list(fleet_rows[0].keys()))
        writer.writeheader()
        writer.writerows(fleet_rows)

    bundle = {
        "dataset": "Boat Lift Sizing Reference Dataset",
        "publisher": "Punta Gorda Boat Lift Repair Research",
        "version": DATASET_VERSION,
        "built": BUILD_DATE,
        "scope": {
            "resources_reviewed": len(METHODS),
            "numerically_reproducible_methods": sum(
                1 for method in METHODS if method.function is not None
            ),
            "nonreproducible_resources": [
                method.method for method in METHODS if method.function is None
            ],
            "reference_vessel_dry_weight_scope": DRY_WEIGHT_SCOPE,
        },
        "reference_constants": {
            "nist_gasoline_lb_per_us_gal_at_60f": str(NIST_GASOLINE_LB_PER_GAL),
            "nist_diesel_lb_per_us_gal_at_60f": str(NIST_DIESEL_LB_PER_GAL),
            "usgs_fresh_water_lb_per_us_gal_at_70f": str(
                USGS_FRESH_WATER_LB_PER_GAL
            ),
            "noaa_reference_seawater_density_kg_m3": str(
                NOAA_SEAWATER_REFERENCE_KG_M3
            ),
            "noaa_reference_seawater_lb_per_us_gal": decimal_text(
                NOAA_SEAWATER_LB_PER_GAL, 6
            ),
            "us_liquid_gallon_m3_exact": str(US_GALLON_M3),
        },
        "reference_vessels": [
            {
                **{
                    key: (str(value) if isinstance(value, Decimal) else value)
                    for key, value in asdict(vessel).items()
                },
                "dry_weight_scope": DRY_WEIGHT_SCOPE,
                "engines_lb": str(vessel.engines_lb),
                "batteries_lb": str(vessel.batteries_lb),
            }
            for vessel in VESSELS
        ],
        "method_definitions": [
            {
                key: value
                for key, value in asdict(method).items()
                if key != "function"
            }
            for method in METHODS
        ],
        "method_comparison": method_rows,
        "full_component_benchmark": benchmark_rows,
        "charlotte_county_fleet_2020": fleet_rows,
        "integrity_checks": {
            "charlotte_pleasure_class_sum": pleasure_sum,
            "charlotte_published_pleasure_total": CHARLOTTE_PLEASURE_TOTAL,
            "charlotte_commercial_class_sum": commercial_sum,
            "charlotte_published_commercial_total": CHARLOTTE_COMMERCIAL_TOTAL,
            "charlotte_dealer_registrations": CHARLOTTE_DEALERS,
            "charlotte_published_overall_total": CHARLOTTE_OVERALL_TOTAL,
            "rv1_reproducible_method_range_lb": [5283, 6824],
            "rv2_reproducible_method_range_lb": [13447, 16953],
        },
    }

    json_path = OUTPUT_DIR / "boat-lift-sizing-dataset.json"
    json_path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False), encoding="utf-8")

    print(f"Wrote {method_csv}")
    print(f"Wrote {fleet_csv}")
    print(f"Wrote {json_path}")
    print("Integrity checks passed.")


if __name__ == "__main__":
    build()
